ryanstubbs/flightmail

Flight PHP mail plugin wrapping Symfony Mailer. Multi-provider DSN support, Twig & Latte template rendering, fully extensible.

Maintainers

Package info

github.com/ryanstubbs/flightmail

pkg:composer/ryanstubbs/flightmail

Transparency log

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.0.0 2026-08-21 04:02 UTC

This package is auto-updated.

Last update: 2026-08-21 04:13:51 UTC


README

CI License: MIT PHP Packagist Version Packagist Downloads

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:1025 and read every captured email in Mailpit at http://<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 (renderer option).
  • 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.