Symfony Mailer: Build Professional Transactional Emails

2026-03-26 · 10 min read · gen:2m 25s · tok:19718
#symfony #symfony-mailer #transactional-emails #backend #intermediate-tutorial #english

Learn to build production-ready transactional emails in Symfony with async queuing, multi-language support, and email client compatibility.

Symfony Mailer: Building Professional Transactional Emails Your Users Will Actually Open

Every developer has been there: you ship a feature, users sign up, and then your carefully crafted welcome email lands in spam. Or worse, it renders as a broken mess in Outlook. Transactional emails—password resets, order confirmations, notifications—are the silent workhorses of your application. When they fail, users lose trust.

The problem isn’t just sending emails. It’s building a system that’s testable locally, maintainable across dozens of email types, renders correctly in every client from Gmail to Outlook 2007, and scales without blocking your HTTP responses. Most tutorials show you how to send a basic email. This article shows you how to build the entire infrastructure.

We’ll construct a modular, production-ready email system in Symfony that separates logic from presentation, supports multiple languages, queues emails asynchronously, and uses reusable components you can maintain without losing your sanity.

Prerequisites

To follow along, you need:

  • PHP 8.2 or higher
  • Symfony 6.4 or 7.x
  • Composer installed
  • Docker (for Mailpit local testing)
  • Basic familiarity with Symfony services and Twig

Install the required packages:

1
composer require symfony/mailer symfony/twig-bundle symfony/messenger symfony/translation

Architecture and Key Concepts

Before writing code, let’s understand the architecture we’re building. A well-designed transactional email system has clear boundaries between components.

flowchart TD
    subgraph Application Layer
        A[Controller/Command] --> B[TransactionalMailer Service]
    end
    
    subgraph Email Domain
        B --> C{Email Type Factory}
        C --> D[OrderConfirmationEmail]
        C --> E[PasswordResetEmail]
        C --> F[NotificationEmail]
    end
    
    subgraph Template Layer
        D --> G[Twig Email Builder]
        E --> G
        F --> G
        G --> H[Base Layout]
        H --> I[Header Component]
        H --> J[Footer Component]
        H --> K[CTA Button Component]
    end
    
    subgraph Delivery Layer
        G --> L[Symfony Messenger Queue]
        L --> M[Async Handler]
        M --> N[Mailer Transport]
        N --> O[(SMTP/Mailgun/SendGrid)]
    end
    
    subgraph Development
        P[Mailpit] -.-> N
    end

The key principle: each email type is a self-contained unit that knows its data requirements but delegates rendering to templates and delivery to the transport layer. This separation allows you to:

  1. Test email logic without sending anything
  2. Preview emails in the browser during development
  3. Change providers without touching business logic
  4. Add new email types without modifying existing code

Step-by-Step Implementation

Configuring Transports for Development and Production

Symfony Mailer uses DSN strings to configure transports. The challenge is managing different configurations across environments without duplicating code.

Start with your environment files:

1
2
3
4
5
# .env (defaults for development)
MAILER_DSN=smtp://mailpit:1025

# .env.prod (production overrides)
MAILER_DSN=mailgun+smtp://USERNAME:PASSWORD@default?region=eu

For production, you have several transport options. Here’s how to configure the major providers:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# config/packages/mailer.yaml
framework:
    mailer:
        dsn: '%env(MAILER_DSN)%'
        envelope:
            sender: '%env(MAIL_FROM_ADDRESS)%'
        headers:
            From: '%env(MAIL_FROM_NAME)% <%env(MAIL_FROM_ADDRESS)%>'

when@dev:
    framework:
        mailer:
            dsn: 'smtp://localhost:1025'

when@test:
    framework:
        mailer:
            dsn: 'null://null'

Set up Mailpit for local development. Add this to your docker-compose.yaml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
services:
  mailpit:
    image: axllent/mailpit
    container_name: mailpit
    restart: unless-stopped
    ports:
      - "1025:1025"  # SMTP server
      - "8025:8025"  # Web UI
    environment:
      MP_MAX_MESSAGES: 5000
      MP_SMTP_AUTH_ACCEPT_ANY: 1
      MP_SMTP_AUTH_ALLOW_INSECURE: 1

