symfony/postmark-mailer
Postmark integration for Symfony Mailer, supporting both SMTP and API transports via simple DSN configuration using your Postmark Server Token. Configure with postmark+smtp://ID@default or postmark+api://KEY@default.
Install the Package
composer require symfony/postmark-mailer
Laravel's MailManager discovers the postmark / postmark+smtp transports automatically — no custom transport class is required.
Configure .env
# API transport (recommended: templates, tags, metadata, bounce webhooks)
MAIL_MAILER=postmark
MAILER_DSN=postmark+api://YOUR_POSTMARK_SERVER_TOKEN@default
# SMTP transport (alternative: plain SMTP relay)
# MAILER_DSN=postmark+smtp://YOUR_POSTMARK_SERVER_TOKEN@default
Keep the server token out of config/ — read it via env() only.
First Use Case: Send a Test Email
use Illuminate\Support\Facades\Mail;
use App\Mail\TestEmail;
Mail::to('[email protected]')->send(new TestEmail());
Or drop down to Symfony's mailer for full header control:
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
$email = (new Email())
->from('[email protected]')
->to('[email protected]')
->subject('Test Email')
->text('Hello, this is a test!');
app(MailerInterface::class)->send($email);
Verify in Postmark Dashboard Check the Postmark web interface for the activity feed, delivery events, and raw message source.
Upgrade note (v8.1.6): if you consume Postmark webhooks (bounces, spam complaints, inbound parsing), pin
^8.1.6. The fix in #65674 makes the bridge authenticate webhook requests with the configured secret instead of ignoring it. On v8.1.0–v8.1.5 your configured credential was not applied, so signature/auth checks could pass or fail for the wrong reason.
Use the API transport for per-message error codes (ErrorCode / MessageID) that you can log and retry on; use SMTP only when you need a legacy relay path or very large attachments.
// config/mail.php
'postmark' => [
'transport' => 'postmark',
],
Mail::to($user->email)->send(new PasswordResetMail($token));
Queuing is a Mailable concern in Laravel, not a mailer-config concern:
Mail::to($user->email)->queue(new WelcomeEmail($user));
// or: ->later(now()->addMinutes(5), new WelcomeEmail($user))
class WelcomeEmail extends Mailable implements ShouldQueue
{
public $tries = 3;
public $backoff = [10, 60, 300];
}
Serialize models, not closures, and keep attachments on disk (attachFromStorage) so queued payloads stay small.
Symfony's bridge does not expose Postmark's server-side template IDs. Two supported options:
->html(...), and keep Postmark for delivery.Http::withHeaders(['X-Postmark-Server-Token' => ...]) when you need TemplateId/TemplateModel.Tag messages so you can filter the Postmark activity feed:
$email->getHeaders()->addTextHeader('X-Postmark-Tag', 'password-reset');
(Or set X-Postmark-Tag via a Mailable withSymfonyMessage() callback.)
Route::post('/postmark/webhook', PostmarkWebhookController::class)
->middleware(['throttle:120,1']);
public function __invoke(Request $request)
{
$payload = $request->json()->all();
// v8.1.6+: the bridge now uses the secret you configured.
// If you verify yourself, use Postmark's Basic Auth credentials:
// abort_unless(hash_equals($expected, $request->getPassword()), 403);
match ($payload['RecordType'] ?? null) {
'Bounce' => $this->handleBounce($payload),
'SpamComplaint' => $this->handleComplaint($payload),
'Inbound' => $this->handleInbound($payload),
'Delivery' => null, // no-op
default => null,
};
return response()->noContent();
}
Configure the same username/password in the Postmark dashboard and in your DSN/webhook config so both sides agree.
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
try {
Mail::to($user->email)->send(new ReceiptMail($order));
} catch (TransportExceptionInterface $e) {
report($e);
// Postmark rejections are usually permanent (422) — don't blindly retry.
}
symfony/mailer's transport factory; adding a PostmarkTransport singleton in AppServiceProvider will shadow Laravel's and break Mail::fake()/Mail::assertSent().Mail::fake() for application tests, and symfony/mailer's TestMailer when you want to assert on the raw Symfony Email object (headers, CIDs, parts).config/mail.php (e.g. postmark_marketing) with a different server token; select it with Mail::mailer('postmark_marketing').MessageID + RecordType in a unique index rather than assuming single delivery.Webhook secret was ignored before v8.1.6 The bridge now authenticates incoming webhook requests with the configured secret (#65674). If your webhook endpoint stopped or started rejecting traffic after an upgrade, re-check the credentials configured on both the Postmark side and your mailer/webhook config — don't "fix" it by disabling verification.
Date header dropped in API transport
The API transport strips Date before sending. Add it manually only if a downstream consumer requires it:
$email->getHeaders()->addDateHeader('Date', new \DateTimeImmutable());
Inline attachments / CID references
Use ->embed() / ->embedFromPath() so Symfony generates matching Content-ID headers; hand-rolled CID headers are the usual cause of broken images. Inspect the raw source in the Postmark dashboard to confirm.
Payload size Postmark caps message size (~10 MB). Keep attachments in object storage and send links, or fall back to SMTP for the rare large send.
SMTP vs API throughput
Postmark rate-limits per server token. API transport gives you explicit 429/ErrorCode responses you can retry with backoff; SMTP just blocks or drops. For high volume, pair queued mailables with a rate-limited queue.
MAIL_MAILER vs MAILER_DSN
Laravel builds the transport from MAIL_MAILER; the DSN belongs to symfony/mailer. Mixing the two (setting only MAILER_DSN) is a frequent "it's still sending via log" cause.
Verbose transport logging
MAIL_MAILER=postmark
MAIL_LOG_CHANNEL=stack
Wire a logger into the mailer and tail storage/logs/laravel.log for TransportException output.
Raw API probe
curl -X POST https://api.postmarkapp.com/email \
-H "X-Postmark-Server-Token: YOUR_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"From":"[email protected]","To":"[email protected]","Subject":"Test","HtmlBody":"<p>Hello</p>"}'
Isolates token/account problems from Laravel config problems.
Common errors
| Error | Cause | Fix |
|---|---|---|
Invalid Server Token (401) |
Wrong token in DSN / wrong server | Verify token belongs to the intended Postmark server |
422 Inactive recipient |
Address suppressed by a prior bounce/complaint | Reactivate in Postmark, or handle in app logic |
Payload Too Large |
Message > ~10 MB | Link attachments or use SMTP |
Date header missing |
API transport strips it | Add Date header explicitly |
| Webhook returns 401/403 | Secret not configured or mismatched (fixed in 8.1.6) | Align Postmark webhook credentials with your config |
Mail::assertSent() fails |
Custom transport binding shadows Laravel's | Remove the manual TransportInterface singleton |
Extension point
Subclass the bridge's PostmarkApiTransport only if you must inject custom headers or retry logic; register it via a custom mailer in config/mail.php ('transport' => 'postmark' subclasses can be bound through Mail::extend()), not by rebinding TransportInterface globally.
How can I help you explore Laravel packages today?