Symfony Mailer: Modern, Type-Safe and Testable Emails
AI generated
SF
{ }
Symfony · Mailer · Email · Twig · Messenger · PHP 8.4
Symfony Mailer:
Modern, Type-Safe and Testable Emails

Anyone sending emails in PHP with Swift Mailer or raw PHP mail() struggles with missing queuing, uncontrollable SMTP connections in tests and unreadable template code. Symfony Mailer solves this completely with type-safe email objects, Twig templates, pluggable transports, DKIM signing and clean test isolation without an SMTP server.

16 min read TemplatedEmail · Transports · DKIM · Messenger · Testing Symfony 7.x · PHP 8.4

1. Why Symfony Mailer instead of Swift Mailer or PHPMailer

Symfony Mailer is the official successor to Swift Mailer and has taken its place in all Symfony projects starting from version 4.3. The decisive difference from older solutions lies in the architecture: Symfony Mailer cleanly separates the email object (what is sent), the transport (how it is sent) and the optional queue system (when it is sent). This separation makes it possible to write emails to a debug file during development, capture them in memory during tests and send them via Postmark or Amazon SES in production, without changing a single line of application code.

PHPMailer and Swift Mailer share the same fundamental problem: the email object is directly tied to the transport. Anyone wanting to intercept emails in tests has to either start a mock server or wrap the email code in conditionals. Symfony Mailer solves this elegantly: the transport is a swappable service in the container, and in tests it is replaced by null:// or the InMemoryTransport. Tests verify what the application wanted to send, not whether an SMTP server responds correctly. That is test-oriented email development as it should be.

2. Installation and transport configuration

Installing Symfony Mailer is done via composer require symfony/mailer. Dedicated bridge packages exist for specific email service providers: symfony/postmark-mailer for Postmark, symfony/mailgun-mailer for Mailgun, symfony/amazon-mailer for Amazon SES. These bridges come with a pre-configured transport service and only need the API key as an environment variable. For Twig templates, and this is the recommended path, you additionally need symfony/twig-bundle and twig/extra-bundle for email-specific Twig extensions.

The transport configuration in mailer.yaml is a single DSN string that contains the service provider, credentials and port. For different environments, the DSN is controlled via environment variables: in development null://null (emails are discarded) or smtp://localhost:1025 (MailHog), in production postmark+api://TOKEN@default. The Symfony debug toolbar shows all emails sent in development mode, sender, recipient, subject and full body, without any email actually being delivered.


<?php

declare(strict_types=1);

namespace App\Mail;

use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mime\Address;

/**
 * Typed email class for order confirmation emails.
 * Encapsulates all email parameters, no raw array manipulation in calling code.
 */
final class OrderConfirmationEmail extends TemplatedEmail
{
    /**
     * Configure the order confirmation email.
     *
     * @param string[] $ccAddresses
     */
    public function __construct(
        private readonly string $customerEmail,
        private readonly string $customerName,
        private readonly string $orderId,
        private readonly float $totalAmount,
        private readonly array $ccAddresses = [],
    ) {
        parent::__construct();

        $this
            ->from(new Address('orders@mironsoft.de', 'Mironsoft Shop'))
            ->to(new Address($customerEmail, $customerName))
            ->subject("Order confirmation #{$orderId}")
            ->htmlTemplate('emails/order-confirmation.html.twig')
            ->textTemplate('emails/order-confirmation.txt.twig')
            ->context([
                'order_id'     => $orderId,
                'customer'     => $customerName,
                'total_amount' => $totalAmount,
            ]);

        foreach ($ccAddresses as $cc) {
            $this->addCc(new Address($cc));
        }
    }
}

3. Type-safe email classes with TemplatedEmail

The key to maintainable email logic in Symfony Mailer is creating your own email classes that extend TemplatedEmail. Instead of configuring the email parameters directly in the service, recipient, subject, context variables, a dedicated class encapsulates all the details. The calling code creates an object and sends it: $this->mailer->send(new OrderConfirmationEmail($order)). That is type-safe, IDE-friendly and testable. A change to the email layout, subject or context only touches the email class, not every place in the application code that sends this email.

