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_factoryOnce 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.

