Symfony Mailer configures its entire transport from a single MAILER_DSN environment variable — no config class, no per-setting keys. This guide points that DSN at Postwing and covers Twig-templated emails, attachments and asynchronous delivery through Messenger.
| Setting | Value |
|---|---|
| SMTP host | smtp.postwing.app |
| Port | 587 |
| Encryption | STARTTLS (the connection is upgraded to TLS before login) |
| Username | The login of an SMTP token for your domain |
| Password | The password of that token — shown once, when the token is created |
composer require symfony/mailer
composer require symfony/twig-bundle # for TemplatedEmail# .env.local — the whole transport is one DSN
MAILER_DSN=smtp://token-login%40your-domain.com:your-token-password@smtp.postwing.app:587
# Implicit TLS on port 465 uses the smtps scheme instead
# MAILER_DSN=smtps://token-login%40your-domain.com:your-token-password@smtp.postwing.app:465@ in a token login must be written %40 — otherwise the parser splits the string at the wrong place and reports a host it never found. The same goes for :, /, #, ? and & in the password. Set a default sender once, rather than on every message:
# config/packages/mailer.yaml — one default sender for every email
framework:
mailer:
envelope:
sender: 'noreply@your-domain.com'
headers:
From: 'Acme <noreply@your-domain.com>' Inject MailerInterface and send. Setting both text() and html() produces a multipart/alternative message:
<?php
namespace App\Controller;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
class OrderController extends AbstractController
{
#[Route('/orders', methods: ['POST'])]
public function create(MailerInterface $mailer): Response
{
$email = (new Email())
->from('Acme <noreply@your-domain.com>')
->to('customer@example.com')
->subject('Your order #4417 is confirmed')
->text('Thanks! Your order ships tomorrow.')
->html('<h1>Order confirmed</h1><p>Your order ships tomorrow.</p>');
$mailer->send($email);
return $this->json(['ok' => true], 201);
}
}TemplatedEmail renders the body from Twig templates, so email markup lives with the rest of your templates instead of inside a controller:
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
$email = (new TemplatedEmail())
->from(new Address('noreply@your-domain.com', 'Acme'))
->to(new Address($order->getCustomerEmail(), $order->getCustomerName()))
->subject(sprintf('Your order #%d is confirmed', $order->getId()))
->htmlTemplate('emails/order_confirmed.html.twig')
->textTemplate('emails/order_confirmed.txt.twig')
->context(['order' => $order]);
$mailer->send($email);{# templates/emails/order_confirmed.html.twig #}
<h1>Order #{{ order.id }} confirmed</h1>
<p>Thanks, {{ order.customerName }}. Your order ships tomorrow.</p>$email
->addPart(new DataPart(new File('/srv/invoices/2026-03.pdf'), 'invoice.pdf'))
// Inline image, referenced from Twig as {{ email.image('@images/logo.png') }}
->addPart((new DataPart(new File('assets/logo.png'), 'logo'))->asInline()); By default $mailer->send() talks to SMTP inside the request. Routing the mailer's message to an async transport moves delivery to a worker without changing a line of calling code:
# config/packages/messenger.yaml
framework:
messenger:
transports:
async: '%env(MESSENGER_TRANSPORT_DSN)%'
routing:
# Anything mailer sends now goes through the queue instead of the request.
Symfony\Component\Mailer\Messenger\SendEmailMessage: asyncphp bin/console messenger:consume async -vvSendEmailMessage is routed to a transport, nothing is delivered until messenger:consume is running. Forgetting the worker in production is the usual reason mail stops the moment async is switched on. # .env.test / .env.dev — collect messages instead of delivering them
MAILER_DSN=null://nulluse Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class OrderControllerTest extends WebTestCase
{
public function testConfirmationIsSent(): void
{
$client = static::createClient();
$client->request('POST', '/orders', [], [], [], '{"email":"c@example.com"}');
$this->assertEmailCount(1);
$this->assertEmailHtmlBodyContains(self::getMailerMessage(), 'Order confirmed');
}
}| Error | Cause and fix |
|---|---|
The "…" mailer DSN is invalid | Unencoded @ or a special character in the credentials. |
TransportException: Connection could not be established | Outbound port blocked. Use 8587 (smtp) or 8465 (smtps). |
Expected response code 250 but got 535 | Wrong token login or password in the DSN. |
| Nothing sends after enabling Messenger | No worker running. Start messenger:consume async. |
Emails stuck in messenger_messages | Worker crashed or the transport is misconfigured. Check messenger:failed:show. |
| 550 sender rejected | The from domain is not verified, or the token does not cover it. |
URL-encode it: @ becomes %40, so token-login@your-domain.com is written token-login%40your-domain.com. The same applies to any special character in the password — : / ? # and & all need encoding, or the DSN parses into the wrong parts and you get a confusing connection error.
smtp:// opens a plain connection and upgrades it with STARTTLS, which is port 587. smtps:// opens an implicitly encrypted connection, which is port 465. Symfony picks the right behaviour from the scheme, so you do not configure encryption separately.
Route Symfony\Component\Mailer\Messenger\SendEmailMessage to an async transport in messenger.yaml and run a worker with messenger:consume. Nothing in your calling code changes — $mailer->send() starts dispatching to the bus instead of talking to SMTP inline.
If Messenger routing is configured but no worker is running, the message sits in the queue forever. Check that messenger:consume is running under a supervisor, and look at the failed transport for messages that were retried and given up on.
Set MAILER_DSN=null://null and open the Symfony Profiler's Email panel, which shows the rendered HTML, the headers and the recipients for each message the request produced. In tests, assertEmailCount and the assertEmail* helpers work against the same collected messages.
Symfony Mailer if you are on Symfony — it integrates with Twig, Messenger, the Profiler and the test assertions. PHPMailer is the better fit for standalone scripts or legacy code with no framework, where a single small dependency matters more than integration.