The pattern is similar to value objects in domain modeling: an email class is an immutable value type that describes all properties of a specific email. It can be instantiated directly in tests and checked for correct configuration, without the Symfony Mailer transport ever being invoked. Unit tests for emails check recipient, subject and context, integration tests check the rendered HTML, and end-to-end tests check the actual delivery. Each layer tests exactly what its scope covers.

4. Twig templates for HTML and text emails

Twig templates for emails in Symfony Mailer have a special mechanism: a single template file can contain the subject block, the HTML block and the text block. The template is rendered with the email object as a context variable, so methods such as email.subject(), email.from() and email.to() are directly accessible in the template. For HTML emails, CSS inlining is important, since most email clients ignore external stylesheets. twig/extra-bundle contains CssInlinerExtension, which automatically transfers CSS rules into inline styles on the HTML elements.

Email templates typically inherit from a base template that contains the header, footer, global CSS styles and the brand layout. Individual email types only override the content block. That is the same inheritance principle used for web templates, but it works particularly well in email clients because HTML emails are rendered completely, no JavaScript, no CSS linking, everything inline. The InkyExtension from twig/extra-bundle allows the Foundation for Emails framework to be used directly in Twig, giving responsive email layout without manual CSS table hacking.


<?php
// templates/emails/order-confirmation.html.twig
// (shown as PHP string for syntax highlighting)
//
// {% extends 'emails/base.html.twig' %}
//
// {% block subject %}Order confirmation #{{ order_id }}{% endblock %}
//
// {% block body_html %}
//   <h1>Thank you, {{ customer }}!</h1>
//   <p>Your order #{{ order_id }} has been received successfully.</p>
//   <table>
//     <tr>
//       <th>Order number</th>
//       <td>{{ order_id }}</td>
//     </tr>
//     <tr>
//       <th>Total amount</th>
//       <td>{{ total_amount | number_format(2, ',', '.') }} €</td>
//     </tr>
//   </table>
// {% endblock %}
//
// templates/emails/base.html.twig (simplified):
// <!DOCTYPE html>
// <html>
// <head>
//   <style>
//     /* CSS here is inlined automatically by CssInlinerExtension */
//     body { font-family: Arial, sans-serif; color: #333; }
//     h1   { color: #0f172a; }
//   </style>
// </head>
// <body>{% block body_html %}{% endblock %}</body>
// </html>

declare(strict_types=1);

namespace App\Service;

use App\Mail\OrderConfirmationEmail;
use Symfony\Component\Mailer\MailerInterface;

/**
 * Sends transactional emails using typed email classes.
 */
final readonly class EmailNotificationService
{
    public function __construct(
        private MailerInterface $mailer,
    ) {}

    /**
     * Send an order confirmation email to the customer.
     */
    public function sendOrderConfirmation(
        string $customerEmail,
        string $customerName,
        string $orderId,
        float $totalAmount,
    ): void {
        $email = new OrderConfirmationEmail(
            customerEmail: $customerEmail,
            customerName: $customerName,
            orderId: $orderId,
            totalAmount: $totalAmount,
        );

        // If Messenger is configured, this sends asynchronously via the queue
        $this->mailer->send($email);
    }
}

5. Attachments, inline images and multipart

File attachments in Symfony Mailer are added via $email->attachFromPath() or $email->attach(). attachFromPath() accepts an absolute file path and an optional content type, suitable for static files such as terms and conditions or invoice templates. attach() accepts a string or a resource, suitable for dynamically generated content such as PDF invoices created in memory. The optional third parameter sets the file name shown to the recipient, independent of the actual file path.

Inline images, for logos and product images in the email body, are embedded with $email->embedFromPath() and referenced in the template via email.image(path) as base64-encoded data URLs or CID references. This ensures that images are still displayed even when the email client blocks external images. Multipart emails with an HTML body and a text fallback are standard in Symfony Mailer: setting htmlTemplate() and textTemplate() automatically produces a MIME multipart message with both parts, and email clients pick the part they can render.

6. DKIM signing and email authentication

DKIM (DomainKeys Identified Mail) is a signature method for emails that recipient mail servers can verify. Emails without a DKIM signature land in the spam folder more often, especially if they are sent from a domain for which no DKIM DNS record exists. Symfony Mailer supports DKIM signing via the DkimSigner middleware system: a private RSA key signs every outgoing email, and the corresponding public key is stored as a DNS TXT record for the domain. Recipient servers verify the signature and thereby increase deliverability.

