Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Amazon Mailer Laravel Package

symfony/amazon-mailer

Amazon SES transport for Symfony Mailer, supporting SMTP, HTTPS, and API DSNs with configurable region and session tokens, plus STARTTLS enforcement controls for secure message delivery.

View on GitHub
Deep Wiki
Context7

Getting Started

This bridge only does one thing: it registers the ses+smtp, ses+https, and ses+api schemes with Symfony Mailer's Transport::fromDsn() factory. Laravel already runs on Symfony Mailer, so "integration" means wiring a DSN into a mailer entry — no SDK, no service class, no facade wrapper.

1. Install a version that matches your framework's Symfony components

composer require symfony/amazon-mailer:^7.4   # Laravel 11/12 (Symfony 7.x, PHP 8.2+)
# composer require symfony/amazon-mailer:^8.0 # only on PHP 8.4+ / Symfony 8

Laravel 10 pins Symfony 6.4 — use ^6.4 there. A mismatched major will conflict with symfony/mailer and fail to install.

2. Register the transport once, in a service provider

// app/Providers/AppServiceProvider.php  (boot())
use Illuminate\Support\Facades\Mail;
use Symfony\Component\Mailer\Transport;

Mail::extend('ses', function (array $config) {
    // $config is the whole mailer array from config/mail.php
    return Transport::fromDsn($config['dsn']);
});

Transport::fromDsn() auto-detects the Amazon factory as soon as the bridge package is autoloadable, so you never touch SesTransportFactory directly.

3. Add a mailer entry

// config/mail.php
'ses' => [
    'transport' => 'ses',                    // matches Mail::extend() key above
    'dsn' => env('AWS_SES_DSN'),
],
MAIL_MAILER=ses
AWS_SES_DSN=ses+api://AKIA...:SECRET...@default?region=us-east-1
[email protected]
MAIL_FROM_NAME="Acme"

First use case: a single transactional send (order receipt / password reset) through a queued Mailable:

Mail::mailer('ses')->to($order->email)->send(new OrderShipped($order));

Where to look first: the README's DSN table (that's the entire public API surface), then the changelog for ses+api — that transport is the one that supports raw MIME, attachments, and custom headers. Prefer ses+api from day one; ses+https is the older v1 SendEmail call with real limitations.

Before writing any code, confirm you're out of the SES sandbox (verified identities only, 200 msgs/day, 1 msg/sec) and that MAIL_FROM_ADDRESS is a verified identity in the same region as the DSN.

Implementation Patterns

Keep the DSN out of env() calls that bypass config. Build it in a config file or a small accessor so secrets get URL-encoded exactly once:

