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

Brevo Mailer Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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

Implementation Patterns

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.

Gotchas and Tips

  • 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.
  • The special headers are type-sensitive. 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.
  • Using 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.
  • Every user header is forwarded. The payload builder copies non-special headers verbatim into Brevo's 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.
  • Inline images degrade. 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.
  • Brevo's 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.
  • Failures are 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.
  • One HTTP request per message. No batching, no /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.
  • No auto-discovery, no framework config. In Symfony the factory is tagged 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.
  • Extension point is the decorator, not the subclass. The API transport is small and not built for inheritance. To add retries, metrics, or fallbacks, wrap it in a class implementing Symfony\Component\Mailer\Transport\TransportInterface and delegate send() — you get full control of the SentMessage and exceptions without patching the bridge.
  • Version alignment. The bridge releases track Symfony's monorepo, so install the major that matches the 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.
  • Maturity note. 26 stars and zero dependents sounds alarming, but this is a Symfony-organisation package with MIT licensing and per-Symfony-release tagging; the surface area you depend on is a DSN parser and one payload builder. The realistic risk isn't abandonment — it's that the bridge silently doesn't support a Brevo feature you assumed (scheduled sends, batch, replyTo nuances, X-Mailin-Custom shape) because the payload builder is not exhaustive. Read it once before you plan around it.
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.
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings
coleus/apps
seampact/laravel
emmyconceptng/laravel-ng-postcode
jjsquady/round-robin