symfony/brevo-mailer
Brevo (formerly Sendinblue) mailer integration for Symfony Mailer, supporting SMTP and API transports plus custom headers for templates, tags, params, and metadata.
symfony/brevo-mailer is a transport bridge for Symfony Mailer, exposing two DSN schemes:
| DSN | Transport | Notes |
|---|---|---|
brevo+api://KEY@default |
BrevoApiTransport (HTTP) |
One POST to api.brevo.com/v3/smtp/email per message |
brevo+smtp://USER:PASS@default[:port] |
EsmtpTransport |
Port defaults to 465 (implicit TLS); any other port starts clear-text and upgrades via STARTTLS |
Laravel's mail layer is Symfony Mailer, so the bridge drops straight in — but unlike a Symfony app, Laravel does not auto-register the mailer.transport_factory tag. Nothing in config/mail.php will make brevo+api work on its own; you must register a transport yourself with Mail::extend().
Steps
composer require symfony/brevo-mailer # match the symfony/mailer major already pulled in by Laravel
// config/services.php
'brevo' => [
'key' => env('BREVO_API_KEY'),
'template_ids' => [
'invoice' => (int) env('BREVO_TEMPLATE_INVOICE'),
],
],
// config/mail.php
'brevo' => [
'transport' => 'brevo',
// 'dsn' => env('BREVO_MAIL_DSN'), // optional; omit to build it from the API key
],
// app/Providers/AppServiceProvider.php
use Illuminate\Support\Facades\Mail;
use Symfony\Component\Mailer\Bridge\Brevo\Transport\BrevoTransportFactory;
use Symfony\Component\Mailer\Transport;
public function boot(): void
{
Mail::extend('brevo', function (array $config) {
$dsn = $config['dsn']
?? 'brevo+api://'.config('services.brevo.key').'@default';
// Transport aggregates factories; both ctor args of the factory are nullable.
return (new Transport([new BrevoTransportFactory()]))->fromString($dsn);
});
}
Mail::mailer('brevo')->to($user)->send(new InvoiceMail($invoice));
Look first at vendor/symfony/brevo-mailer/Transport/BrevoApiTransport.php — specifically the payload builder. That single method is the whole contract: which headers are special-cased, how attachments are encoded, what triggers a TransportException. Everything else in the package is a ~40-line factory.
First real use case: a queued invoice/receipt email rendered by a Brevo template. Laravel supplies the recipient, the bridge supplies templateId and params, Brevo supplies subject/body. That is the one thing brevo+api does that Laravel's built-in SMTP driver cannot.
If you only ever need SMTP, you don't need this package: MAIL_MAILER=smtp, MAIL_HOST=smtp-relay.brevo.com, MAIL_PORT=587, MAIL_ENCRYPTION=tls is the same wire protocol with zero extra dependencies. Install the bridge for the API transport (no SMTP ports, better for serverless/containers, one fewer socket per worker).
Direct transport instead of DSN parsing. For a single Brevo account, skip the factory entirely:
Mail::extend('brevo', fn () => new \Symfony\Component\Mailer\Bridge\Brevo\Transport\BrevoApiTransport(
config('services.brevo.key')
));
Pass new BrevoApiTransport($key, $client, null, app('log')) if you want a customised Symfony HttpClientInterface (timeouts, retries, proxies) or Brevo traffic in your logs.
Templated email trait. Header injection is the only API the bridge exposes to your code, and withSymfonyMessage() is the only hook Laravel gives you onto the Symfony\Component\Mime\Email:
trait UsesBrevoTemplate
{
public function brevoTemplate(int $id, array $params): static
{
return $this->withSymfonyMessage(function (Email $email) use ($id, $params) {
$email->getHeaders()
->addTextHeader('templateId', (string) $id)
->addParameterizedHeader('params', 'params', $params);
});
}
}
public function build(): static
{
return $this->brevoTemplate(config('services.brevo.template_ids.invoice'), [
'first_name' => $this->invoice->user->first_name,
'invoice_number' => $this->invoice->number,
'amount' => number_format($this->invoice->total / 100, 2),
]);
}
Note the deliberate call site: inside build()/content(), not the constructor. A queued mailable is serialized before the worker runs; a closure captured in the constructor cannot be serialized. Building it in build() means the closure is created in the worker, after deserialization.
Observability headers. The API transport maps TagHeader → Brevo tags, MetadataHeader → X-Mailin-Custom, and forwards sender.ip into the sender object. Tags are what Brevo webhooks and the transactional log filter on, so tag per mail type:
$email->getHeaders()
->add(new TagHeader('invoice'))
->add(new TagHeader('billing'))
->add(new MetadataHeader('custom', json_encode(['invoice_id' => $this->invoice->id])))
->addTextHeader('sender.ip', request()->ip());
Stamp headers globally with a listener rather than per-Mailable, when every message needs the same tags:
Event::listen(MessageSending::class, function (MessageSending $event) {
$event->message->getHeaders()->add(new TagHeader(app()->environment()));
});
Failover. Because a custom creator returns a plain Symfony transport, failover/roundrobin work as long as you register the underlying mailers first:
'failover' => ['transport' => 'failover', 'mailers' => ['brevo', 'smtp']],
Local and test paths. Keep MAIL_MAILER=log locally so the transport is never constructed. In tests, Mail::fake() short-circuits at the manager — perfect for "did we send". For an actual payload assertion, instantiate the transport with a MockHttpClient and inspect the request body; Http::fake() will not intercept this, because the bridge uses Symfony HttpClient, not Laravel's HTTP client.
UnsupportedSchemeException from config alone. Transport::fromDsn() only knows the core schemes (smtp, smtps, sendmail, native, null). Setting 'dsn' => 'brevo+api://...' without a Mail::extend() creator throws. Always pair the DSN with the custom creator, or return a concrete transport and drop the DSN.@default is mandatory. Dsn requires a host; brevo+api://KEY fails to parse. Credentials containing @, :, or / (common in SMTP passwords) must be urlencoded.templateId and params only work as addTextHeader / addParameterizedHeader; tags only work as TagHeader instances. addTextHeader('tags', 'invoice') silently becomes a custom header Brevo ignores. Similarly addTextHeader('templateId', 1) coerces today and will break under declare(strict_types=1) — cast to string.templateId discards your body. Brevo renders the template; anything from ->view()/->html() is dead weight (and can confuse reviewers). Pick one model per mailable — Blade-rendered HTML or Brevo template — and don't mix.headers object. Brevo only honours custom headers with an X- prefix, and rejects or drops the rest; a stray internal header can turn into a 400. Keep an eye on anything Laravel or a package adds globally.EmbeddedFile parts are flattened into Brevo attachments with base64 content — no Content-ID, so cid: references break. Host the image or put it in a Brevo template instead.messageId is thrown away. The bridge returns a SentMessage whose ID is Symfony's locally generated Message-ID, not the ID in Brevo's 201 response. Nothing surfaces it. If you need per-message correlation, put your own identifier in MetadataHeader/tags and match it in webhooks. MessageSent gives you the SentMessage, which is enough for "sent" telemetry but not for Brevo-side lookup.TransportExceptionInterface. Non-201 responses throw with Brevo's error body attached, which queues will retry. Configure $tries/$backoff on the queued mailable and make the job idempotent — a retry after a 429 is fine, a retry after a partial send is a duplicate email. There is no idempotency key./smtp/email bulk endpoint. Brevo's per-plan quota and API rate limits mean a large Mail::to($many) loop will hit 429s; chunk with LazyCollection::chunk() + Bus::batch() and backoff.mailer.transport_factory; in Laravel you wire it by hand. Keep Mail::extend() in boot() of a provider that runs before anything sends mail, and remember MailManager memoizes mailers — re-registering a creator after a mailer was resolved has no effect in the same process.Symfony\Component\Mailer\Transport\TransportInterface and delegate send() — you get full control of the SentMessage and exceptions without patching the bridge.symfony/mailer Laravel already locked (^7.x on Laravel 11/12, ^6.4 on Laravel 10). Check that symfony/http-client is present for the api scheme — if it isn't, AbstractTransport fails at construction time, not at send time.replyTo nuances, X-Mailin-Custom shape) because the payload builder is not exhaustive. Read it once before you plan around it.How can I help you explore Laravel packages today?