DKIM integration in Symfony Mailer is an event subscriber pattern: DkimSigner implements MessageSignerInterface and is invoked via the mailer event system before the email is handed off to the transport. The private key is injected securely as an environment variable or vault secret, never in code or configuration files. The DKIM domain and selector must match the DNS TXT record. For projects with multiple sender domains, several DkimSigner instances can be configured for different domains and applied selectively per email class.


<?php

declare(strict_types=1);

namespace App\Tests\Mail;

use App\Mail\OrderConfirmationEmail;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mailer\Test\Constraint\EmailCount;
use Symfony\Component\Mime\Email;

/**
 * Tests the OrderConfirmationEmail class directly, no transport needed.
 */
final class OrderConfirmationEmailTest extends TestCase
{
    public function testEmailConfiguration(): void
    {
        $email = new OrderConfirmationEmail(
            customerEmail: 'john.doe@example.com',
            customerName: 'John Doe',
            orderId: 'ORD-2026-001',
            totalAmount: 129.99,
        );

        // Verify recipient
        self::assertCount(1, $email->getTo());
        self::assertSame('john.doe@example.com', $email->getTo()[0]->getAddress());
        self::assertSame('John Doe', $email->getTo()[0]->getName());

        // Verify subject
        self::assertSame('Order confirmation #ORD-2026-001', $email->getSubject());

        // Verify sender
        self::assertSame('orders@mironsoft.de', $email->getFrom()[0]->getAddress());

        // Verify template context
        self::assertSame('ORD-2026-001', $email->getContext()['order_id']);
        self::assertSame(129.99, $email->getContext()['total_amount']);
    }

    public function testCcAddresses(): void
    {
        $email = new OrderConfirmationEmail(
            customerEmail: 'john.doe@example.com',
            customerName: 'John Doe',
            orderId: 'ORD-2026-002',
            totalAmount: 49.00,
            ccAddresses: ['buchhaltung@mironsoft.de'],
        );

        self::assertCount(1, $email->getCc());
        self::assertSame('buchhaltung@mironsoft.de', $email->getCc()[0]->getAddress());
    }
}

7. Asynchronous sending via Symfony Messenger

Synchronous email sending within the HTTP request extends the response time, since every SMTP connection setup costs time. Symfony Mailer integrates seamlessly with Symfony Messenger for asynchronous email queuing. The integration is a single configuration entry in messenger.yaml: Symfony\Component\Mailer\Messenger\SendEmailMessage: async. After that, every email sent via $mailer->send() is placed on the configured message queue instead of being handed to the transport immediately.

The message worker process processes the queue asynchronously and then hands the emails off to the SMTP transport or API provider. The result: HTTP requests respond immediately, and email delivery happens in the background without blocking the user. On network errors or temporary SMTP outages, Messenger can automatically perform retries with exponential backoff, which is significantly more robust than synchronous sending in the request. For transactional emails that should only be sent after a successful database operation, the dispatch-after-current-bus pattern is ideal: the email is only placed on the queue once the transaction has committed successfully.

8. Tests without a real SMTP server

Unit tests for email classes are simple: the Symfony Mailer email class is instantiated directly and its properties are checked, no transport, no network. For integration tests that check whether a service dispatches an email with the correct parameters, you use MailerInterface mocks or the Symfony Mailer test transport. The test bundle provides assertEmailCount(), assertEmailIsQueued() and assertEmailHasHeader() assertions. These assertions access the InMemoryTransport, which keeps all sent emails in memory.

The InMemoryTransport is the right test transport for kernel tests and functional tests. It is activated automatically when the mailer DSN in the test environment is set to null://null. Sent emails can be retrieved via the InMemoryTransport service. For tests that check whether a rendered Twig template produces the correct HTML output, you render the template directly with the Symfony test kernel infrastructure and compare the output, without ever calling the mailer transport. This layer separation makes email tests fast, deterministic and independent of external infrastructure.

9. Transport options compared

Choosing the right transport for Symfony Mailer depends on requirements around deliverability, cost, logging and testability.

