Symfony email deliverability in 2026: Brevo, DKIM and DMARC that actually pass
Transactional email now fails closed: Gmail, Yahoo and Outlook reject unauthenticated mail outright. The DNS records that matter, the DMARC alignment rule that explains them, and the Symfony-side mistakes we made so you don't have to.
Your Symfony app doesn't deliver email. It submits email — Gmail,
Outlook and Yahoo decide the rest, and they no longer decide with spam
folders but with rejections. Google
and Yahoo have enforced
authentication requirements since February 2024; Microsoft's Outlook
joined in
May 2025
for high-volume senders and answers non-compliance with a hard
550 5.7.515. The era of "we'll set up DKIM later" is over — later
is a bounce.
The good news: for a transactional SaaS sender the checklist is short, and most of it is four DNS records. This post is the setup behind ShipAnvil and the products built on it — records checked live on 11 July 2026 — plus the two Symfony-side bugs that taught us more than the DNS ever did.
The one concept that explains everything: alignment
SPF verifies that the sending server may send for a domain. DKIM
cryptographically signs the message for a domain. Neither says which
domain the recipient sees. DMARC closes that gap: it requires that
the domain in the visible From: header aligns with the domain
that passed SPF or DKIM. A technically passing check on the wrong
domain counts for nothing.
This is why routing mail through a provider like
Brevo works without touching your SPF. Here
are the actual records behind shipanvil.com (dig'd on 11 July 2026):
shipanvil.com. TXT "v=spf1 include:mx.ovh.com -all"
brevo1._domainkey.shipanvil.com. CNAME b1.shipanvil-com.dkim.brevo.com.
brevo2._domainkey.shipanvil.com. CNAME b2.shipanvil-com.dkim.brevo.com.
_dmarc.shipanvil.com. TXT "v=DMARC1; p=none; rua=mailto:rua@dmarc.brevo.com"
Notice what's not there: no Brevo entry in the SPF record. The
envelope sender of a Brevo message is a Brevo domain — SPF passes for
them, unaligned with us, and DMARC ignores it. What aligns is DKIM:
the two CNAMEs delegate brevo{1,2}._domainkey.shipanvil.com to keys
Brevo rotates, so every message carries a signature with
d=shipanvil.com — same domain as the From: header. Aligned DKIM →
DMARC passes → Gmail is satisfied. One authentication path, fully
delegated, zero key management on your side.
The practical consequences, in order of how often we see them missed:
- The
From:domain is the contract. Send fromanything@shipanvil.comand authentication holds. Send fromfrom@example.orgthrough the same Brevo account and it is rejected before it leaves — we've watched it happen in the mailer logs. - You cannot send "as" your users. Putting your customer's Gmail
address in
From:fails their domain's DMARC on your infrastructure. The pattern that works:From:on your authenticated domain with the customer's name as display name,Reply-To:the customer. Replies flow to them; authentication stays yours. p=noneis a starting point, not a destination. It already satisfies the 2026 receiver requirements and turns on aggregate reporting (rua=) so you can see who sends as your domain. Move top=quarantineonce the reports show only your legitimate senders.- Sending under 5,000 emails/day spares you the bulk-sender extras (mandatory DMARC, one-click unsubscribe headers) — not the authentication. Google requires SPF or DKIM from every sender and a spam rate below 0.3 % regardless of volume; in practice you want both mechanisms plus DMARC anyway, because unaligned transactional mail increasingly lands in junk even when it isn't bounced.
The Symfony side: two bugs that outlived the DNS setup
Records correct, still broken — both of our real incidents were in the application layer.
Bug one: the From address that crashed the mailer. Symfony's mailer takes its default sender from configuration:
# config/packages/mailer.yaml
framework:
mailer:
dsn: '%env(MAILER_DSN)%'
headers:
From: '%env(MAILER_FROM)%'
We shipped MAILER_FROM="StockPilot <hello@…>" — a display name in
the env var — and code elsewhere did new Address($from). That
constructor wants a bare address; handed a full mailbox string it
throws. Address::create() is the parser that accepts both forms.
The lesson is boring and universal: decide one format for the env
var (we settled on the bare address, display name applied in code),
and unit-test the boundary.
Bug two: the failure nobody saw. The crash above didn't page anyone. The email path swallowed the exception, marked the notification "sent", and moved on — the worst deliverability bug is the one that reports success. The fixes that matter: the send path returns how many channels actually delivered, the command exits non-zero when nothing left the building, and the message is retried on the next pass instead of being remembered as done.
Which is why emails belong on the message bus, not in the request:
# config/packages/messenger.yaml
framework:
messenger:
failure_transport: failed
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 3
delay: 1000 # 1s, then 4s, then 16s
multiplier: 4
failed: 'doctrine://default?queue_name=failed'
routing:
# Emails never block (or break) the HTTP response that queues them
Symfony\Component\Mailer\Messenger\SendEmailMessage: async
A Brevo hiccup now costs three transparent retries; a real outage
parks messages in the failed transport where
messenger:failed:show and messenger:failed:retry make the loss
visible and recoverable instead of silent. The worker is a
five-line systemd unit (the "worker everyone forgets" — covered in
the VPS deploy guide); in tests the
transport flips to sync:// so a functional test asserts on the sent
email right after the HTTP call.
The checklist
For a transactional Symfony sender in 2026, in order:
- Authenticate the domain at your provider: DKIM CNAMEs delegated,
provider verification record set. Verify with
dig, not with the provider's green checkmark. - Publish DMARC with
p=noneand arua=address you actually read. Tighten top=quarantinewhen reports are clean. - Every
From:your app uses lives on the authenticated domain. No exceptions, no "just this one system email from gmail". - Sending on behalf of users: your domain in
From:, their address inReply-To:, their name as display name. - Emails go through Messenger async with a failure transport, and a send that delivered zero channels is an error, not a success.
- Preview what you send: every templated email in ShipAnvil renders at
/_dev/emailsin dev, because the fastest deliverability win is not looking like spam to a human either.
Steal the setup
ShipAnvil ships this entire chain wired: themed transactional templates, the async pipeline with the failed-transport runbook, rate-limited endpoints for every email a visitor can trigger (see the magic-link setup), and the docs for the DNS records above — next to auth, 2FA, teams and billing on two providers. See what's in the box or poke the live demo.