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 from anything@shipanvil.com and authentication holds. Send from from@example.org through 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=none is 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 to p=quarantine once 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=none and a rua= address you actually read. Tighten to p=quarantine when 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 in Reply-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/emails in 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.