daniel-zahariev/php-aws-ses
A lightweight, dependency-free PHP wrapper for Amazon SES. Send HTML and plain-text emails, handle attachments, bulk sending, and multiple recipients through the SES API without pulling in the full AWS SDK. Simple configuration with access keys and region.
This is a dependency-free wrapper around the SES Query (REST-like) API — no aws/aws-sdk-php, no credential chain, just cURL and three global classes. That is both its selling point (tiny footprint, works on locked-down hosts, drops into legacy PHP) and its limit.
composer require daniel-zahariev/php-aws-ses
php -m | grep curl # ext-curl is the only hard requirement
Look here first: src/SimpleEmailService.php (sendEmail(), getErrors(), setRegion()) and src/SimpleEmailServiceMessage.php (setFrom, addTo, setSubject, setMessageHtml, setMessageText, addAttachment, addCustomHeader). Everything you will do day-to-day is those two files. The classes are global namespace (\SimpleEmailService, not DanielZahariev\...), so import with a leading backslash.
Minimal wiring in Laravel:
// config/services.php
'ses_rest' => [
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'eu-west-1'),
],
// AppServiceProvider::register()
$this->app->singleton(\SimpleEmailService::class, function ($app) {
return (new \SimpleEmailService())
->setAccessKey(config('services.ses_rest.key'))
->setSecretKey(config('services.ses_rest.secret'))
->setRegion(config('services.ses_rest.region')) // also selects SigV4
->setConnectTimeout(5)
->setTimeout(15);
});
First use case: password resets and order receipts, sent from a queued job, not inline in a request. Before anything else, verify your domain once (verifyDomain('example.com')) and confirm the identity shows up in the console — in the SES sandbox every recipient also needs verification, so test with your own address.
$m = new \SimpleEmailServiceMessage();
$m->setFrom('[email protected]', 'Example');
$m->addTo($user->email);
$m->setSubject('Your receipt');
$m->setMessageText($text);
$m->setMessageHtml($html);
$ses->clearErrors();
if (! $ses->sendEmail($m)) {
throw new \RuntimeException(json_encode($ses->getErrors()));
}
Wrap it behind your own interface, always. The package's boolean return is the single biggest day-to-day hazard, so the one thing you should never do is call $ses->sendEmail() from a controller. Put it behind App\Mail\SesRestMailer so you can swap in the official SDK later and so Mail::fake()-style tests remain possible.
interface TransactionalMailer { public function send(EmailMessage $msg): void; }
Custom Laravel mail transport (Laravel 9+). Register via Mail::extend('ses-rest', fn () => $transport) and return a Symfony\Component\Mailer\Transport\TransportInterface. Map the Symfony Email onto SimpleEmailServiceMessage: getFrom(), getTo()/getCc()/getBcc(), getSubject(), getHtmlBody(), getTextBody(), and iterate getAttachments() into addAttachment(). This keeps Mail::to($user)->send(new Receipt($order)) and all your existing Mailables/Blade views working. On Laravel 8 and below (SwiftMailer) the callback must return a fully-built Illuminate\Mail\Mailer with a Swift_Transport, which is more work — for old versions, skip the transport and use a job.
Render Mailables manually when you skip the transport. $mailable->render() gives HTML; use $mailable->assertHasSubject-friendly data and view() for the text alternative. Then feed the wrapper class from a queued job.
Queue + throttle, because the package does none. SES enforces a per-second send rate and a 24-hour quota. Parallel workers will blow straight through it.
public function middleware():.array {
return [(new RateLimited('ses'))->dontReport()];
}
public $tries = 3;
public $backoff = [5, 30, 120];
Configure the ses limiter just under your actual SES rate. Retry connection errors and 5xx/Throttling; do not retry MessageRejected, InvalidParameterValue, or ConfigurationSetDoesNotExist — those will fail identically three times.
Configuration sets + custom headers for observability. Bounce/complaint handling is not optional with SES. Attach a config set to every message so events land in SNS/EventBridge:
$m->addCustomHeader('X-SES-CONFIGURATION-SET', 'transactional');
Same mechanism for List-Unsubscribe / List-Unsubscribe-Post on bulk mail — required by Gmail/Yahoo since 2024 and there is no first-class API for it here.
Build the message object per send, never reuse it. SimpleEmailServiceMessage accumulates recipients; a singleton message across a loop sends to everyone in one To: header. Also call clearErrors() before each send — the error array is append-only on the service instance.
Attachments: addAttachment($absolutePath, $displayName) for files on disk; addStringAttachment($bytes, $name) for generated PDFs. Read from Storage::path() in a job, not from a request-scoped temp file.
Quota monitoring as a scheduled command. getSendQuota() and getSendStatistics() are cheap and let you alert before you hit the ceiling — worth a daily Schedule::command() once you are past a few thousand a month.
sendEmail() returns false and stashes XML errors in getErrors(); it does not throw. A queued job that just calls it will "succeed" while the user gets nothing. Always convert a falsy return into an exception and log getResponseCode() + getErrors() together.setRegion() even if you override the endpoint. Region is what flips the client to SigV4. Leave it at the us-east-1 default and sends to eu-west-1 identities fail with SignatureDoesNotMatch / InvalidSignatureException — an error that looks like bad credentials.aws/aws-sdk-php instead.MessageRejected: Email address is not verified means you are still sandboxed (200/day, 1/sec, verified recipients only) or your From domain isn't verified. Request production access early.\r and \n from user-supplied values before they reach setSubject, setFrom, addTo, or addCustomHeader. MIME requires CRLF and the package will happily build a malformed message that SES rejects with a vague InvalidParameterValue.setMessageText().Return-Path to a verified domain. Otherwise bounces route to your From: address and poison your sender reputation. Pair it with a config set that publishes bounces and complaints to SNS, and actually process the SNS topic.To/Cc. Every recipient sees the others. Use per-recipient addBcc() at minimum, and keep the per-message recipient count low to avoid throttling bursts.setConnectTimeout(5)/setTimeout(15) so a hung SES call doesn't pin a queue worker; only disable setVerifyPeer() on local/dev boxes.SimpleEmailServiceMessage each send and clear errors. If you route across regions for data residency, build a separate client instance per region rather than mutating one.setFrom) vary slightly across 1.x tags — read the actual class in vendor/ rather than trusting docs. Pin the version in composer.json.getRequest() and getResponse() expose the raw query params and XML. Log them at debug level when a send fails; the error objects alone often omit the offending parameter. Subclassing SimpleEmailService to override sendEmail() with logging and one retry is a legitimate, low-risk extension.TransactionalMailer interface. When you outgrow it — IAM roles, SES v2 features, per-message event publishing, better error granularity — Symfony Mailer's SES transport (or the SDK's SesV2Client) drops in without touching a single call site.How can I help you explore Laravel packages today?