ryanstubbs / flightmail
Flight PHP mail plugin wrapping Symfony Mailer. Multi-provider DSN support, Twig & Latte template rendering, fully extensible.
Requires
- php: >=8.2
- flightphp/core: ^3.15
- symfony/event-dispatcher: ^6.4 || ^7.2 || ^8.0
- symfony/http-client-contracts: ^3.2
- symfony/mailer: ^7.2 || ^8.0
- symfony/mime: ^7.2 || ^8.0
Requires (Dev)
- latte/latte: ^3.0
- phpstan/phpstan: ^2.1
- phpstan/phpstan-phpunit: ^2.0
- phpunit/phpunit: ^11.5 || ^12.0 || ^13.0
- twig/twig: ^3.20
Suggests
- latte/latte: ^3.0 — render message bodies with Latte
- symfony/amazon-mailer: Amazon SES DSN support (ses+https://)
- symfony/brevo-mailer: Brevo DSN support (brevo+api://)
- symfony/mailersend-mailer: MailerSend DSN support (mailersend+api://)
- symfony/mailgun-mailer: Mailgun DSN support (mailgun+api://, mailgun+https://)
- symfony/postmark-mailer: Postmark DSN support (postmark+api://)
- symfony/sendgrid-mailer: Sendgrid DSN support (sendgrid+api://)
- twig/twig: ^3.0 — render message bodies with Twig
README
Send email from your Flight PHP app without the headaches.
FlightMail is a small plugin that wraps Symfony Mailer - the most battle-tested mail library in PHP - and makes it feel like part of Flight. One line to install, one fluent chain to send:
Flight::mail()->compose() ->to('someone@example.com') ->subject('You did it!') ->text('Your first email is on its way.') ->send();
Why you'll like it:
- Any provider, one line each. SMTP, Postmark, Sendgrid, Mailgun, Amazon SES, Brevo and friends all work through simple DSN strings.
- Use several providers at once. Transactional mail through Postmark, newsletters through your own SMTP - pick per message.
- Templates if you want them. Render bodies with Twig or Latte. Don't want templates? Just pass strings and install nothing extra.
- Boring in the best way. Lazy connections, clear errors instead of silently swallowed mail, and everything is swappable if you need something custom.
Requirements
| What | Version |
|---|---|
| PHP | 8.2 or newer |
| Flight PHP | core ^3.15 |
| Symfony Mailer | ^7.2 or ^8.0 (installed automatically) |
Installation
composer require ryanstubbs/flightmail
That's it for sending plain-text and HTML emails. Template rendering is opt-in - add an engine only if you'll use it:
composer require twig/twig # for .twig templates composer require latte/latte # for .latte templates
Both can be installed side by side; FlightMail picks the right one based on the file extension.
Your first email
Add this to your bootstrap (the same place you define routes):
<?php require 'vendor/autoload.php'; use ryanstubbs\FlightMail\MailPlugin; // Tell FlightMail where to send mail from and through. MailPlugin::install([ 'dsns' => [ 'default' => 'smtp://user:pass@localhost:1025', ], 'from' => 'no-reply@example.com', ]); Flight::route('/signup', function () { Flight::mail()->compose() ->to('new-user@example.com') ->subject('Welcome aboard!') ->html('<h1>Welcome!</h1><p>We are glad you are here.</p>') ->send(); }); Flight::start();
Using the Flight PHP skeleton instead? Register in app/config/services.php with the instance style:
use ryanstubbs\FlightMail\MailPlugin; MailPlugin::register($app, [ 'dsns' => ['default' => 'smtp://user:pass@localhost:1025'], 'from' => 'no-reply@example.com', ]);
Both styles expose the same mailer: Flight::mail() and $app->mail() are interchangeable.
Testing locally? If your project runs in DDEV, point the DSN at
smtp://127.0.0.1:1025and read every captured email in Mailpit athttp://<project>.ddev.site:8025. Nothing leaves your machine.
Sending email
Plain strings (no template engine needed)
->text() and ->html() take raw strings and need nothing else installed:
Flight::mail()->compose() ->to('ops@example.com') ->subject('Backup finished') ->text('Nightly backup completed in 42 minutes.') ->send(); Flight::mail()->compose() ->to('billing@example.com') ->subject('Invoice #123') ->html('<h1>Invoice #123</h1><p>Total due: $42.00</p>') ->send();
Twig templates
// welcome.html.twig contains: Hello {{ name }}, thanks for signing up! Flight::mail()->compose() ->to('someone@example.com') ->subject('Welcome!') ->template('welcome.html.twig', ['name' => 'Ryan']) ->send();
Latte templates
Same idea, .latte extension:
// welcome.latte contains: Hello {$name}, thanks for signing up! Flight::mail()->compose() ->to('someone@example.com') ->subject('Welcome!') ->template('welcome.latte', ['name' => 'Ryan']) ->send();
HTML + plain text together
Best practice for deliverability - give mail clients both versions:
Flight::mail()->compose() ->to('someone@example.com') ->subject('Welcome!') ->template('welcome.html.twig', ['name' => 'Ryan']) // rich version ->textTemplate('welcome.txt.twig', ['name' => 'Ryan']) // fallback version ->send();
A few things worth knowing about templates:
- They render lazily, at send time - compose now, render later.
- The engine is chosen by extension:
.twig→ Twig,.latte→ Latte, anything else → your configured default (rendereroption). - An explicit
->html()or->text()body always wins over a template, so you can set a default template and override it per message.
Choosing a provider
Providers plug in through DSN strings. Install the bridge package, paste the DSN into dsns, done.
| Provider | Install | DSN example |
|---|---|---|
| SMTP | built-in | smtp://user:pass@host:587 |
| Sendmail | built-in | sendmail://default |
| Dev/null (drop mail) | built-in | null://null |
| Postmark | composer require symfony/postmark-mailer |
postmark+api://KEY@api.postmarkapp.com |
| Sendgrid | composer require symfony/sendgrid-mailer |
sendgrid+api://KEY@default |
| Mailgun | composer require symfony/mailgun-mailer |
mailgun+https://KEY:DOMAIN@api.mailgun.net |
| Amazon SES | composer require symfony/amazon-mailer |
ses+https://KEY:SECRET@default |
| Brevo | composer require symfony/brevo-mailer |
brevo+api://KEY@default |
| MailerSend | composer require symfony/mailersend-mailer |
mailersend+api://KEY@default |
The full list lives in the Symfony Mailer docs - anything documented there works here unchanged.
Multiple providers at once
Name each transport, then choose per message:
MailPlugin::install([ 'dsns' => [ 'transactional' => 'postmark+api://KEY@api.postmarkapp.com', 'bulk' => 'smtp://user:pass@bulk.example.com:587', ], 'from' => 'no-reply@example.com', ]);
// No ->transport() call = first key in "dsns" ("transactional" here). Flight::mail()->compose()->to('...')->text('receipt')->send(); // Opt into another route explicitly. Flight::mail()->compose()->to('...')->text('newsletter')->transport('bulk')->send();
Configuration reference
Everything is optional except dsns.
MailPlugin::install([ // REQUIRED - transport name => Symfony DSN. // The first entry is used when a message doesn't name one. 'dsns' => [ 'default' => 'smtp://user:pass@localhost:1025', ], // Transport used when a message has no explicit ->transport() and // you don't want the first key. Must exist in "dsns". 'default_transport' => 'default', // Global sender. String, Symfony Address, or ['email' => 'Name']. // Applied only when a message doesn't set its own ->from(). 'from' => ['no-reply@example.com' => 'My App'], // Default template engine: 'twig', 'latte', or a custom name. // Only consulted for templates whose extension isn't a registered renderer. 'renderer' => 'twig', // Where templates live, searched in order; plus an optional cache dir. 'templates' => [ 'paths' => [__DIR__ . '/mail-templates'], 'cache' => __DIR__ . '/cache/mail', ], // Extra options passed straight to Twig\Environment. 'twig' => ['options' => ['strict_variables' => true]], // Tweak the Latte engine at boot: fn(Latte\Engine $engine): void. 'latte' => ['setup' => static fn (Latte\Engine $e) => $e->addExtension(new MyExtension())], // Custom DSN schemes, custom renderers, pre-send hooks (see below). 'transport_factories' => [], 'renderers' => [], 'hooks' => [], // Optional plumbing handed to every transport. 'event_dispatcher' => $dispatcher, // Symfony MessageEvents 'logger' => $psr3Logger, ]);
Going further
Everything below is optional. The defaults cover most apps.
Add a custom DSN scheme
Implement Symfony's TransportFactoryInterface and register it - then your own scheme works exactly like a built-in one:
use ryanstubbs\FlightMail\MailPlugin; use Symfony\Component\Mailer\Transport\Dsn; use Symfony\Component\Mailer\Transport\TransportFactoryInterface; use Symfony\Component\Mailer\Transport\TransportInterface; class MyCarrierFactory implements TransportFactoryInterface { public function supports(Dsn $dsn): bool { return $dsn->getScheme() === 'mycarrier'; } public function create(Dsn $dsn): TransportInterface { // ... build a transport that talks to your carrier } } $plugin = MailPlugin::install(['dsns' => ['carrier' => 'mycarrier://key']]); $plugin->addTransportFactory(new MyCarrierFactory());
Add a custom template renderer
Anything that turns a template name plus params into a string qualifies:
use ryanstubbs\FlightMail\MailPlugin; use ryanstubbs\FlightMail\Render\RendererInterface; $plugin = MailPlugin::install($config); $plugin->addRenderer('markdown', fn (array $config): RendererInterface => new MarkdownMailRenderer($config['templates']['paths'] ?? []) );
// Templates ending in .markdown now use it automatically: Flight::mail()->compose()->to('...')->template('welcome.markdown', ['name' => 'Ryan'])->send();
Run something right before sending
Hooks receive the finished message - after rendering, after defaults, just before the wire:
$plugin->addHook(function (ryanstubbs\FlightMail\Message $message): void { $message->getHeaders()->addTextHeader('X-Mailer', 'MyApp/1.0'); });
Events and logging
Hand over a Symfony event dispatcher and/or PSR-3 logger and every transport will use them:
$plugin->eventDispatcher($dispatcher); // receives MessageEvent before each send $plugin->logger($logger); // transport-level logs
Using FlightMail outside Flight
The core has no framework coupling - instantiate it anywhere:
use ryanstubbs\FlightMail\Mailer; use ryanstubbs\FlightMail\Render\RendererFactory; use ryanstubbs\FlightMail\Transport\TransportManager; $mailer = new Mailer( new TransportManager(['default' => 'smtp://127.0.0.1:1025']), new RendererFactory(['templates' => ['paths' => [__DIR__ . '/templates']]]), ); $mailer->compose()->to('...')->template('welcome.latte', ['name' => 'Ryan'])->send();
API cheat sheet
// Setup MailPlugin::install($config) // register on the global Flight app MailPlugin::register($app, $config) // register on a specific Engine $mailer = Flight::mail(); // the shared Mailer instance // Building messages $mailer->compose(): Message $message->to(...)->from(...)->subject(...) // standard Symfony Mime methods $message->text(string) // plain string body $message->html(string) // HTML string body $message->template($name, $params) // HTML body from a template $message->htmlTemplate($name, $params) // alias of template() $message->textTemplate($name, $params) // text body from a template $message->transport($name) // route via a named DSN $message->send(): ?SentMessage // render + send // On the mailer itself $mailer->send($message): ?SentMessage // explicit alternative to $message->send() $mailer->render($template, $params): string // render without sending $mailer->addHook(callable): static // fn(Message $message): void $mailer->transports(): TransportManager // get() / has() / names() $mailer->renderers(): RendererFactory // create() / has() / add()
Since Message extends Symfony\Component\Mime\Email, every Symfony method you already know - attach(), embed(), priority(), replyTo() - works out of the box.
Troubleshooting
"No mail DSNs configured"
You called Flight::mail() before registering the plugin, or the config array didn't include dsns. This error is deliberate - FlightMail refuses to guess where your mail should go rather than silently dropping it.
"Unknown mail template renderer ..."
You used a template whose engine isn't installed. Fix with composer require twig/twig or composer require latte/latte, or register a custom renderer named after the extension.
"Unknown mail transport ..."
A ->transport('name') (or default_transport) doesn't match any key in dsns. Check spelling - the error lists the configured names.
Mail isn't arriving
Point dsns at null://null to confirm the rest of your code works, then switch back to the real DSN. In DDEV, use smtp://127.0.0.1:1025 and inspect messages in Mailpit at port 8025.
Development
This repository is a DDEV project:
ddev start ddev composer install ddev composer test # PHPUnit ddev composer analyse # PHPStan
Contributing & license
Bug reports and pull requests welcome. MIT licensed - see LICENSE.