Wednesday, 13 August 2025

Sending emails from Symfony applications

G'day:

I needed to add email functionality to a Symfony app - specifically, sending templated emails when the application receives webhooks from external systems. Simple enough requirement, but I had absolutely no idea how Symfony handles emails. Coming from other frameworks where you just chuck some SMTP details at a mail function and hope for the best, I figured it was time to learn "the Symfony way" (Sending Emails with Mailer).

The basic requirement: when a webhook comes in, validate the data, do some processing, and fire off a nicely formatted email to let someone know what happened. Plus, as a stretch goal, I wanted to set up email notifications for system errors - because finding out your application's gone tits up from an angry user is never ideal.

Since I'm using my symfony-scheduler project as the test bed (can't show you the real application for obvious reasons), I'll trigger the emails from one of the scheduled tasks that's already running. Same principle, different trigger.

What we'll cover:

  • Setting up a development SMTP server with Docker
  • Installing and configuring symfony/mailer
  • Sending basic emails from application code
  • Creating proper email templates with Twig
  • Bonus: email notifications for system errors via Monolog

Fair warning: my AI colleague is helping write this article as we work through the implementation, so if something sounds off, blame the robot. She's been pretty good at capturing my voice though, so hopefully it reads like I actually wrote it myself.

SMTP server for development

First things first: you can't test email functionality without somewhere to send emails. For development, the last thing you want is accidentally spamming real email addresses, so I use reachfive/fake-smtp-server - a lightweight Docker container that captures all outgoing emails instead of actually sending them.

Adding it to the existing docker-compose.yml was dead simple:

services:
  # ... existing services ...

  mail:
    image: reachfive/fake-smtp-server
    ports:
      - "1080:1080"
      - "25:25"
    command: ["node", "index.js", "--smtp-port", "25"]
    restart: unless-stopped

This gives you:

  • SMTP server on port 25 - where your application sends emails
  • Web interface on port 1080 - http://localhost:1080 to view captured emails
  • API on port 1080 - http://localhost:1080/api/emails for integration tests

The web interface is particularly handy during development - you can see exactly what emails your application is sending, complete with headers, formatting, and attachments. Much better than trying to debug email issues by actually sending test emails to yourself.

I initially thought about writing integration tests to verify the SMTP server was working properly - testing that PHP could connect and send emails via raw SMTP commands. But then I realised that was pointless complexity. The fake SMTP server either works or it doesn't, and we'll be using Symfony Mailer anyway, not raw PHP mail() functions. Plus, PHP's mail() function on Linux typically ignores SMTP settings and just calls the system's sendmail binary anyway.

Better to focus the testing effort on the actual Symfony email integration once that's set up.

Installing and configuring Symfony Mailer

With the SMTP server sorted, installing Symfony Mailer was straightforward:

composer require symfony/mailer:7.3.*

Symfony Flex automatically created the basic configuration in config/packages/mailer.yaml, which just references an environment variable for the DSN (Data Source Name):

framework:
    mailer:
        dsn: '%env(MAILER_DSN)%'

