How to Send Transactional Email with Amazon SES from PHP
Verify your sending domain in Amazon SES and publish its DKIM records, create SMTP credentials for your region, then send over port 587 with STARTTLS using a library such as PHPMailer. Request production access to leave the sandbox, and set up SPF, DMARC and bounce handling before real users arrive.
SMTP or the SES API?
PHP apps can reach Amazon SES two ways. The SMTP interface works with any mail library, needs no AWS SDK and is easy to swap for another provider later. The SES API, through the AWS SDK for PHP, gives programmatic access to more features and avoids SMTP connection overhead at very high volume.
For sign-in codes, receipts and notifications, SMTP is usually enough. Transactional email means one-to-one messages triggered by something the user did: a login code, a receipt, a password-free sign-in link, a "your export is ready" notice. Keep marketing newsletters separate, ideally on a different subdomain or service, so a complaint about a campaign never delays someone's sign-in code. Two facts trip people up:
- SES SMTP credentials are not your AWS access key and secret. They are generated for SMTP, and the password differs from the secret access key.
- Credentials and sandbox status are per region. Credentials made in
eu-west-2do not work against theus-east-1endpoint.
Set up SES step by step
- Choose a region close to your users or your server, and stay in it for every step below.
- Verify a domain identity, not just one address. SES gives you DKIM CNAME records (Easy DKIM) to add at your DNS provider.
- Optionally set a custom MAIL FROM domain such as
mail.example.com, so SPF aligns with your domain for DMARC. - Publish a DMARC record (see below).
- Create SMTP credentials from the SES console's SMTP settings page. You can view the password only once.
- Request production access. In the sandbox you can send only to verified addresses, at most 200 messages per 24 hours and one per second. Choose "Transactional" and describe your sign-up flow and how you handle bounces. AWS states an initial response within 96 hours.
Send with PHPMailer
Install it with composer require phpmailer/phpmailer, keep credentials in environment variables, and always include a plain-text alternative:
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
$mail = new PHPMailer(true);
try {
$mail->isSMTP();
$mail->Host = getenv('SES_SMTP_HOST'); // e.g. email-smtp.eu-west-2.amazonaws.com
$mail->Port = 587;
$mail->SMTPAuth = true;
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->Username = getenv('SES_SMTP_USERNAME');
$mail->Password = getenv('SES_SMTP_PASSWORD');
$mail->Timeout = 15;
$mail->setFrom('hello@example.com', 'Example');
$mail->addAddress($to);
$mail->isHTML(true);
$mail->Subject = 'Your sign-in code';
$mail->Body = $html;
$mail->AltBody = $text;
$mail->send();
} catch (Exception $e) {
error_log('Mail failed: ' . $mail->ErrorInfo); // never log the password
}
SES requires TLS on every connection. Port 587 with STARTTLS is the usual choice; SES also accepts STARTTLS on 25 and 2587, and implicit TLS on 465 and 2465. If you are on EC2, port 25 is throttled by default.
SPF, DKIM and DMARC
Mailbox providers decide whether to trust you largely from these three records:
- DKIM signs each message. Easy DKIM's CNAME records sign with your domain, which also satisfies DMARC alignment.
- SPF lists servers allowed to send for a domain. With a custom MAIL FROM domain, add the SPF record SES gives you for that subdomain.
- DMARC tells receivers what to do when checks fail and where to send reports. Start in monitoring mode, then tighten once reports look clean:
_dmarc.example.com TXT "v=DMARC1; p=none; rua=mailto:dmarc@example.com"
Send from a real domain address such as hello@ or support@, and keep transactional messages short and clearly expected by the recipient.
Bounces, complaints and suppression
SES watches your bounce and complaint rates, and high rates can lead to sending being paused. Accounts that started using SES after 25 November 2019 use the account-level suppression list for bounces and complaints by default, so SES stops attempting delivery to addresses that hard-bounced or complained. Messages to suppressed addresses still count towards your daily quota.
If you want your own database to know, for example to flag an account whose address bounces, configure SES event publishing to Amazon SNS and process the notifications. Gmail does not feed spam complaints back to SES, so do not rely on complaint data alone.
Inside your app, the cheapest protections are upstream: validate addresses at sign-up, never send to lists you did not collect yourself, and rate-limit anything that triggers an email so nobody can use your sign-in form to bombard a stranger's inbox. Watch the reputation metrics on the SES account dashboard after launch and investigate any rise before it becomes a pause.
Safe in development, robust in production
- In development, write emails to a log file instead of sending, so test runs never hit real inboxes. For deliberate tests, use the SES mailbox simulator addresses.
- Do not let a mail failure break sign-up or checkout. Record the user or order first, then send, and make the send retryable.
- Build links from a configured base URL. For login emails specifically, see passwordless login in PHP.
- Rotate SMTP credentials if they are ever exposed: delete the SMTP user and create a new one.
- Store the SMTP password only in the server's environment or
.env, readable by the app user and never committed. The VPS deployment guide shows suitable file permissions.
Write emails that render and get read
Email clients render HTML inconsistently, so keep templates simple: a single-column layout, inline styles, a clear heading, one call-to-action button and a plain-text alternative with the same content. Put the important information in the text, not only in an image. For a sign-in email, the code itself should be large and copyable, and the subject should say what it is.
- Use one shared layout function so every message has the same header, footer and colours.
- Escape every variable you insert, exactly as you would on a web page.
- Add a short preheader, the preview text many clients show beside the subject.
- If users sign in with Apple and hide their address, register your sending domain with Apple's private email relay service, or those messages will not arrive. See adding Sign in with Apple and Google.
Sources: Amazon SES: SMTP credentials · Amazon SES: request production access · Amazon SES: connecting to an SMTP endpoint · Amazon SES: account-level suppression list
How Simple Stack handles it
Simple Stack sends every email through SES SMTP with PHPMailer, its one Composer dependency, using STARTTLS. Setup is in EMAIL.md. It relies on SES suppression rather than processing bounce notifications itself.
- Sends over SES SMTP with PHPMailer and STARTTLS; no AWS SDK is used at runtime
- MAIL_TRANSPORT=log writes emails to storage/logs/mail.log instead of sending them
- One branded email layout, themed to your accent colour, wraps every template
- app_send_email() sends ad hoc messages and returns false rather than throwing
- Optional welcome email and a send-once onboarding drip run from a cron job
- doctor --prod fails if mail is set to log-only or SES settings are missing
Questions people ask
Skip the plumbing. Build the product.
Sign-in, Stripe, emails, an admin and a CMS — already built and tested in plain PHP. £49, once, for unlimited projects.