Symfony Mailer is for what is called transactional emails only. These are user-specific emails that occur when something specific happens in your app. Things like: a welcome email after a user signs up, an order confirmation email when they place an order and are all examples of transactional email
Symfony Mailer is not for bulk or marketing emails. Because of this, we don’t need to worry about any kind of unsubscribe functionality. There are specific services for sending bulk emails or newsletters, Mailtrap can even do this via their site.
The Mailer recipe added this MAILER_DSN
environment variable. This is a special URL-looking string that configures your mailer transport: how your emails are actually sent, like via SMTP, Mailtrap, etc. The recipe defaults to null://null
and is perfect for local development and testing
Twig can be used for any text format, not just html
. A good practice is to include the format – .html
or .txt
– in the filename. But Twig doesn’t care about the that – it’s just to satisfy our human brains.
Emails should always have a plain-text version, but they can also have an HTML version. Symfony Mailer automatically creates a text version but strips the tags. This is a nice fallback, but it’s not perfect.
Think about this :
composer require league/html-to-markdown

One of the main limitations is the inconsistent support for the class
attribute in many email clients. To ensure consistent rendering, inline styles are usually a safer choice when working with email templates.
The solution is inline all the styles. For every tag that has a class
, we need to find all the styles applied from the class and add them as a style
attribute. Manually, this would hard. Symfony Mailer has you covered!
composer require twig/cssinliner-extra
For creating a custom Twig namespace


The source()
function in Twig is used to fetch the raw content of a file, such as a CSS file, without interpreting it as a Twig template. This is useful when using the inline_css()
filter to apply styles from an external CSS file in an email template. It ensures the file’s contents are used as-is without executing any Twig logic inside it.
Foundation CSS for Emails
For emails, we recommend using Foundation Framework CSS as it has a specific framework for emails. Foundation has a dedicated framework called Foundation for Emails, which helps handle email-specific styling issues, ensuring better rendering across different email clients. Unlike websites, emails do not support Flexbox or Grid, and external stylesheets are often ignored.
Now we need to improve our HTML. But weird news! Most of the things we use for styling websites don’t work in emails. For example, we can’t use Flexbox or Grid. Instead, we need to use tables for layout. Tables! Tables, inside tables, inside tables…
There’s a templating language we can use to make this easier. Search for « inky templating language » to find this page. Inky is developed by this Zurb Foundation. Zurb, Inky, Foundation… these names fit in perfectly with our space theme! And they all work together!
https://get.foundation/emails/docs/inky.html
ou can get an idea of how it works on the overview. This is the HTML needed for a simple email. It’s table-hell! Click the « Switch to Inky » tab. Wow! This is much cleaner! We write in a more readable format and Inky converts it to the table-hell needed for emails.
Inky is a templating language that simplifies email layout by using a cleaner syntax (e.g., <container>
, <row>
, <column>
). The inky_to_html
Twig filter processes this markup and converts it into standard HTML tables required for proper email rendering.
symfony composer require twig/inky-extra

Attachments and Images

There are two methods: attach()
and attachFromPath()
. attach()
is for adding the raw content of a file (as a string or stream). Since our attachment is a real file on our filesystem, use attachFromPath()
and pass $termsPath
then a friendly name like Terms of Service.pdf
Next, let’s embed the images directly into the email. This is like an attachment, but isn’t available for download instead, you reference it in the HTML of your email
When you use Twig to render your emails, of course you have access to the variables passed to ->context()
but there’s also a secret variable available called email
. This is an instance of WrappedTemplatedEmail
and it gives you access to email-related things like the subject, return path, from, to, etc. The thing we’re interested in is this image()
method. This is what handles embedding images!


When Symfony Mailer encounters an email string in the format Full Name <email@example.com>
, it automatically creates an Address
object. This object has a name of Full Name
and email of email@example.com
.
Production Sending with Mailtrap
Mailer comes with various ways to send emails, called « transports« . This smtp one is what we’re using for our Mailtrap testing. We could set up our own SMTP server to send emails ….
We could using a 3rd-party email service. These handle all these complexities for you and Mailer provides bridges to many of these to make setup. We are using Mailtrap for testing but Mailtrap also has production sending capabilities.
composer require symfony/mailtrap-mailer
Link Generation in the CLI
The messenger:consume
is a CLI command, and when generating absolute URLs in the CLI, Symfony doesn’t know what the domain should be (or if it should be http or https). So why does it when in a controller? In a controller, Symfony uses the current request to figure this out. In a CLI command, there is no request so it gives up and uses http://localhost
.
Configure the Default URL
Open up config/packages/routing.yaml

