Mugo Web main content.

Creating a file mail transport for Symfony

By: Philipp Kamps | August 27, 2026 | development and symfony

When developing a Symfony application, you often need to inspect the emails your application generates without actually sending them. While tools like Mailpit or Mailtrap are excellent choices, sometimes you want something even simpler: write every email to disk as a standard .eml file.

This approach is particularly useful for:

  • Local development
  • CI pipelines
  • Automated testing
  • Staging environments where no real emails should leave the application
  • Debugging complex email templates

In this article, we'll build a custom Symfony Mailer transport that stores every outgoing email as an RFC 822 compatible .eml file.

Why a file transport?

Writing emails to disk has several advantages:

  • No external mail server required
  • Emails can be opened with any mail client
  • Attachments remain intact
  • Easy to archive or inspect generated emails
  • Perfect for automated tests and debugging

Instead of sending mail over SMTP, our transport simply serializes the complete message and stores it in a directory.

Creating the transport

The transport itself extends Symfony's AbstractTransport.

use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Mailer\SentMessage;
use Symfony\Component\Mailer\Transport\AbstractTransport;

class MailerFileTransport extends AbstractTransport
{
    public function __construct(
        private string $directory,
        ?EventDispatcherInterface $dispatcher = null,
        ?LoggerInterface $logger = null,
    )
    {
        parent::__construct($dispatcher, $logger);

        if (
            !is_dir($this->directory)
            && !mkdir($this->directory, 0775, true)
            && !is_dir($this->directory)
        ) {
            throw new \RuntimeException(
                sprintf('Cannot create mail dump directory "%s".', $this->directory)
            );
        }
    }

    public function __toString(): string
    {
        return sprintf('file://%s', $this->directory);
    }

    protected function doSend(SentMessage $message): void
    {
        $filename = sprintf(
            '%s/%s-%s.eml',
            rtrim($this->directory, '/'),
            (new \DateTimeImmutable())->format('Ymd-His'),
            bin2hex(random_bytes(4)),
        );

        file_put_contents($filename, $message->toString());
    }
}

The implementation is intentionally straightforward.

Whenever Symfony wants to send an email, the transport generates a unique filename and writes the complete email using SentMessage::toString(). The result is a valid .eml file that can be opened with Outlook, Thunderbird, Apple Mail, or many other mail clients. You can even read it with a simple text editor.

Registering the transport factory

Symfony Mailer discovers transports through transport factories. We therefore need to implement a factory for our custom file:// DSN.

use Symfony\Component\Mailer\Exception\UnsupportedSchemeException;
use Symfony\Component\Mailer\Transport\AbstractTransportFactory;
use Symfony\Component\Mailer\Transport\Dsn;
use Symfony\Component\Mailer\Transport\TransportInterface;

final class MailerFileTransportFactory extends AbstractTransportFactory
{
    public function create(Dsn $dsn): TransportInterface
    {
        if ('file' !== $dsn->getScheme()) {
            throw new UnsupportedSchemeException(
                $dsn,
                'file',
                $this->getSupportedSchemes()
            );
        }

        // MAILER_DSN=file:///var/mails
        // MAILER_DSN=file://default?path=/var/mails

        $directory = $dsn->getOption('path') ?? $dsn->getHost();

        if (!$directory || $directory === 'default') {
            throw new \InvalidArgumentException(
                'The "file" mailer DSN must include a directory.'
            );
        }

        return new MailerFileTransport(
            $directory,
            $this->dispatcher,
            $this->logger
        );
    }

    protected function getSupportedSchemes(): array
    {
        return ['file'];
    }
}

The factory allows Symfony to recognize a file:// DSN and instantiate the correct transport.

Registering the factory

Register the factory as a service so Symfony Mailer can discover it.

# config/services.yaml

App\MailerFileTransportFactory:
    parent: mailer.transport_factory.abstract
    tags:
        - mailer.transport_factory

Once this service is registered, Symfony automatically supports the new transport scheme.

Configuring the mailer

Finally, configure your local environment to use the file transport.

# .env.local

MAILER_DSN="file://default?path=<path to project dir>/var/cache/dev/mails"

Replace <path to project dir> with the absolute path to your Symfony project. For example:

MAILER_DSN="file://default?path=/home/developer/projects/my-symfony-app/var/cache/dev/mails"

Note: You cannot use Symfony parameters such as %kernel.project_dir% directly inside a .env or .env.local file. Environment files are processed before Symfony's service container resolves parameters. Symfony parameters can be used in YAML configuration files, such as config/packages/mailer.yaml, but not as parameter placeholders inside .env values.

Every email generated by your application will now appear inside:

var/cache/dev/mails

Each message is stored as an individual .eml file with a timestamp and a random suffix to avoid filename collisions.

Opening the emails

Because the transport stores the raw RFC 822 message, you can simply open an .eml file to inspect:

  • Subject
  • Sender
  • Recipients
  • HTML body
  • Plain text body
  • Headers
  • Attachments
  • Embedded images

This gives you an accurate representation of the email exactly as Symfony generated it.

When should you use this?

This transport is ideal when:

  • you don't want to install a third party mail service;
  • your CI environment has no SMTP server;
  • you want to archive generated emails;
  • you need to inspect the exact MIME message;
  • you want deterministic integration tests.

For production, you should of course continue using a real transport such as SMTP, Amazon SES, Postmark, or another supported mail provider.

Conclusion

Creating a custom Symfony Mailer transport is surprisingly easy. By implementing a small amount of code, you gain a lightweight solution for development and testing that requires no additional infrastructure.

The resulting .eml files are portable, easy to inspect, and faithfully represent the exact email that Symfony would have sent. For many projects, this can be an elegant alternative to running a local mail server while still providing complete visibility into every generated email.