symfony/resend-mailer
Symfony Resend Mailer provides a Symfony Mailer transport for sending email through Resend’s API. Configure it with a Resend API key and use familiar Symfony Mailer messages, DSNs, and events to deliver transactional email from PHP applications.
Install and wire it up
composer require symfony/resend-mailer
The package is a Mailer bridge (not a bundle). Installing it pulls in symfony/mailer and the resend/resend-php SDK, and — with Symfony Flex — the resend+api transport factory is discovered automatically. No bundle registration needed in a standard app.
Configure the DSN
# .env
RESEND_API_KEY=re_xxxxxxxxxxxxxxxx
MAILER_DSN="resend+api://${RESEND_API_KEY}@default"
# config/packages/mailer.yaml
framework:
mailer:
dsn: '%env(MAILER_DSN)%'
The API key goes in the user position of the DSN; default is just a syntactic host placeholder. If the key ever contains @, : or /, URL-encode it.
First send
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
final class WelcomeMailer
{
public function __construct(private MailerInterface $mailer) {}
public function send(string $to): void
{
$email = (new Email())
->from('[email protected]')
->to($to)
->subject('Welcome aboard')
->text('Thanks for signing up.')
->html('<p>Thanks for signing up.</p>');
$this->mailer->send($email);
}
}
Where to look first
Symfony\Component\Mailer\Bridge\Resend\Transport\ResendTransportFactory — confirms the accepted scheme(s) and how the DSN is parsed.ResendTransport::doSend() — the exact mapping from Symfony's Email to the Resend payload. This tells you what is and isn't forwarded (custom headers, tags, scheduled_at, attachments).resend/resend-php SDK docs for the underlying payload shape, since the bridge is a thin adapter over it.Verify with bin/console debug:container --tag=mailer.transport_factory that the Resend factory is registered before you start debugging anything else.
First realistic use case: transactional email (password reset, receipts, invites). Before sending to real users, verify a sending domain in the Resend dashboard and use a From on that domain — otherwise every send fails with a 403.
Inject the mailer, not the transport. Use MailerInterface for the default transport. Reach for a specific transport only when you have several configured:
framework:
mailer:
dsn: '%env(MAILER_DSN)%'
transports:
resend: '%env(RESEND_DSN)%'
transactional: 'resend+api://%env(RESEND_API_KEY)%@default'
Then either inject the named transport with an autowiring alias, or route per-message with the X-Transport header:
$email->getHeaders()->addTextHeader('X-Transport', 'transactional');
Capture the provider message ID. Send through TransportInterface directly when you need the Resend email ID for webhook correlation or idempotency bookkeeping:
use Symfony\Component\Mailer\Transport\TransportInterface;
$sent = $transport->send($email); // SentMessage
$resendId = $sent->getMessageId(); // Resend's email id
Store $resendId alongside your own domain entity so delivery/bounce webhooks can be matched back.
Twig emails. Use TemplatedEmail exactly as with any other Symfony transport; the bridge sees a fully rendered Email, so nothing changes:
$email = (new TemplatedEmail())
->from('[email protected]')
->to($user->getEmail())
->subject('Your receipt')
->htmlTemplate('email/receipt.html.twig')
->context(['order' => $order]);
Attachments and inline images. Standard MIME API works; inline images need a CID reference in the HTML:
$email->embed(fopen($path, 'r'), 'logo', 'image/png');
// <img src="cid:logo">
Async delivery + retries via Messenger. Route the mailer message to a queue; this gives you automatic retries for transient Resend failures (429/5xx) for free:
framework:
messenger:
transports:
async: '%env(MESSENGER_TRANSPORT_DSN)%'
routing:
'Symfony\Component\Mailer\Messenger\SendEmailMessage': async
Add a retry strategy on the transport rather than hand-rolling backoff around MailerInterface::send().
Observability via events. The bridge extends AbstractTransport, so MessageEvent and SentMessageEvent are dispatched. Hook a listener to log recipients/subjects and the returned message ID — useful because the Resend request body is built inside the bridge and isn't otherwise visible:
public function onMessage(MessageEvent $event): void { /* dump envelope + headers */ }
Testing. Never hit the real API in tests. Swap the DSN to null://null in config/packages/test/mailer.yaml, then assert with MailerAssertionsTrait (assertEmailCount, assertEmailSubjectContains, …). If you need to inspect the Resend-specific payload, write a small decorator implementing TransportInterface that records the SentMessage and delegates to a null transport.
Local development. resend+api:// always talks to the live API. Use null://null for tests and an SMTP catcher (Mailpit) or the [email protected] sender for dev — that address only delivers to the account owner's own email, which is fine for smoke tests.
resend+api://default:re_xxx@default or putting the key in the host. Keep it as resend+api://KEY@default, URL-encoded if necessary.mailer.dsn often ends up in debug:config output and in exception traces. Use env vars or Symfony Secrets (bin/console secrets:set RESEND_API_KEY) and reference %env(RESEND_API_KEY)%; never commit the literal key.scheduled_at, tag taxonomy, custom headers, batch endpoints, idempotency keys — may not be forwarded by doSend(). Read the transport source first; if it's missing, either open a PR or call $resend->emails->send([...]) from a dedicated service. Don't mix both paths for the same email type.TransportException. Inspect the previous exception / getDebug() for the Resend API message. The high-frequency ones: 403 (unverified domain or From not on a verified domain), 422 (empty body — Resend requires at least text or html), 429 (rate limit).attachFromPath() and streamed parts get base64-encoded into one HTTP request. Keep total message size well under Resend's limit and avoid attaching multi-MB files through the mailer — link to them instead.->returnPath(), ->sender() and envelope overrides are largely ignored by API-based transports; Resend derives From/Reply-To from the message. Set ->replyTo() explicitly if you need replies to land somewhere other than the sender.X-Campaign-Id-style headers, verify in doSend() that they're mapped. Prefer Resend's native tagging if the bridge exposes it.SentMessage::getMessageId(); without it, Resend webhooks (email.delivered, email.bounced) are hard to reconcile with your records.ResendTransportFactory as a service tagged mailer.transport_factory. Confirm with bin/console debug:container --tag=mailer.transport_factory.symfony/resend-mailer and resend/resend-php in composer.json. The bridge tracks the SDK's payload shape, so a minor SDK bump can change behavior.Email object's headers/body in a MessageEvent listener and compare against the Resend API reference — the bridge's own error messages are often just the wrapped HTTP response.How can I help you explore Laravel packages today?