symfony/mailer
Symfony Mailer is a flexible PHP component for sending emails via SMTP, sendmail, or third-party providers. It supports rich HTML and text messages, attachments, Twig templates, and event-driven transport configuration for clean, testable email delivery in any PHP application.
symfony/mailer ships inside Laravel (it's the engine behind the Mail facade since Laravel 9). Check your pinned version before reading any docs:
composer show symfony/mailer
composer update symfony/mailer # brings you to v8.1.7
You only need this package directly when you want something the facade doesn't expose: multiple named DSN transports, failover/round-robin, SMTP connection tuning, MessageEvent listeners, or TemplatedEmail.
Keep using .env / config/mail.php — the DSN style maps 1:1:
MAIL_MAILER=smtp
MAIL_HOST=smtp.example.com
MAIL_PORT=587
MAIL_USERNAME=...
MAIL_PASSWORD=...
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS="[email protected]"
MAIL_FROM_NAME="${APP_NAME}"
Symfony\Component\Mailer\MailerInterface is not in the container by default. Reuse the transport Laravel already built so you share connection settings and reuse the SMTP socket:
// app/Providers/AppServiceProvider.php
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Transport\TransportInterface;
public function register(): void
{
$this->app->singleton(TransportInterface::class, fn ($app) =>
$app['mail.manager']->mailer()->getSymfonyTransport()
);
$this->app->singleton(MailerInterface::class, fn ($app) =>
new Mailer($app->make(TransportInterface::class))
);
}
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
public function send(MailerInterface $mailer): void
{
$mailer->send((new Email())
->from(config('mail.from.address'))
->to('[email protected]')
->subject('Welcome!')
->text('Thanks for signing up!'));
}
In Laravel, render Blade to a string rather than pulling in Twig:
$html = view('emails.welcome', ['name' => 'John'])->render();
$mailer->send((new Email())
->from(config('mail.from.address'))
->to('[email protected]')
->subject('Welcome!')
->html($html)
->text(strip_tags($html)));
TemplatedEmail only pays off if you actually add symfony/twig-bridge and construct the Mailer with a Twig\Environment — Laravel has no Twig environment out of the box.
config/mail.php — mailers, DSNs, from.app/Providers/AppServiceProvider.php — where the bindings above go.resources/views/emails/ — Blade templates (.blade.php, not .twig).vendor/symfony/mailer/Transport/Smtp/SmtpTransport.php — the class changed in 8.1.7.Mail::to(...)->send(...) covers ~90% of day-to-day work (Mailables, queued mailables, Mail::fake(), localization). Reach for the low-level API when you need:
| Need | Use |
|---|---|
| Failover / load balancing across MTAs | FailoverTransport / RoundRobinTransport |
| Per-second rate limiting | SmtpTransport::setMaxPerSecond() |
| Long-lived worker reusing one SMTP connection | singleton TransportInterface |
| Intercept/modify every message | MessageEvent listener |
| Provider-specific DSNs | Transport::fromDsn('api://key@default') |
The real cost of SMTP is the connect + TLS + AUTH handshake. Bind the transport as a singleton (step 3 above) so queued jobs reuse the open channel instead of reconnecting per job. This is exactly the setup the 8.1.7 reconnect fix targets.
use Symfony\Component\Mailer\Transport;
use Symfony\Component\Mailer\Transport\FailoverTransport;
use Symfony\Component\Mailer\Transport\RoundRobinTransport;
use Symfony\Component\Mailer\Mailer;
$transport = new FailoverTransport([
Transport::fromDsn('smtp://user:[email protected]:587'),
Transport::fromDsn('smtp://user:[email protected]:587'),
]);
$this->app->singleton(MailerInterface::class, fn () => new Mailer($transport));
Use FailoverTransport for resilience, RoundRobinTransport only if your provider bills/limits per connection — round-robin on transactional mail risks inconsistent deliverability and IP reputation.
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\Mailer\Event\MessageEvent;
$dispatcher = new EventDispatcher();
$dispatcher->addListener(MessageEvent::class, function (MessageEvent $event) {
$event->getMessage()->getHeaders()->addTextHeader(
'X-Request-Id', request()->header('X-Request-Id', 'n/a')
);
});
$transport = Transport::fromDsn($dsn, $dispatcher);
Symfony's dispatcher is not Laravel's; don't pass app('events') (different interface). Register the EventDispatcher as a singleton and forward to Laravel events/logging inside the listener if you want them in your app's event system. Also note $event->getMessage() is a RawMessage once serialized — mutate Email instances before sending rather than after.
$email->attachFromPath(storage_path('app/invoice.pdf'), 'Invoice.pdf', 'application/pdf')
->embedFromPath(public_path('logo.png'), 'logo')
->html('<img src="cid:logo"> ...');
public function handle(MailerInterface $mailer): void
{
$transport = app(TransportInterface::class);
if ($transport instanceof \Symfony\Component\Mailer\Transport\Smtp\SmtpTransport) {
$transport->setMaxPerSecond(20); // stay under provider throttle
}
foreach ($this->recipients->chunk(200) as $chunk) {
foreach ($chunk as $user) {
$mailer->send($this->buildEmail($user));
}
// Optional: keep the socket warm / detect dead peers
method_exists($transport, 'ping') && $transport->ping();
}
}
Mail::fake() only swaps Laravel's mailer — it will not intercept a raw MailerInterface send. In tests, rebind the transport:
$this->app->instance(TransportInterface::class, new \Symfony\Component\Mailer\Transport\NullTransport());
// or LogTransport to assert on the raw MIME
->from() sets the header; the SMTP MAIL FROM comes from the envelope. When they must differ (VERP, bounce handling), use ->returnPath() or pass an explicit Envelope via $mailer->send($email, $envelope).
Bug #65967 makes the SMTP transport reconnect when the server has closed the transmission channel (common after idle timeouts, MTA restarts, or load-balancer idle cutoffs returning 421). Previously this surfaced as TransportException: Connection to "…" failed or a stream write on a dead socket, killing the whole queue job.
What this means in practice:
--timeout/--max-time sane and restart workers (queue:restart) on deploy.RSET scope, pipelined transaction) is lost. Don't cache anything across sends that assumes a live session.DATA still throws, and the server may already have accepted the message. Wrap sends with a retry that has an upper bound and consider a dedupe/idempotency header (X-Request-Id) for critical mail.setMaxPerSecond().TemplatedEmail::htmlTemplate('emails/welcome.twig') needs a configured Twig\Environment passed to the Mailer constructor and a Twig loader pointing at your view path. There is no default Twig in Laravel — if you go this route, register the environment as a singleton or you'll recompile templates on every send.html() and text(); some clients (and spam filters) penalise HTML-only bodies.LogTransport to inspect raw MIME without sending:
$this->app->instance(TransportInterface::class, new \Symfony\Component\Mailer\Transport\LogTransport(logger()));
dump($email->getHeaders()->toString()) and $email->getBody()->bodyToString() catch encoding/header problems early.Transport::fromDsn() throws on malformed DSNs at boot — a Could not parse DSN in the logs is usually a URL-encoded password (@, : must be percent-encoded).Mail::fake() doesn't see raw MailerInterface sends — the most common source of "my test asserts nothing happened and it passed" confusion.subject()/addTextHeader() without stripping \r\n.name in attachFromPath().Return-Path, Sender); keep envelope handling in Envelope, not headers, if you may switch transports.attachFromPath() streams, attach() does not. For big PDFs in batches, prefer paths and chunk your jobs.AbstractTransport (or AbstractHttpTransport for APIs) and register the scheme so Transport::fromDsn('myapi://…') resolves to it.Email subclass for domain methods (NewsletterEmail::addUnsubscribeLink()), but remember the message is serialized to RawMessage — keep mutations before send().MessageEvent listener is the clean hook for suppression lists and audit logging; return early ($event->isPropagationStopped()) rather than mutating a RawMessage..env and, if you hold multiple transports, resolve DSNs from config at boot rather than inside request handlers.Singleton transport → queued sending → setMaxPerSecond() for provider throttles → chunked batches → cache compiled templates. That combination gets you near the throughput ceiling of your SMTP provider without touching the wire protocol.
How can I help you explore Laravel packages today?