đź’ˇ Mailpit captures all outgoing emails and provides a web interface at http://localhost:8025. You can inspect HTML rendering, headers, and attachments without sending real emails.

For API-based providers, install the corresponding Symfony transport:

1
2
3
4
5
6
7
8
# For Mailgun
composer require symfony/mailgun-mailer

# For SendGrid
composer require symfony/sendgrid-mailer

# For Amazon SES
composer require symfony/amazon-mailer

Provider DSN formats:

1
2
3
4
5
6
7
8
# Mailgun
MAILER_DSN=mailgun+https://KEY:DOMAIN@default?region=eu

# SendGrid
MAILER_DSN=sendgrid+api://KEY@default

# Amazon SES
MAILER_DSN=ses+smtp://ACCESS_KEY:SECRET_KEY@default?region=eu-west-1

⚠️ Never commit API keys to version control. Use environment variables or a secrets management system like Symfony’s secrets:set.

Building Reusable Twig Templates with Inline Styles

Email HTML is notoriously difficult. CSS support varies wildly across clients. Outlook uses Word’s rendering engine. Gmail strips <style> tags from the <head>. The solution: inline styles and bulletproof HTML structures.

First, create the base layout that all emails will extend:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
{# templates/emails/base.html.twig #}
<!DOCTYPE html>
<html lang="{{ app.request.locale|default('en') }}">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <title>{% block title %}{% endblock %}</title>
    <!--[if mso]>
    <style type="text/css">
        body, table, td {font-family: Arial, Helvetica, sans-serif !important;}
    </style>
    <![endif]-->
</head>
<body style="margin: 0; padding: 0; background-color: #f4f4f4; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">
    {# Wrapper table for full-width background #}
    <table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background-color: #f4f4f4;">
        <tr>
            <td align="center" style="padding: 40px 20px;">
                {# Content container - max 600px for email clients #}
                <table role="presentation" cellpadding="0" cellspacing="0" width="600" style="max-width: 600px; background-color: #ffffff; border-radius: 8px; overflow: hidden;">
                    {% block header %}
                        {{ include('emails/components/_header.html.twig') }}
                    {% endblock %}
                    
                    <tr>
                        <td style="padding: 40px 30px;">
                            {% block content %}{% endblock %}
                        </td>
                    </tr>
                    
                    {% block footer %}
                        {{ include('emails/components/_footer.html.twig') }}
                    {% endblock %}
                </table>
            </td>
        </tr>
    </table>
</body>
</html>

Create the header component:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{# templates/emails/components/_header.html.twig #}
<tr>
    <td style="background-color: #2563eb; padding: 30px; text-align: center;">
        {% if logo_url is defined and logo_url %}
            <img src="{{ logo_url }}" alt="{{ app_name|default('Company') }}" width="150" style="max-width: 150px; height: auto;">
        {% else %}
            <h1 style="margin: 0; color: #ffffff; font-size: 24px; font-weight: 600;">
                {{ app_name|default('Company') }}
            </h1>
        {% endif %}
    </td>
</tr>

The footer component with unsubscribe link:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
{# templates/emails/components/_footer.html.twig #}
<tr>
    <td style="background-color: #f8fafc; padding: 30px; text-align: center; border-top: 1px solid #e2e8f0;">
        <p style="margin: 0 0 10px 0; color: #64748b; font-size: 14px;">
            {{ 'email.footer.company_info'|trans({}, 'emails') }}
        </p>
        <p style="margin: 0; color: #94a3b8; font-size: 12px;">
            {{ 'email.footer.address'|trans({}, 'emails') }}
        </p>
        {% if unsubscribe_url is defined and unsubscribe_url %}
            <p style="margin: 20px 0 0 0;">
                <a href="{{ unsubscribe_url }}" style="color: #94a3b8; font-size: 12px; text-decoration: underline;">
                    {{ 'email.footer.unsubscribe'|trans({}, 'emails') }}
                </a>
            </p>
        {% endif %}
    </td>
</tr>

Now the CTA button component—this is critical for click-through rates:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
{# templates/emails/components/_button.html.twig #}
{# 
    Usage: {{ include('emails/components/_button.html.twig', {
        url: 'https://example.com',
        text: 'Click Here',
        color: 'primary'  {# primary, success, danger #}
    }) }}
#}
{% set colors = {
    'primary': {'bg': '#2563eb', 'text': '#ffffff'},
    'success': {'bg': '#16a34a', 'text': '#ffffff'},
    'danger': {'bg': '#dc2626', 'text': '#ffffff'}
} %}
{% set btn_color = colors[color|default('primary')] %}

<table role="presentation" cellpadding="0" cellspacing="0" width="100%">
    <tr>
        <td align="center" style="padding: 20px 0;">
            <!--[if mso]>
            <v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{{ url }}" style="height:50px;v-text-anchor:middle;width:200px;" arcsize="10%" stroke="f" fillcolor="{{ btn_color.bg }}">
                <w:anchorlock/>
                <center style="color:{{ btn_color.text }};font-family:sans-serif;font-size:16px;font-weight:bold;">{{ text }}</center>
            </v:roundrect>
            <![endif]-->
            <!--[if !mso]><!-->
            <a href="{{ url }}" target="_blank" style="display: inline-block; background-color: {{ btn_color.bg }}; color: {{ btn_color.text }}; padding: 14px 30px; text-decoration: none; font-size: 16px; font-weight: 600; border-radius: 6px; mso-hide: all;">
                {{ text }}
            </a>
            <!--<![endif]-->
        </td>
    </tr>
</table>

📝 The VML code inside <!--[if mso]> is specifically for Outlook on Windows. Without it, buttons appear as plain text links in Outlook.

Now let’s create specific email templates. Here’s the order confirmation:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
{# templates/emails/order_confirmation.html.twig #}
{% extends 'emails/base.html.twig' %}

{% block title %}{{ 'email.order.title'|trans({'%order_number%': order.reference}, 'emails') }}{% endblock %}

{% block content %}
    <h1 style="margin: 0 0 20px 0; color: #1e293b; font-size: 24px; font-weight: 600;">
        {{ 'email.order.heading'|trans({}, 'emails') }}
    </h1>
    
    <p style="margin: 0 0 20px 0; color: #475569; font-size: 16px; line-height: 1.6;">
        {{ 'email.order.greeting'|trans({'%name%': customer.firstName}, 'emails') }}
    </p>
    
    <p style="margin: 0 0 30px 0; color: #475569; font-size: 16px; line-height: 1.6;">
        {{ 'email.order.confirmation_text'|trans({'%order_number%': order.reference}, 'emails') }}
    </p>
    
    {# Order items table #}
    <table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="margin-bottom: 30px; border: 1px solid #e2e8f0; border-radius: 6px;">
        <tr style="background-color: #f8fafc;">
            <td style="padding: 12px 15px; font-weight: 600; color: #1e293b; border-bottom: 1px solid #e2e8f0;">
                {{ 'email.order.product'|trans({}, 'emails') }}
            </td>
            <td style="padding: 12px 15px; font-weight: 600; color: #1e293b; border-bottom: 1px solid #e2e8f0; text-align: center;">
                {{ 'email.order.quantity'|trans({}, 'emails') }}
            </td>
            <td style="padding: 12px 15px; font-weight: 600; color: #1e293b; border-bottom: 1px solid #e2e8f0; text-align: right;">
                {{ 'email.order.price'|trans({}, 'emails') }}
            </td>
        </tr>
        {% for item in order.items %}
        <tr>
            <td style="padding: 12px 15px; color: #475569; border-bottom: 1px solid #e2e8f0;">
                {{ item.productName }}
            </td>
            <td style="padding: 12px 15px; color: #475569; border-bottom: 1px solid #e2e8f0; text-align: center;">
                {{ item.quantity }}
            </td>
            <td style="padding: 12px 15px; color: #475569; border-bottom: 1px solid #e2e8f0; text-align: right;">
                {{ item.total|format_currency(order.currency) }}
            </td>
        </tr>
        {% endfor %}
        <tr style="background-color: #f8fafc;">
            <td colspan="2" style="padding: 12px 15px; font-weight: 600; color: #1e293b; text-align: right;">
                {{ 'email.order.total'|trans({}, 'emails') }}
            </td>
            <td style="padding: 12px 15px; font-weight: 600; color: #1e293b; text-align: right;">
                {{ order.total|format_currency(order.currency) }}
            </td>
        </tr>
    </table>
    
    {{ include('emails/components/_button.html.twig', {
        url: tracking_url,
        text: 'email.order.track_button'|trans({}, 'emails'),
        color: 'primary'
    }) }}
{% endblock %}

And the password reset email:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
{# templates/emails/password_reset.html.twig #}
{% extends 'emails/base.html.twig' %}

{% block title %}{{ 'email.password_reset.title'|trans({}, 'emails') }}{% endblock %}

{% block content %}
    <h1 style="margin: 0 0 20px 0; color: #1e293b; font-size: 24px; font-weight: 600;">
        {{ 'email.password_reset.heading'|trans({}, 'emails') }}
    </h1>
    
    <p style="margin: 0 0 20px 0; color: #475569; font-size: 16px; line-height: 1.6;">
        {{ 'email.password_reset.intro'|trans({}, 'emails') }}
    </p>
    
    {{ include('emails/components/_button.html.twig', {
        url: reset_url,
        text: 'email.password_reset.button'|trans({}, 'emails'),
        color: 'primary'
    }) }}
    
    <p style="margin: 30px 0 0 0; color: #94a3b8; font-size: 14px; line-height: 1.6;">
        {{ 'email.password_reset.expiry_notice'|trans({'%minutes%': expiry_minutes}, 'emails') }}
    </p>
    
    <p style="margin: 20px 0 0 0; color: #94a3b8; font-size: 14px; line-height: 1.6;">
        {{ 'email.password_reset.ignore_notice'|trans({}, 'emails') }}
    </p>
    
    {# Fallback link for clients that don't render buttons well #}
    <p style="margin: 30px 0 0 0; color: #94a3b8; font-size: 12px; line-height: 1.6;">
        {{ 'email.password_reset.link_fallback'|trans({}, 'emails') }}<br>
        <a href="{{ reset_url }}" style="color: #2563eb; word-break: break-all;">{{ reset_url }}</a>
    </p>
{% endblock %}

Implementing the Centralized Email Service

Now let’s build the service layer that orchestrates everything. We’ll create a system that supports multiple email types, handles translations, and integrates with Symfony Messenger for async delivery.

First, define an interface for email messages:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<?php
// src/Email/TransactionalEmailInterface.php

namespace App\Email;

interface TransactionalEmailInterface
{
    public function getRecipientEmail(): string;
    public function getRecipientName(): ?string;
    public function getSubjectTranslationKey(): string;
    public function getSubjectTranslationParams(): array;
    public function getTemplate(): string;
    public function getContext(): array;
    public function getLocale(): string;
    public function getPriority(): int;
}

Create an abstract base class:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<?php
// src/Email/AbstractTransactionalEmail.php

namespace App\Email;

abstract class AbstractTransactionalEmail implements TransactionalEmailInterface
{
    protected string $recipientEmail;
    protected ?string $recipientName = null;
    protected string $locale = 'en';
    protected int $priority = 0;

    public function getRecipientEmail(): string
    {
        return $this->recipientEmail;
    }

    public function getRecipientName(): ?string
    {
        return $this->recipientName;
    }

    public function getLocale(): string
    {
        return $this->locale;
    }

    public function getPriority(): int
    {
        return $this->priority;
    }

    public function getSubjectTranslationParams(): array
    {
        return [];
    }
}

Implement concrete email types:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<?php
// src/Email/OrderConfirmationEmail.php

namespace App\Email;

use App\Entity\Order;
use App\Entity\Customer;

final class OrderConfirmationEmail extends AbstractTransactionalEmail
{
    public function __construct(
        private readonly Order $order,
        private readonly Customer $customer,
        private readonly string $trackingUrl,
    ) {
        $this->recipientEmail = $customer->getEmail();
        $this->recipientName = $customer->getFullName();
        $this->locale = $customer->getPreferredLocale() ?? 'en';
        $this->priority = 10; // High priority for transactional
    }

    public function getSubjectTranslationKey(): string
    {
        return 'email.order.subject';
    }

    public function getSubjectTranslationParams(): array
    {
        return ['%order_number%' => $this->order->getReference()];
    }

    public function getTemplate(): string
    {
        return 'emails/order_confirmation.html.twig';
    }

    public function getContext(): array
    {
        return [
            'order' => $this->order,
            'customer' => $this->customer,
            'tracking_url' => $this->trackingUrl,
        ];
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<?php
// src/Email/PasswordResetEmail.php

namespace App\Email;

use App\Entity\User;

final class PasswordResetEmail extends AbstractTransactionalEmail
{
    private const EXPIRY_MINUTES = 60;

    public function __construct(
        private readonly User $user,
        private readonly string $resetUrl,
    ) {
        $this->recipientEmail = $user->getEmail();
        $this->recipientName = $user->getDisplayName();
        $this->locale = $user->getLocale() ?? 'en';
        $this->priority = 20; // Highest priority - time sensitive
    }

    public function getSubjectTranslationKey(): string
    {
        return 'email.password_reset.subject';
    }

    public function getTemplate(): string
    {
        return 'emails/password_reset.html.twig';
    }

    public function getContext(): array
    {
        return [
            'user' => $this->user,
            'reset_url' => $this->resetUrl,
            'expiry_minutes' => self::EXPIRY_MINUTES,
        ];
    }
}

Now the central mailer service:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
<?php
// src/Service/TransactionalMailer.php

namespace App\Service;

use App\Email\TransactionalEmailInterface;
use App\Message\SendEmailMessage;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Mime\Address;
use Symfony\Contracts\Translation\TranslatorInterface;

final class TransactionalMailer
{
    public function __construct(
        private readonly MailerInterface $mailer,
        private readonly MessageBusInterface $messageBus,
        private readonly TranslatorInterface $translator,
        private readonly LoggerInterface $logger,
        private readonly string $fromEmail,
        private readonly string $fromName,
        private readonly string $appName,
    ) {
    }

    /**
     * Send email synchronously - use for critical, time-sensitive emails
     */
    public function sendNow(TransactionalEmailInterface $email): void
    {
        $templatedEmail = $this->buildEmail($email);

        try {
            $this->mailer->send($templatedEmail);
            $this->logger->info('Transactional email sent', [
                'type'      => $email->getType(),
                'recipient' => $email->getRecipient(),
            ]);
        } catch (TransportExceptionInterface $e) {
            $this->logger->error('Transactional email failed', [
                'type'      => $email->getType(),
                'recipient' => $email->getRecipient(),
                'error'     => $e->getMessage(),
            ]);
            throw $e;
        }
    }

    /**
     * Queue the email for asynchronous delivery - the default for everything
     * that isn't strictly time-critical.
     */
    public function send(TransactionalEmailInterface $email): void
    {
        $this->messageBus->dispatch(new SendEmailMessage($email));
    }

    private function buildEmail(TransactionalEmailInterface $email): TemplatedEmail
    {
        $locale = $email->getLocale() ?? 'en';

        $templatedEmail = (new TemplatedEmail())
            ->from(new Address($this->fromEmail, $this->fromName))
            ->to(new Address($email->getRecipient(), $email->getRecipientName() ?? ''))
            ->subject($this->translator->trans(
                $email->getSubjectKey(),
                $email->getSubjectParams(),
                'emails',
                $locale,
            ))
            ->htmlTemplate($email->getTemplate())
            ->context(array_merge($email->getContext(), [
                'app_name' => $this->appName,
                'locale'   => $locale,
            ]));

        // Deliverability headers
        $headers = $templatedEmail->getHeaders();
        $headers->addTextHeader('X-Transactional-Type', $email->getType());
        $headers->addTextHeader(
            'List-Unsubscribe',
            sprintf('<mailto:unsubscribe@%s>', $this->fromEmailDomain()),
        );

        return $templatedEmail;
    }

    private function fromEmailDomain(): string
    {
        return substr(strrchr($this->fromEmail, '@') ?: '@localhost', 1);
    }
}

📝 buildEmail() centralizes three things that otherwise drift across a codebase: the From identity, subject translation via the emails domain, and the deliverability headers. Every email in the app goes through this one method.

The message handler that runs for the async path:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
<?php
// src/MessageHandler/SendEmailMessageHandler.php

namespace App\MessageHandler;

use App\Message\SendEmailMessage;
use App\Service\TransactionalMailer;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
final class SendEmailMessageHandler
{
    public function __construct(private readonly TransactionalMailer $mailer) {}

    public function __invoke(SendEmailMessage $message): void
    {
        // Reuse the synchronous path; a throw here lets Messenger retry.
        $this->mailer->sendNow($message->getEmail());
    }
}

Wire the transport so failed sends retry with backoff and land in a failure queue:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# config/packages/messenger.yaml
framework:
    messenger:
        failure_transport: failed
        transports:
            async:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                retry_strategy:
                    max_retries: 3
                    delay: 5000          # 5s, then 10s, then 20s
                    multiplier: 2
            failed:
                dsn: 'doctrine://default?queue_name=failed'
        routing:
            App\Message\SendEmailMessage: async

Production Configuration

  • Run a worker. Async delivery does nothing without messenger:consume. Run it under a supervisor with a memory ceiling and a time limit so long-lived PHP doesn’t leak:
1
php bin/console messenger:consume async --limit=100 --memory-limit=128M --time-limit=3600
  • DNS is deliverability. Publish SPF, DKIM, and DMARC for your sending domain. Most providers (SES, Mailgun, SendGrid) give you the exact records; without them Gmail and Outlook route you to spam or reject outright.
  • Use a subdomain for transactional mail (mail.example.com) so its reputation is isolated from marketing sends.
  • Set List-Unsubscribe (header above) and honor it — Gmail and Yahoo now require it for bulk senders and it measurably improves inbox placement.
  • Pin a real From name and address, never no-reply@ if you can avoid it; a monitored reply address improves engagement signals.
  • Sandbox non-prod. Point staging at Mailpit or the provider’s sandbox mode so a bad WHERE clause can’t email real users.

Common Mistakes and Troubleshooting

Emails render fine locally, broken in Gmail/Outlook. You relied on <style> blocks. Move every rule inline (the Twig inline_css filter from twig/cssinline-extra, or a build step). Outlook uses Word’s rendering engine — test tables, not flexbox.

Nothing sends in production. No worker is consuming async. Check php bin/console messenger:stats and your supervisor.

Everything sends twice. Either two workers on the same transport without a lock, or you call both send() and sendNow(). Pick one path per email type.

TransportException: Connection could not be established. Wrong MAILER_DSN, a blocked outbound port (25/465/587), or missing API credentials. Test with php bin/console mailer:test you@example.com.

Translations fall back to the key. The emails translation domain isn’t loaded, or the locale isn’t passed into ->context()/->subject(). Confirm translations/emails.<locale>.yaml exists and buildEmail() forwards $locale.

Retries hammer the provider. A 4xx from the provider (rate limit, bad recipient) shouldn’t retry forever. Catch permanent failures in the handler and throw new UnrecoverableMessageHandlingException() so Messenger routes them straight to failed.

Performance and Scalability

  • Always queue by default. Rendering Twig + a TLS handshake to the provider is 100–500 ms you don’t want in the request path. Reserve sendNow() for password resets and 2FA codes.
  • Batch worker throughput with --limit and multiple worker processes rather than one worker doing thousands of sends per boot.
  • Cache compiled templates (APP_ENV=prod does this) — Twig compilation per email is pure waste.
  • Use the provider’s API transport, not SMTP, at volume: mailgun+https, sendgrid+api, ses+api avoid per-message connection setup.
  • Watch the failure transport. A growing failed queue is your early warning for DNS, credential, or reputation problems.
  • Rate-limit per recipient if you send order/status updates, so a retry storm or a loop can’t send someone 200 emails.

Conclusion and Next Steps

You now have a transactional email system with one entry point (TransactionalMailer), a single buildEmail() that owns identity, i18n, and deliverability headers, an async path with retry and a dead-letter queue, and templates that survive Gmail and Outlook. The architecture keeps “what to send” (typed email objects) cleanly separated from “how it’s delivered” (transport, worker, retries).

Next steps:

  • Add provider webhooks (bounces, complaints, opens) and suppress addresses that hard-bounce.
  • Render a plaintext part from the HTML so text-only clients and spam filters are happy.
  • Add a mailer:test-style smoke check to CI that renders every template with sample data.
  • Track a per-type delivery dashboard (sent / failed / bounced) from the worker logs or webhook events.

Additional Resources