Laravel Mail: Best Practices for Scaling Past 100k Sends/Month
Laravel Mail just works at low volume. Past 100k/month, the cracks show. Here is the production playbook: queues, connections, retries, per-stream tagging.
Laravel ships an excellent mail system that handles the first 10k messages a month without you noticing. Beyond that volume you start running into queue worker starvation, connection limits, lost retries on deploy, and one big foot-gun: the default Mailable will re-send if the queue worker dies mid-job. This article covers the production patterns that move Laravel Mail from "works" to "survives a Black Friday".
Step 1: Never Send Inline
The single most common bug at scale: calling Mail::send() from a controller. This blocks the HTTP request, exposes the SMTP/API latency to your P99, and on failure throws an exception that may or may not be caught.
Always queue:
// BAD - blocks the request
Mail::to($user)->send(new OrderConfirmation($order));
// GOOD - returns immediately, sends asynchronously
Mail::to($user)->queue(new OrderConfirmation($order));Or, on the Mailable itself:
class OrderConfirmation extends Mailable implements ShouldQueue {
use Queueable;
public $tries = 3;
public $backoff = [60, 300, 900];
}Step 2: Dedicated Queue
Do not put email on the default queue. Run a separate worker pool for email so a backlog in image processing does not delay password resets, and vice versa.
// config/queue.php
'connections' => [
'redis' => [
'driver' => 'redis',
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 90,
],
],php artisan queue:work redis --queue=emails --tries=3 --backoff=60,300,900And on the Mailable:
public function __construct() {
$this->onQueue('emails');
}Step 3: Mailer Configuration
For 100k+/month use SMTP with a pool, or better, the API driver. The default smtp driver opens a fresh connection per send. For volume you want persistent connections.
// config/mail.php
'mailers' => [
'targetsmtp' => [
'transport' => 'smtp',
'host' => env('MAIL_HOST', 'smtp.targetsmtp.it'),
'port' => env('MAIL_PORT', 587),
'encryption' => 'tls',
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'timeout' => 10,
'local_domain' => env('MAIL_EHLO_DOMAIN'),
],
],Set MAIL_EHLO_DOMAIN to your real sending domain. The Symfony Mailer underneath uses this for HELO/EHLO, and receivers compare it to your reverse DNS.
Step 4: Per-Stream Tagging
If you send password resets, order confirmations and marketing digests from the same domain, you want per-stream Feedback-ID so Gmail Postmaster can break down the spam rate.
class OrderConfirmation extends Mailable {
public function build() {
return $this->from('orders@example.com', 'Acme Shop')
->subject("Order #{$this->order->id} confirmed")
->withSymfonyMessage(function ($message) {
$message->getHeaders()->addTextHeader(
'Feedback-ID',
"order:transactional:acme:order-confirm"
);
})
->view('emails.orders.confirmation');
}
}Step 5: Idempotency
Laravel jobs can be retried by the worker. If the worker died after the API call succeeded but before the job was marked complete, you re-send. Stop that by passing an idempotency key derived from a stable identifier:
->withSymfonyMessage(function ($message) {
$message->getHeaders()->addTextHeader(
'X-Idempotency-Key',
"order-confirm:{$this->order->id}:v1"
);
});If your provider supports idempotency keys (Target SMTP does via the REST transport), the duplicate is silently absorbed. With raw SMTP you also want job-level deduplication via ShouldBeUnique:
class OrderConfirmation extends Mailable implements ShouldQueue, ShouldBeUnique {
public function uniqueId() {
return "order-confirm:{$this->order->id}";
}
public $uniqueFor = 3600;
}Step 6: Retry and Backoff
Default $tries = 1 means a single transient SMTP failure is permanent. Set $tries = 3 with backoff:
public $tries = 3;
public $backoff = [60, 300, 900]; // 1m, 5m, 15mAnd classify failures so 5xx are not retried unnecessarily:
public function failed(Throwable $exception): void {
if (str_contains($exception->getMessage(), "550")) {
// hard bounce - suppress recipient, do not retry
SuppressionList::add($this->user->email, "hard_bounce");
return;
}
Log::error("Email send failed permanently", [...]);
}Step 7: Failed-Job Visibility
Use Horizon (or at minimum a metric exporter) to surface failed jobs. Set an alert on failed-jobs-per-minute. A spike of failures is your earliest signal of a deliverability incident.
php artisan horizon:install
php artisan horizonStep 8: Throttling Per Domain
Gmail accepts up to ~5 connections at a time from a single IP. Outlook is stricter. Above 100k/month you will hit receiver-side rate limits. Throttle per recipient domain:
public function handle() {
Redis::throttle("email:" . $this->recipientDomain())
->allow(10)->every(1)
->then(function () {
Mail::send(...);
}, function () {
$this->release(2);
});
}Step 9: Tracking Headers
Always inject a stable Message-ID derived from your application. This is what your support team will quote when a customer says "I never got the email" three weeks later.
$message->getHeaders()->addIdHeader(
'Message-ID',
"order->id}.v1@example.com>"
); Step 10: Test the Failure Modes
- Provider returns 500: do retries happen on the correct backoff?
- Provider returns 550: is the recipient suppressed?
- Worker is killed mid-job: is the email re-sent?
- Redis is briefly unavailable: does the queue back up cleanly?
Chaos-test these in staging at least once a quarter.
The Operational Dashboard
At 100k/month you want at-a-glance:
- Sends per minute per Mailable class.
- Failure rate per Mailable.
- Average queue wait per Mailable.
- Suppression list growth rate.
- Reputation per sending subdomain (from Postmaster).
Closing
Laravel Mail at scale is not magic — it is disciplined queueing, deduplication and observability. Get those three right and you can ride past 1M/month without rewriting. Target SMTP's Laravel integration ships idempotency keys, Feedback-ID, MIME validation and per-receiver throttling out of the box. The Send-Time Firewall enforces the suppression list at send-time — so an accidental import that re-enables hard-bounced recipients is blocked before any of those Laravel jobs reach a remote MX.