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.
Table of Contents
- 1. Why Symfony Mailer instead of Swift Mailer or PHPMailer
- 2. Installation and transport configuration
- 3. Type-safe email classes with TemplatedEmail
- 4. Twig templates for HTML and text emails
- 5. Attachments, inline images and multipart
- 6. DKIM signing and email authentication
- 7. Asynchronous sending via Symfony Messenger
- 8. Tests without a real SMTP server
- 9. Transport options compared
- 10. Summary
- 11. FAQ
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.