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
<?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($