Transport DSN scheme Use case Notable trait
SMTP smtp://user:pass@host:587 Own mail server Full control, TLS support
Postmark postmark+api://TOKEN@default Transactional emails High deliverability level
Mailgun mailgun+api://KEY:DOMAIN@default Bulk and transactional Detailed tracking
Amazon SES ses+api://KEY:SECRET@default High volume Cheap, AWS integration
Null (test) null://null Tests, dev Emails are discarded

For most Symfony projects, a cloud-based email service provider is recommended over running your own SMTP server. Postmark and Mailgun offer detailed delivery reports, bounce handling and DKIM configuration through their dashboard, which simplifies email operations considerably. The Symfony Mailer bridge packages abstract away the API differences completely: switching between Postmark and Mailgun only means changing the DSN string.

Mironsoft

Symfony email architecture, mailer integration and deliverability optimization

Building a professional Symfony email system?

We develop complete email systems with Symfony Mailer, type-safe email classes, Twig templates, DKIM signing, Messenger queue and fully testable transports for your stack.

Email architecture

Type-safe email classes, Twig templates and transport configuration for all environments

Queue & async

Messenger integration for asynchronous sending with retry logic and error handling

Deliverability

DKIM signing, SPF configuration and Postmark/Mailgun integration for maximum deliverability

10. Summary

Symfony Mailer modernizes email sending in PHP projects on several levels at once. Type-safe email classes encapsulate all email parameters and make refactoring safe. Twig templates with automatic CSS inlining produce HTML emails that display correctly in all common clients. Swappable transports allow different configurations for development, staging and production, with no code changes. The Messenger integration turns asynchronous email sending into a one-line configuration. And the null:// transport in tests ensures that no email is ever accidentally sent from a test environment.

The biggest practical benefit lies in test isolation: email classes are directly testable, transport behavior is mockable, and rendered Twig templates can be verified in integration tests. Teams migrating from Swift Mailer or PHPMailer benefit immediately, with less boilerplate, more type safety and better testability. The DKIM integration and the cloud transports for Postmark, Mailgun and Amazon SES are production ready and make Symfony Mailer the complete email infrastructure for scalable Symfony projects.

Symfony Mailer, the essentials at a glance

Type-safe email classes

Extend TemplatedEmail, encapsulate all parameters in the constructor. Calling code sends an object, no raw arrays, full IDE support.

Transport & environments

null://null in dev and test, Postmark/Mailgun in production, only the DSN string changes, no application code.

Async via Messenger

SendEmailMessage: async in messenger.yaml, emails are queued, HTTP requests respond immediately, workers send in the background.

Testing

Instantiate email classes directly and check properties. null:// transport prevents real sending. InMemoryTransport holds emails for assertions.

11. FAQ: Symfony Mailer

1What is Symfony Mailer?
The official email component for Symfony, successor to Swift Mailer. Type-safe email objects, swappable transports, Twig integration, DKIM and Messenger queue.
2Email vs. TemplatedEmail?
Email is the base object. TemplatedEmail extends it with Twig template rendering with context variables and CSS inlining, the recommended path for HTML emails.
3Transport per environment?
MAILER_DSN environment variable: null://null in dev, postmark+api://TOKEN in prod. Only the DSN changes, no application code to adapt.
4Asynchronous sending?
messenger.yaml: SendEmailMessage to async. Every $mailer->send() call is queued. A worker sends in the background with retry logic.
5Tests without an SMTP server?
Instantiate email classes directly and check properties. null:// prevents real sending. InMemoryTransport holds emails for assertEmailCount().
6Adding file attachments?
attachFromPath('/path/file.pdf') for static files. attach($body, 'name.pdf', 'application/pdf') for dynamically generated content. embedFromPath() for inline images.
7What is DKIM?
A cryptographic email signature. DkimSigner signs all outgoing emails with a private RSA key. Recipient servers verify it against the DNS TXT record.
8Supported email providers?
Postmark, Mailgun, Amazon SES, Sendgrid, Brevo, Mandrill, all configurable via a DSN string. All bridges implement the same MailerInterface.
9Migration from Swift Mailer?
composer require symfony/mailer, then migrate service by service: Swift_Message to Email/TemplatedEmail, adapt injected types and update email construction methods.
10CSS inlining for HTML emails?
CssInlinerExtension from twig/extra-bundle automatically converts <style> CSS into inline styles during Twig rendering, compatible with all email clients.