In development though, we need to use our local dev server’s URL. For me, this is 127.0.0.1:8003
but this might be different for other team members for example
To make this configurable, there’s a trick: the Symfony CLI server sets a special environment variable with the domain called SYMFONY_PROJECT_DEFAULT_ROUTE_URL
.
This environment variable will only be available if the Symfony CLI server is running and you’re running commands via symfony console
(not bin/console
)
Running messenger:consume in the Background
Open this .symfony.local.yaml
file. This is the Symfony CLI server config for our app. See this workers
key ? It lets us define processes to run in the background when we start the server. We already have the tailwind command set.

Foundry and Browser
zenstruck/foundry
gives us this ResetDatabase
trait which wipes the database before each test. It also gives us this Factories
trait which lets us create database fixtures in our tests. And HasBrowser
is from another package – zenstruck/browser
– and is essentially a user-friendly wrapper around Symfony’s test client.
composer require --dev zenstruck/mailer-test

X-Tag and X-Metadata
If your Mailer transport doesn’t support tags and metadata, these values will not be lost. Instead, they will be added as generic headers: X-Tag
and X-Metadata
, which can still be useful for testing purposes in services like Mailtrap. This ensures that even unsupported features can still be tracked in the email headers.

Test for CLI Command
Symfony has some out-of-the-box tooling for testing commands, but we would like to use a package that wraps these up into a nicer experience. Install it with:
composer require --dev zenstruck/console-test


Webhook & RemoteEvent Components
The webhook component gives us a single endpoint to send all webhooks to. It parses the data sent to us – called the payload, converts it to a remote event object, and sends it to a consumer.
You can think of remote events as similar to Symfony events. Instead of your app dispatching an event, a third-party service does it – hence remote event. And instead of event listeners, we say that remote events have consumers.

3rd party webhooks – like from Mailtrap or a payment processor or a supernova alert system – can send us wildly different payloads, we typically need to create our own parsers and remote events. Since email events are pretty standard, Symfony provides some out-of-the-box remote events for these: MailerDeliveryEvent
and MailerEngagementEvent
. Some mailer bridges, including the Mailtrap bridge we’re using, provide parsers for each service’s webhook payload to create these objects. We just need to set it up.
https://symfony.com/doc/current/webhook.html
The webhook controller sends the remote event to the consumer via Symfony Messenger, inside of a message class called ConsumeRemoteEventMessage
.



Scheduling our Email Command
Think of Symfony Scheduler as an add-on for Messenger. It provides its own special transport that, instead of a queue, determines if it’s time to run a job. Each job, or task, is a messenger message, so it requires a message handler. You consume the schedule, like any messenger transport with the messenger:consume
command.
composer require scheduler

This ->stateful()
that we’re passing $this->cache
to is important. If the process that’s running this schedule goes down – like our messenger workers stop temporarily during a server restart – when it comes back online, it will know all the jobs it missed and run them. If a task was supposed to run 10 times while it was down, it will run them all. That might not be desired so add ->processOnlyLastMissedRun(true)
to only run the last one
We’ve configured our schedule, but how do we run it? Remember, schedules are just Messenger transports. The transport name is scheduler_<schedule_name>
, in our case, scheduler_default
. Run it with:
- symfony console messenger:consume scheduler_default
Messenger Monitor Bundle
When you have a bunch of messages and schedules running in the background, it can be hard to know what’s happening. Are my workers running? Is my schedule running? And where is it running to? What about failures?
Jump over to the browser and visit: /admin/messenger
. This is the Messenger Monitor dashboard!


A lot of what we covered here is based on the great tutorial from SymfonyCasts:
Symfony Mailer & Mailtrap screencast
It’s an excellent resource if you want to go even deeper and see the concepts applied step by step.
Symfony Mailer is powerful, flexible, and built for transactional emails. Combined with Twig, Foundation for Emails, Inky, the CSS inliner, and the Scheduler, you can create emails that are reliable, well-styled, and compatible with (almost) every email client.
The golden rule: keep emails simple and compatible, while making the most of the tools Symfony provides.
With this setup, you’re ready to start sending professional emails from your Symfony app!