// config/services.php
'ses' => [
    'key'    => env('AWS_ACCESS_KEY_ID'),
    'secret' => env('AWS_SECRET_ACCESS_KEY'),
    'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],
'aws_ses_dsn' => sprintf(
    'ses+api://%s:%s@default?region=%s',
    rawurlencode(config('services.ses.key')),
    rawurlencode(config('services.ses.secret')),
    config('services.ses.region'),
),

Two mailers, one codebase. Route bulk/broadcast through SES and keep a premium or SMTP-relay mailer for deliverability-critical flows. Laravel lets you pick per send, which is exactly what the product assessment above recommends:

Mail::mailer($user->isEnterprise() ? 'postmark' : 'ses')
    ->to($user->email)
    ->send(new InvoiceReady($invoice));

Per-category SES event routing with configuration sets. One header gives you separate SNS topics for transactional vs. marketing so bounce suppression on marketing never blocks receipts:

$mailable->withSymfonyMessage(function (Email $email) {
    $email->getHeaders()->addTextHeader('X-SES-CONFIGURATION-SET', 'transactional');
});

One-click unsubscribe (bulk/compliance) — ses+api only:

$email->getHeaders()->addTextHeader(
    'X-SES-LIST-MANAGEMENT-OPTIONS',
    'List-Unsubscribe=One-Click'
);

Queue everything, then rate-limit the queue, not the mailer. SES throughput is an account quota; Symfony Mailer has no queue, no retry loop, no backoff you control. Laravel's queue is the correct throttle point:

public function middleware(): array
{
    return [(new RateLimited('ses'))->releaseAfter(1)];
}
// AppServiceProvider::boot()
RateLimiter::for('ses', fn (Job $job) => Limit::perSecond(10)); // match your SES send rate

Pair with $tries = 5, backoff() returning [10, 60, 300], and retryUntil() so throttling (Throttling / 429) resolves instead of bouncing to the user.

Normalize recipients before looping. The bridge sends one message per call; looping ->to() inside a single Mail::send() leaks recipients between users. Either send one message per recipient (safest) or batch into Bcc with the SES 50-recipient cap in mind.

Handle bounces/complaints outside the package. The bridge gives you nothing here. Point the SES configuration set's SNS topic at a Laravel route, verify the SNS signature, and add addresses to a suppressed_emails table checked in a Mailable's build() or a global MessageSending listener.

Local/staging: keep the custom transport registered but switch MAIL_MAILER to log or array in .env.testing / .env.local. Guard against a null DSN so Mail::extend doesn't throw during php artisan config:cache or Mail::fake() in CI.

Gotchas and Tips

The default region is eu-west-1. Forget ?region= and you'll sign requests for Ireland while your verified identities live in us-east-1 — you get MessageRejected or a signature mismatch that looks like a credential problem. Always set it explicitly.

@default is a mandatory placeholder, not a host. ses+api://KEY:SECRET@us-east-1?... is invalid; the region goes only in the query string. Omit @default and DSN parsing throws InvalidArgumentException.

URL-encode the secret. Base64 AWS secrets routinely contain +, /, and =. Unencoded, parse_url() truncates or misreads them and you get SignatureDoesNotMatch with credentials that look correct in the dashboard. rawurlencode() both halves of the userinfo.

ses+https ≠ ses+api. ses+https is the v1 SendEmail API: simple subject/body only, no attachments, and custom X-SES-* headers are dropped. ses+api uses SES v2 with raw MIME — attachments, custom headers, and full Symfony Email fidelity (including the recent "preserve the sent message object as-is" fix). Default to ses+api; only fall back to SMTP if an operator-controlled egress proxy forces it.

Pin minimum 7.4.6 / 8.0.6 if you use custom headers. The ses+api custom-header path had an encoding bug fixed in those releases (7.3.0 introduced the header support itself). Older patches silently mangle non-ASCII header values.

SMTP port semantics are enforced for you. Ports 465/2465 are implicit TLS; anything else (587, 2587) starts cleartext and requires STARTTLS, aborting otherwise. require_tls=0 exists for middlebox-stripping networks — never ship it to production, and never use ses+smtp unless you have to; it's strictly worse than ses+api for anything with attachments.

Static credentials in the DSN fight with IAM roles. If you run on ECS/EKS/EC2, leave the userinfo empty (ses+api://default?region=...) so AsyncAws falls back to its default credential chain. But note that session_token in the DSN is static — STS credentials expire in hours, so for assumed roles you must register the transport programmatically with freshly-minted keys rather than reading one cached DSN string. This is the single biggest operational trap for EKS deployments.

config:cache freezes the DSN. Rotating a key means php artisan config:cache again (or a deploy). Also: never call env() inside the Mail::extend closure — it returns null under cached config.

Provider ordering matters. Register Mail::extend() in AppServiceProvider::boot() (not a deferred provider) and before anything resolves the mailer; a container-resolved Mailer from a package's boot() can otherwise bind the default transport first.

Debugging recipe. When a send fails, bypass the Mailable and drive the transport directly to isolate DSN vs. message problems:

$transport = Mail::mailer('ses')->getSymfonyTransport();
$transport->send($email); // throws TransportExceptionInterface with the SES reason intact

Catch Symfony\Component\Mailer\Exception\TransportExceptionInterface; inner SES errors surface as AsyncAws\Core\Exception\HttpClientException subclasses. MessageRejected: Email address is not verified = sandbox or wrong region; SignatureDoesNotMatch = encoding; Throttling = quota, not code.

Attachments + queues. Queued mailables serialize attachment paths, not contents. Generate attachments into persistent storage (storage/app/...) before dispatch, or use Attachment::fromData() with a closure — a temp file cleaned up by the request lifecycle will be gone when the worker runs. Large attachments also push you past SES's 10 MB message cap, which the client surfaces as a transport exception, not a validation error.

Mail::fake() still needs a resolvable mailer. Laravel swaps in a fake after resolving the mailer config, so an unparseable DSN in config/mail.php breaks tests that never send mail. Use a dummy-but-valid DSN in .env.testing.

Consider the built-in ses transport first. Laravel's native 'transport' => 'ses' already drives SES via aws/aws-sdk-php. This bridge is the better choice when you want (a) ses+api raw MIME with X-SES-* headers, (b) the AsyncAws dependency footprint instead of the full AWS SDK, or (c) one DSN string shared with a Symfony service. If you only need plain sends and already ship aws/aws-sdk-php, adding this bridge is redundant dependency weight.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
ably/pubsub-server
mstfkhazaal/defender
mfonte/base62x
karnoweb/metapilot
tobya/bcsapiwrapper
swalker2/cpanel
vish4395/laravel-translation-manager
morningtrain/economic
teksite/systeminfo-laravel
atomcms/voting
run6/weather
siokas/laravelembeddirectives
adamlien/pkuuids
gilsonsouza/lara-doc-generator
manelgavalda/enrollment_mobile
unabomber/timezone
delatbabel/contacts
astritzeqiri/g-recaptcha
kevinongko/laravel-genode
developeruz/laravel-analytics