Every Laravel app sends email. Password resets, invoices, invitations, alerts, digests. It is also the part of the stack that gets the least engineering attention until a customer says "I never got the reset link" and you discover you have no idea whether the message was sent, accepted, deferred, bounced or silently dropped into a spam folder.
This tutorial builds transactional email in a Laravel 13 app the way we build it for clients: queued mailables and notifications, a provider with event webhooks, a suppression list you control, authenticated sending domains, and tests that assert the right message went to the right person. None of it is exotic — it is mostly the boring parts that teams skip.
What "deliverability" actually means
Three different things get called the same word. Keep them separate:
- Sending — your app handed the message to a transport without an exception. This is all
Mail::send()proves. - Delivery — the receiving server accepted the message (a
deliveredevent from your provider). This can arrive seconds or minutes later. - Placement — the message landed in the inbox rather than spam. No provider can promise this; you influence it with domain authentication, list hygiene and sending reputation.
Most Laravel apps only ever observe step 1, which is why "email is broken" bugs take days to diagnose. The work below turns steps 2 and 3 into data you can query.
Authenticate the sending domain first
No amount of application code compensates for an unauthenticated domain. Before writing PHP, make sure the domain in your MAIL_FROM_ADDRESS has:
- SPF — a TXT record on the domain authorising your provider's sending hosts, for example
v=spf1 include:_spf.example-provider.com -all. One SPF record per domain, no more. - DKIM — the CNAME or TXT records your provider gives you, so each message carries a signature that survives forwarding.
- DMARC — a TXT record at
_dmarc.yourdomain.com. Start atv=DMARC1; p=none; rua=mailto:dmarc-reports@yourdomain.com, read the aggregate reports for a couple of weeks, then move top=quarantineand eventuallyp=reject.
Two rules that save pain later: send transactional mail from a subdomain (mail.yourdomain.com or notifications.yourdomain.com) so a marketing blast can never damage the reputation that carries your password resets, and never set MAIL_FROM_ADDRESS to a customer's address. If a user submits a contact form, the sender is you; put their address in replyTo().
// app/Mail/ContactFormReceived.php
public function envelope(): Envelope
{
return new Envelope(
subject: 'New enquiry from '.$this->enquiry->name,
replyTo: [new Address($this->enquiry->email, $this->enquiry->name)],
);
}
Spoofing the customer's domain in the From header is the single most common reason a contact form stops reaching an inbox once the customer's domain publishes p=reject.
Configure transports, including a failover
config/mail.php supports multiple mailers and a failover transport that tries them in order:
'default' => env('MAIL_MAILER', 'failover'),
'mailers' => [
'failover' => [
'transport' => 'failover',
'mailers' => ['postmark', 'ses'],
'retry_after' => 60,
],
'postmark' => [
'transport' => 'postmark',
'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
],
'ses' => [
'transport' => 'ses',
],
'log' => [
'transport' => 'log',
'channel' => 'mail',
],
],
Keep two independent providers configured in production with separate credentials. Provider outages are not rare, and a failover transport turns a four-hour incident into a log line. Locally use MAIL_MAILER=log or a catcher such as Mailpit; never let a staging environment talk to a real provider with real customer addresses.
Use a dedicated message stream (or the provider's equivalent) for transactional mail, separate from bulk. Providers reputation-score streams independently.
Queue everything, but queue it correctly
A mailable that implements ShouldQueue never blocks a web request:
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\SerializesModels;
class InvoiceIssued extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(public Invoice $invoice) {}
public function envelope(): Envelope
{
return new Envelope(subject: "Invoice {$this->invoice->number}");
}
public function content(): Content
{
return new Content(markdown: 'mail.invoices.issued');
}
}
Three details matter more than the mailable itself:
Send after commit. If you dispatch inside a database transaction, a worker can pick the job up before the row exists. Set after_commit => true on the queue connection, or call Mail::to($user)->later(now(), $mailable) from an event fired after commit.
Use a separate queue and a sane retry policy. Email should not sit behind a million-row export:
Mail::to($user)
->queue((new InvoiceIssued($invoice))->onQueue('mail'));
// In the mailable or notification
public int $tries = 3;
public array $backoff = [60, 300, 900];
Exponential backoff matters because provider errors are usually transient rate limits. Retrying three times in three seconds just burns your quota.
Make sends idempotent. A queue retry after a timeout can send the same message twice. Record intent before sending and check it:
public function handle(): void
{
$sent = EmailLog::firstOrCreate(
['mailable' => static::class, 'key' => "invoice:{$this->invoice->id}"],
['status' => 'queued'],
);
if ($sent->wasRecentlyCreated === false && $sent->status !== 'failed') {
return;
}
Mail::to($this->invoice->billing_email)->send(new InvoiceIssued($this->invoice));
}
Notifications, channels and user preferences
For anything a user can opt out of, use notifications rather than raw mailables — the channel abstraction is where preferences belong:
class PaymentFailed extends Notification implements ShouldQueue
{
use Queueable;
public function via(object $notifiable): array
{
return array_values(array_filter([
$notifiable->wantsEmail('billing') ? 'mail' : null,
'database',
$notifiable->slack_webhook ? 'slack' : null,
]));
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->error()
->subject('We could not process your payment')
->greeting("Hello {$notifiable->first_name},")
->line('Your most recent payment was declined by your bank.')
->action('Update payment method', route('billing.index'))
->line('We will retry automatically in three days.');
}
}
Always write the database channel as well. When support asks "what did we send this customer?", an in-app notification history answers in one query instead of a provider dashboard search.
For digests, batch instead of blasting: aggregate events during the day and send one queued notification on a schedule. Ten thousand single emails at 09:00 is both a worse user experience and a reputation risk.
Consume provider webhooks
This is the step that converts "we sent it" into "it arrived". Every serious provider posts events — Delivery, Bounce, SpamComplaint, SubscriptionChange — to an HTTPS endpoint.
// routes/web.php
Route::post('/webhooks/postmark', PostmarkWebhookController::class)
->middleware('postmark.signature')
->withoutMiddleware([VerifyCsrfToken::class]);
Verify authenticity before trusting the payload — a shared secret in the URL path plus HTTP basic auth, or the provider's signature header. Then record and act:
public function __invoke(Request $request): Response
{
$type = $request->string('RecordType')->toString();
$email = strtolower($request->string('Email')->toString());
EmailEvent::create([
'message_id' => $request->input('MessageID'),
'email' => $email,
'type' => $type,
'payload' => $request->all(),
'occurred_at' => $request->date('ReceivedAt') ?? now(),
]);
if ($type === 'SpamComplaint' || $request->boolean('Inactive')) {
Suppression::suppress($email, reason: $type);
}
return response()->noContent();
}
Keep the handler thin: validate, store, dispatch a queued job for anything slow, return 2xx quickly. Providers retry on timeouts, and slow webhook endpoints get disabled.
Maintain your own suppression list
Providers keep suppression lists, but you should keep one too — it is the difference between an audit you can answer and a shrug.
class EnsureNotSuppressed
{
public function handle(MessageSending $event): bool
{
$recipients = array_keys($event->message->getTo() ?? []);
foreach ($recipients as $address) {
if (Suppression::isSuppressed($address)) {
Log::warning('Blocked send to suppressed address', ['email' => $address]);
return false; // cancels the send
}
}
return true;
}
}
Register it as a listener for Illuminate\Mail\Events\MessageSending; returning false cancels the message. Hard bounces and spam complaints suppress permanently. Soft bounces should suppress only after repeated failures — a full mailbox clears up.
One exception worth coding deliberately: security-critical mail (password reset, MFA change, breach notice) is sometimes still worth attempting to a soft-bounced address. Make that a conscious rule in Suppression::isSuppressed(), not an accident.
Tag and correlate every message
Add headers so a provider event can be traced back to a user, a tenant and a request:
public function headers(): Headers
{
return new Headers(
text: [
'X-PM-Tag' => 'invoice-issued',
'X-Tenant-Id' => (string) $this->invoice->tenant_id,
'X-Request-Id' => (string) Str::uuid(),
],
);
}
Store the provider's message ID on send by listening for MessageSent and reading $event->sent->getMessageId(). Now a support question — "did Maria get her invoice on the 3rd?" — is a single join between your email_logs and email_events tables.
Write the templates like a grown-up
- Always ship a plain-text alternative. Markdown mailables generate one automatically; hand-built HTML views do not, and HTML-only messages score worse with filters.
- Inline CSS, table layouts, no external stylesheets, no web fonts. Laravel's markdown mail theme already does this; if you customise, publish with
php artisan vendor:publish --tag=laravel-mail. - Keep images light and always set
alttext — many clients block images by default and your call to action must survive that. - Use a real subject line, not
[App] Notification. Filters and humans both penalise vagueness. - Include a physical address and, for anything non-transactional, a working one-click unsubscribe. Mixing marketing content into transactional mail is how a password-reset stream gets a spam complaint.
Test it
Laravel's fakes make assertions cheap, and these tests catch real regressions:
use Illuminate\Support\Facades\Mail;
it('emails the billing contact when an invoice is issued', function () {
Mail::fake();
$invoice = Invoice::factory()->create(['billing_email' => 'ap@acme.test']);
IssueInvoice::run($invoice);
Mail::assertQueued(InvoiceIssued::class, function ($mail) use ($invoice) {
return $mail->hasTo('ap@acme.test')
&& $mail->invoice->is($invoice)
&& $mail->assertSeeInHtml($invoice->number);
});
});
it('never sends to a suppressed address', function () {
Mail::fake();
Suppression::suppress('ap@acme.test', reason: 'HardBounce');
Mail::assertNotQueued(InvoiceIssued::class);
});
Add a rendering test for each template ((new InvoiceIssued($invoice))->render()) so a missing Blade variable fails in CI rather than in a customer's inbox. And run a seeded preview route in local and staging — Route::get('/mail/preview/invoice', fn () => new InvoiceIssued(Invoice::factory()->make())) — so designers can iterate without sending anything.
Monitor what you have built
Once events are stored, put three numbers on a dashboard and alert on them:
- Bounce rate over the last 24 hours, by tag. Above roughly 2% sustained, providers start throttling.
- Complaint rate. Above 0.1% is trouble; investigate the tag responsible immediately.
- Sent-but-never-delivered, messages with no terminal event after 30 minutes. This is the metric that catches a silently broken transport, and almost nobody has it.
A scheduled command that queries email_events and pushes these to Pulse, a Slack channel or your alerting tool takes an afternoon and pays for itself the first time a provider account gets rate-limited overnight.
A pragmatic rollout order
If you are retrofitting this into an existing app, do it in this sequence: authenticate the domain, split transactional onto its own subdomain and stream, queue the sends, log sends with a message ID, ingest webhooks, build the suppression list, then add monitoring. Each step is independently useful and none of them require a rewrite.
Email is infrastructure. Treated as infrastructure — authenticated, queued, observed and tested — it stops being the thing that quietly loses customers.
Struggling with an email pipeline that sends into the void, or a Laravel app where nobody can say what was delivered to whom? Our Laravel consultants do this kind of remediation regularly. Get in touch with the details of your setup and we will tell you what we would change first.