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

Mailer Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Know what you already have

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.

2. Configure the transport the Laravel way

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}"

3. Bind the interfaces (Laravel does not do this for you)

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))
    );
}

4. First send (plain text)

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!'));
}

5. First send (HTML)

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.

Where to look first

  • 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.

Implementation Patterns

1. Default to the facade, drop down when needed

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')

2. Share one transport across the worker

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.

3. Failover and round-robin

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.

4. Reacting to messages (logging, suppression, rewriting)

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.

5. Attachments and inline images

$email->attachFromPath(storage_path('app/invoice.pdf'), 'Invoice.pdf', 'application/pdf')
      ->embedFromPath(public_path('logo.png'), 'logo')
      ->html('<img src="cid:logo"> ...');

6. Batched sends from a job

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();
    }
}

7. Testing

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

8. Envelope control

->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).


Gotchas and Tips

Reconnection behaviour (v8.1.7)

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:

  • Long-running queue workers and batch jobs no longer need one job per message just to survive idle drops — but keep --timeout/--max-time sane and restart workers (queue:restart) on deploy.
  • Reconnection re-does the handshake: TLS and AUTH are renegotiated, and any SMTP session state (e.g. RSET scope, pipelined transaction) is lost. Don't cache anything across sends that assumes a live session.
  • It does not make sends idempotent. A failure mid-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.
  • Connection counts: a fleet of workers each reconnecting can trip provider concurrency limits. Cap worker count and use setMaxPerSecond().
  • Verify it: reproduce by sending two messages with a 5+ minute gap (or restart your local MailHog/Postfix between them) on the same worker. On ≤8.1.5 you get an exception; on 8.1.7 the second send succeeds.

Template gotchas

  • 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.
  • With Blade, always send both html() and text(); some clients (and spam filters) penalise HTML-only bodies.

Silent failures and debugging

  • Swap in 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).

Pitfalls

  • Mail::fake() doesn't see raw MailerInterface sends — the most common source of "my test asserts nothing happened and it passed" confusion.
  • Header injection: never interpolate user input into subject()/addTextHeader() without stripping \r\n.
  • Non-ASCII attachment filenames can break older MTAs; pass an explicit ASCII name in attachFromPath().
  • Microsoft Graph / API transports ignore or reject some RFC headers (Return-Path, Sender); keep envelope handling in Envelope, not headers, if you may switch transports.
  • Attachments are loaded into memory — attachFromPath() streams, attach() does not. For big PDFs in batches, prefer paths and chunk your jobs.

Extension points

  • Custom transport: extend 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.
  • Keep credentials in .env and, if you hold multiple transports, resolve DSNs from config at boot rather than inside request handlers.

Performance checklist

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.

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.
robertmarney/lara-hierarchial-collections
simonschaufi/rule-doc-generator
emissaoja/emissaoja
blackbricksoftware/gravityforms-civicrm-form-processor
al-rahabi/fancy-crud
jinas/dhivatar-component
mltstephane/laravel-quality
bernskiold/laravel-talentlms
bernskioldmedia/laravel-form-architect
youbar/easy-crud
c-point-labs/laravel-hostyourai
dbravoan/dbcms-admin-theme-dbcms
dbravoan/dbcms-theme-db-cyber
dbravoan/dbcms-blocks-basic
dbravoan/dbcms-module-content-audit
dbravoan/dbcms-module-ai-assistant
paygate-to/paygate-php
padosoft/askmydocs-connector-base
defstudio/supercharged-carbon
bernskioldmedia/laravel-matomo