Menu
Accedi Crea account
Guide

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.

08 Aug 2025 · 10 min read · Target SMTP

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,900

And 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, 15m

And 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 horizon

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

Tag #laravel #php #queue #scale

Related posts