The MAILER_DSN is where all the SMTP connection details go in a single URL format. For our fake SMTP server setup, I added this to the private environment variables (private, because in other environments we'd have a password in there):

MAILER_DSN=smtp://host.docker.internal:25?auto_tls=false

The auto_tls=false bit is crucial - by default, Symfony Mailer tries to negotiate TLS encryption when connecting to SMTP servers. Our fake server doesn't support encryption, so we need to explicitly disable it. This is a properly documented Symfony option, unlike some of the cargo cult configuration you'll find on Stack Overflow that references invalid parameters like allow_self_signed (which is a PHP stream context option, not a Symfony Mailer DSN parameter).

Integration testing

With Symfony Mailer configured, I wrote some integration tests to verify the email functionality actually works end-to-end. The tests use the real Symfony Mailer service, send emails through our configured transport, and verify they're captured by the fake SMTP server via its API.

I chose three specific test scenarios:

Basic text email - Tests the fundamental workflow: create an email, send it via Symfony Mailer, and verify it appears in the fake SMTP server with the expected content and headers.

HTML email - Tests HTML content handling and verifies that Symfony properly sets the Content-Type header to text/html. The fake SMTP server captures the actual HTML in the html field, so we can assert against the real content rather than assuming it worked.

Multiple recipients - Tests TO and CC recipients to ensure address parsing and delivery works correctly with multiple destinations.

Code is @ tests/Integration/Mail/MailerTest.php.

One thing that tripped me up initially: the fake SMTP server's API format wasn't quite what I expected. The html field contains the actual HTML content (when present), not a boolean flag indicating whether the email is HTML. Learning that from the actual API response saved me from making incorrect assumptions about the data structure.

The integration tests also required making the MailerInterface service public for testing, since Symfony services are private by default. I added a public alias in config/services_dev.yaml:

services:
    testing.MailerInterface:
        alias: 'Symfony\Component\Mailer\MailerInterface'
        public: true

With those tests passing, I had confidence that the email infrastructure was working properly and ready for use in the actual application.

Sending emails from application code

With the configuration sorted, I added basic email functionality to the existing SendEmailsTaskHandler - a scheduled task that runs every 30 seconds, making it perfect for testing email functionality.

The implementation was straightforward - inject the MailerInterface via constructor dependency injection and create a simple notification email:

class SendEmailsTaskHandler extends AbstractTaskHandler
{
    public function __construct(
        LoggerInterface $tasksLogger,
        EntityManagerInterface $entityManager,
        ScheduleFormatDetector $scheduleFormatDetector,
        ScheduleTimezoneConverter $scheduleTimezoneConverter,
        private readonly MailerInterface $mailer
    ) {
        parent::__construct($tasksLogger, $entityManager, $scheduleFormatDetector, $scheduleTimezoneConverter);
    }

    protected function handle(DynamicTaskMessage $task): string
    {
        $email = new Email()
            ->from('scheduler@symfony-scheduler.local')
            ->to('admin@symfony-scheduler.local')
            ->subject('Scheduled task execution: ' . $task->getName())
            ->text("The scheduled task '{$task->getName()}' executed successfully");

        $this->mailer->send($email);

        return 'Email sent successfully';
    }
}

Simple enough. The task reported "Email sent successfully" in the logs, but nothing appeared in the fake SMTP server. This kicked off a proper debugging session.

The mystery: tests work, scheduled tasks don't

The integration tests were working perfectly - emails appeared in the fake SMTP server exactly as expected. But the same code running in the scheduled task context produced no emails, despite the logs claiming success.

Comparing the logs from both scenarios revealed the smoking gun. The test emails showed proper mailer debug logs:

[2025-08-13T12:19:28.759542+00:00] mailer.DEBUG: Email transport "Symfony\Component\Mailer\Transport\Smtp\SmtpTransport" starting [] []
[2025-08-13T12:19:28.888593+00:00] mailer.DEBUG: Email transport "Symfony\Component\Mailer\Transport\Smtp\SmtpTransport" started [] []

The scheduled task logs showed the messenger handling the email message, but the crucial mailer debug logs were completely missing. The application wasn't even attempting to connect to the SMTP server.

The culprit: Symfony Flex "helpful" defaults

After scratching my head for longer than I care to admit, I checked the .env file:

###> symfony/mailer ###
MAILER_DSN=null://null
###< symfony/mailer ###

There it was. Symfony Flex had helpfully set the default mailer DSN to null://null - a transport that silently discards all emails. My override in appEnvVars.private with the real SMTP connection string was only being used in the test environment context, while the running application was still using the base .env default.

One quick change to update the .env file with the real SMTP DSN, and emails started appearing in the fake server immediately. Classic environment variable precedence gotcha - the kind of thing that can drive you mental for hours if you're not paying attention to which configuration files are actually being loaded.

The reason tests worked while the scheduled task didn't comes down to environment loading order. Tests (see tests/bootstrap.php use the standard Composer autoloader (vendor/autoload.php), which doesn't automatically process .env files. This allows the custom EnvironmentService::load() to run first and properly load secrets that override any subsequent .env values.

Console commands use Symfony's runtime component (vendor/autoload_runtime.php), which automatically loads .env files early in the bootstrap process. By the time the custom environment loading runs, the .env values are already set and can't be overridden.

Standard load order issue - tests get secrets first, then .env; console gets .env first, then secrets that don't stick.

Email templates with Twig

Rather than cramming HTML into PHP strings, Symfony provides proper templating support for emails through Twig templates. This separates presentation concerns from business logic and makes emails much more maintainable.

I created a dedicated email template at templates/email/sms_task_notification.html.twig:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>SMS Task Notification</title>
    <style>
        body { font-family: Arial, sans-serif; margin: 20px; }
        .header { background-color: #f8f9fa; padding: 15px; border-radius: 5px; }
        .task-details { background-color: #e9ecef; padding: 10px; border-radius: 3px; }
        .success { color: #28a745; font-weight: bold; }
    </style>
</head>
<body>
    <div class="header">
        <h1>SMS Task Execution Report</h1>
        <p class="success">✓ Task completed successfully</p>
    </div>
    
    <div class="content">
        <p>The scheduled SMS task "<strong>{{ task.name }}</strong>" has been executed.</p>
        
        <div class="task-details">
            <h3>Task Details</h3>
            <ul>
                <li><strong>Task ID:</strong> {{ task.id }}</li>
                <li><strong>Executed At:</strong> {{ executed_at|date('Y-m-d H:i:s') }}</li>
                {% if task.metadata %}
                <li><strong>Configuration:</strong>
                    {% for key, value in task.metadata %}
                        <br>&nbsp;&nbsp;{{ key }}: {{ value }}
                    {% endfor %}
                </li>
                {% endif %}
            </ul>
        </div>
    </div>
</body>
</html>

The template uses proper HTML structure with embedded CSS, dynamic content via Twig variables, and conditional sections for optional data like task metadata.

Using the template is straightforward - replace Email with TemplatedEmail and specify the template path and context variables (see src/MessageHandler/SendSmsTaskHandler.php):

$email = new TemplatedEmail()
    ->from('scheduler@symfony-scheduler.local')
    ->to('admin@symfony-scheduler.local')
    ->subject('SMS Task Completed: ' . $task->getName())
    ->htmlTemplate('email/sms_task_notification.html.twig')
    ->context([
        'task' => $task,
        'executed_at' => new DateTimeImmutable()
    ]);

$this->mailer->send($email);

The resulting emails look professional and are much easier to maintain than inline HTML strings.

I mean it's not gonna put Banksy out of work or anything, but it's better than "oi, it worked" in plain text.

Plus, designers can work on the templates without touching PHP code.

Taking it further: decoupled messaging

For even better separation of concerns, consider creating a dedicated message/handler pair like SendTaskSuccessMessage and SendTaskSuccessMessageHandler. This way, task handlers don't need to know about email addresses, templates, or even that notifications are sent via email - they just dispatch a SendTaskSuccessMessage and let the messaging system handle the rest. This is how Symfony Mailer deals with async messaging: it doesn't do anything special itself, one just uses the Symfony Messaging system.

This approach is covered in detail in my previous article on Using Symfony's Messaging system to separate the request for work to be done and doing the actual work.

Email notifications for system errors via Monolog

For production applications, you want to know immediately when something goes seriously wrong. Monolog includes a built-in email handler that can send notifications when critical errors occur.

Setting this up requires adding an email handler to the Monolog configuration in config/packages/monolog.yaml:

monolog:
    channels:
        - tasks # Task execution logging
    
    handlers:
        main:
            type: stream
            path: "%kernel.logs_dir%/%kernel.environment%.log"
            level: debug
            channels: ["!event"]
        error_email:
            type: symfony_mailer
            from_email: '%env(EMAIL_NO_REPLY)%'
            to_email: '%env(EMAIL_DEV_TEAM)%'
            subject: 'Critical Application Error in Symfony Scheduler'
            level: critical
            channels: ["!tasks"]  # Don't email task-related errors

The symfony_mailer handler type uses the same mailer configuration we set up earlier. The key settings are:

Log level threshold: Setting level: critical is crucial. Symfony automatically logs unhandled exceptions at different levels depending on the resulting HTTP status code - 5xx errors get logged as critical, while 4xx errors (like 404s) get logged as warning or error. Using critical means you only get emailed for serious application failures, not routine issues like missing pages. Trust me, you don't want to get emailed every time someone hits a 404.

Channel filtering: The channels: ["!tasks"] excludes task-related errors since we're already handling task notifications separately through the scheduled email tasks.

To test the setup, I created a simple controller (see src/Controller/TestErrorController.php) with three different error scenarios:

#[Route('/exception')]
public function testException(): Response
{
    throw new RuntimeException('Deliberate test exception');
}

#[Route('/critical')]
public function testCritical(LoggerInterface $logger): Response
{
    $logger->critical('Test critical error');
    return new Response('Critical error logged (should trigger email)');
}

#[Route('/error')]
public function testError(LoggerInterface $logger): Response
{
    $logger->error('Test error for log files only');
    return new Response('Error logged (should NOT trigger email)');
}

The first two scenarios trigger emails - the unhandled exception because Symfony automatically logs it as request.CRITICAL, and the manual critical log because it meets the level threshold. The regular error log goes to the log files but doesn't trigger an email.

This gives you immediate notification of serious application problems while avoiding email spam from routine logging.

That's about all me 'n' Claudia came up with to look into. It's certainly clarified a few things I had encountered before and didn't quite understand (nor had time to work out), so that's a win too. Again, Claudia wrote most of this, under my "supervision" / proofreading. I guess it's pair-blogging if you will.

Righto.

--
Adam