One DSN, three PHP ecosystems: MailKite's Symfony Mailer transport
mailkite/symfony-mailer resolves a mailkite+api:// DSN into a first-party Symfony Mailer transport — which means Mautic, PrestaShop, Drupal, and any plain Symfony app all get it from one package, with honest errors instead of a generic SMTP rejection.
TL;DR: mailkite/symfony-mailer is a new
package that turns mailkite+api://API_KEY@default into a valid Symfony Mailer DSN. One
composer require, and every MailerInterface::send() in a Symfony app — or in Mautic,
PrestaShop, or anything else built on Symfony Mailer — delivers through MailKite’s Send API
instead of SMTP. It’s extracted from our existing mailkite/laravel package, which now
depends on it rather than carrying its own copy of the mapping logic. 15 tests, no network,
all green. composer require mailkite/symfony-mailer will resolve once the package clears
Packagist’s one-time submission step — until then, install straight from the
GitHub repo (v0.1.0 is tagged).
Why one package reaches three ecosystems
Symfony Mailer resolves transports from a DSN string. That’s a small design choice with a large consequence: any platform built on Symfony Mailer inherits the same extension point, whether or not that platform has anything else to do with Symfony.
use MailKite\Mailer\Transport\MailKiteTransportFactory;
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Transport\Dsn;
$transport = (new MailKiteTransportFactory())->create(
Dsn::fromString('mailkite+api://mk_live_xxx@default')
);
$mailer = new Mailer($transport);
Write a TransportFactoryInterface implementation once, and it’s live everywhere that
mechanism is used:
- A plain Symfony app — set
MAILER_DSNin.env, done. Autoconfiguration picks up the factory the moment the package is installed. - Mautic 5+ — replaced SwiftMailer with Symfony Mailer and a DSN field in Configuration → Email Settings. Mautic is worth calling out specifically: it’s a marketing-automation platform, so email volume per install runs far ahead of a typical CMS or CRM contact form.
- PrestaShop 8+ — Symfony-based mailer since PS8, same DSN mechanism.
- Drupal, via the Symfony Mailer Lite module — a smaller install base (17,528 sites) but the same wiring.
We didn’t build four integrations. We built one, and the DSN did the rest.
What was actually new here — and what wasn’t
The transport itself isn’t new code. It’s the same AbstractTransport subclass that’s
lived inside mailkite/laravel since that package shipped: doSend() converts a
SentMessage into a MailKite send payload via MessageConverter::toEmail(), calls the
API, and stamps the returned message ID back onto the message.
What’s new is the factory — the piece that resolves a DSN string into that transport — and moving the transport itself out from under Laravel so it isn’t Laravel’s to own:
final class MailKiteTransportFactory extends AbstractTransportFactory
{
public function create(Dsn $dsn): TransportInterface
{
$apiKey = $this->getUser($dsn); // throws IncompleteDsnException if missing
$host = $dsn->getHost();
$endpoint = $host === 'default'
? 'https://api.mailkite.dev'
: sprintf('https://%s%s', $host, $dsn->getPort() !== null ? ':' . $dsn->getPort() : '');
return new MailKiteTransport(new Client($apiKey, $endpoint));
}
protected function getSupportedSchemes(): array
{
return ['mailkite', 'mailkite+api'];
}
}
mailkite/laravel’s composer.json now requires mailkite/symfony-mailer and its
MailKiteServiceProvider instantiates MailKite\Mailer\Transport\MailKiteTransport
directly — the ~200 lines of address, body, and attachment mapping logic exist in exactly
one place. The Laravel package’s own test suite dropped from 14 tests to 4, because it’s
no longer testing logic it doesn’t own; it tests wiring (does MAIL_MAILER=mailkite
resolve to the right transport, does the SDK client singleton bind, does a missing key
throw a helpful error).
Honest failures instead of a generic SMTP rejection
This is the part worth dwelling on if you’re currently pointing Mautic, PrestaShop, or Drupal at generic SMTP. SMTP gives you a numeric code and maybe a line of server-specific text. The MailKite API returns its own error message — “domain not verified,” a suppressed recipient, a rate limit — and this transport surfaces it verbatim instead of translating it into an SMTP-shaped abstraction that loses the detail:
try {
$mailer->send($email);
} catch (TransportException $e) {
// "Unable to send an email: Can't send yet — domain not verified. (MailKite API status 403)."
}
The same honesty applies to what the transport won’t silently drop:
| Situation | What happens |
|---|---|
Custom header (X-Campaign, a tag/metadata header) | Throws — the API’s envelope has no room for it, so refusing beats a header that quietly never arrives |
Inline (cid:-embedded) image | Throws — the API has no Content-ID support; host the image at a URL instead |
| More than one reply-to address | Throws — the API accepts exactly one |
Unverified from domain | Throws with the API’s own message and HTTP status |
Every one of those is a case where a generic SMTP transport would either accept the message and let it fail downstream, or reject it with a code that doesn’t say why. This one tells you the actual reason at the call site.
The batch-sending trade-off, made explicit
Symfony-provided transports send one email per request — that’s the TransportInterface
contract, and MailKiteTransport follows it like every other bridge. Most third-party
Symfony Mailer transports stop there: no batch path, full stop, one HTTP round-trip per
recipient no matter how many you’re sending.
We didn’t want to pretend our transport batches when it doesn’t, so it’s documented
instead: for high-volume sends — a Mautic campaign to a real list, a PrestaShop order-email
backlog — call the PHP SDK’s sendBatch() directly rather than looping mailer->send()
per message. It’s a different code path for a different job, not a workaround bolted onto
the transport.
Try it
composer require mailkite/symfony-mailer
# .env
MAILER_DSN=mailkite+api://mk_live_xxx@default
Full mapping table, troubleshooting, and the Mautic-specific walkthrough: mailkite.dev/docs/integrations/symfony-mailer. Running Mautic specifically? See mailkite.dev/docs/integrations/mautic for the DSN field location and an SMTP fallback for managed hosts without shell access.
Using Laravel instead of raw Symfony Mailer? mailkite/laravel
wraps this same transport with MAIL_MAILER=mailkite and Laravel’s own config conventions —
nothing to change there, it’s already using the code in this post.