Laravel Mail: best practice per scaling oltre 100k invii/mese
Laravel offre Mailable, queue, ThrottlesEmails e Horizon. Una guida completa per scalare gli invii email da migliaia a centinaia di migliaia/mese senza bruciare la reputation.
Laravel ha uno dei sistemi email più ergonomici di qualunque framework PHP: Mailable, queue native, ThrottlesEmails, Horizon per monitoring. Funziona benissimo nei primi mesi di vita di un progetto, ma quando il volume di invii supera i 100k al mese iniziano a emergere problemi di scaling: queue saturate, retry naive che peggiorano la reputation, mancata idempotency che genera doppi invii, template che bloccano i worker. In questo articolo vediamo come costruire un sistema email Laravel robusto per volume produzione: configurazione driver, queue dedicate, batch sending, throttling intelligente, monitoring con Horizon, e gestione webhook eventi.
Driver email: scegliere quello giusto
Laravel 11+ supporta nativamente diversi driver via config/mail.php:
return [
'default' => env('MAIL_MAILER', 'smtp'),
'mailers' => [
'smtp' => [
'transport' => 'smtp',
'host' => env('MAIL_HOST', 'smtp.targetsmtp.it'),
'port' => env('MAIL_PORT', 587),
'encryption' => env('MAIL_ENCRYPTION', 'tls'),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'timeout' => 10,
'local_domain' => env('MAIL_EHLO_DOMAIN', 'targetsmtp.it'),
],
'tsmtp_api' => [
'transport' => 'tsmtp',
'key' => env('TSMTP_API_KEY'),
],
'failover' => [
'transport' => 'failover',
'mailers' => ['tsmtp_api', 'smtp'],
],
],
];
Pattern raccomandato: configurazione failover con API REST primaria e SMTP fallback. Laravel switcha automaticamente al secondo se il primo solleva eccezione.
Mailable: separation of concerns
Una Mailable Laravel pulita ha sole responsabilità di assembly del messaggio, no business logic:
namespace AppMail;
use IlluminateBusQueueable;
use IlluminateContractsQueueShouldQueue;
use IlluminateMailMailable;
use IlluminateMailMailables{Content, Envelope, Headers};
class OrderConfirmationMail extends Mailable implements ShouldQueue
{
use Queueable;
public function __construct(public readonly int $orderId, public readonly string $customerName) {}
public function envelope(): Envelope
{
return new Envelope(
from: new IlluminateMailMailablesAddress('noreply@targetsmtp.it', 'Target SMTP'),
subject: "Conferma ordine #{$this->orderId}",
tags: ['order-confirmation', 'transactional'],
metadata: ['order_id' => (string) $this->orderId],
);
}
public function content(): Content
{
return new Content(view: 'emails.order-confirmation');
}
public function headers(): Headers
{
return new Headers(
messageId: "order-{$this->orderId}-confirm@targetsmtp.it",
text: [
'Feedback-ID' => "orders:transactional:targetsmtp:{$this->orderId}",
'X-Idempotency-Key' => "order-{$this->orderId}-confirm",
],
);
}
}
💡 Suggerimento: il Message-ID custom (RFC 5322 §3.6.4) ti permette di referenziare un messaggio specifico nei webhook eventi e nei log. Senza Message-ID custom, il valore generato è random e non collegabile al record nel tuo DB.
Queue: separare per priorità
Definire queue separate per tipologia evita che un blast di marketing blocchi conferme ordine critiche:
// config/queue.php
'connections' => [
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => 'default',
'retry_after' => 120,
'block_for' => 5,
],
],
// Dispatch
OrderConfirmationMail::dispatch($order->id, $order->customer_name)
->onQueue('mail-transactional');
NewsletterMail::dispatch($users)
->onQueue('mail-marketing')
->delay(now()->addMinutes(5));
Strategia di queue tipica
| Queue | Tipologia | Priorità Horizon | Worker |
|---|---|---|---|
| mail-transactional | Conferme ordine, reset password, OTP | 1 (alta) | 10 |
| mail-notifications | Alert utente, summary giornaliero | 2 | 5 |
| mail-marketing | Newsletter, promo, drip campaign | 3 (bassa) | 3 |
| mail-batch | Importazioni massive una tantum | 4 | 2 |
Throttling: il middleware nascosto
Senza throttling, una queue con 100.000 mailable può saturare in pochi minuti il rate limit del provider, generando 421 di risposta e mancate consegne. Laravel fornisce un middleware dedicato:
use IlluminateQueueMiddlewareThrottlesExceptions;
use IlluminateQueueMiddlewareWithoutOverlapping;
class NewsletterMail extends Mailable implements ShouldQueue
{
public $tries = 3;
public $backoff = [60, 300, 900]; // exponential
public function middleware(): array
{
return [
// Max 1000 messaggi al minuto per questa classe
(new ThrottlesExceptions(maxAttempts: 1000, decayMinutes: 1)),
// Niente overlap dello stesso recipient
(new WithoutOverlapping($this->recipientHash))->dontRelease(),
];
}
}
Il throttle ferma il worker se supera 1000 invii/minuto, ritarda i job successivi senza spreco di tentativi.
Batch sending
Per newsletter di centinaia di migliaia di destinatari, NON dispatchare un job per recipient: usa Laravel Bus Batch.
use IlluminateBusBatch;
use IlluminateSupportFacadesBus;
$users = User::query()
->where('newsletter_opt_in', true)
->where('last_engagement_at', '>=', now()->subMonths(6))
->cursor();
$jobs = collect();
foreach ($users->chunk(50) as $chunk) {
$jobs->push(new NewsletterChunkJob($chunk->pluck('id')->all()));
}
Bus::batch($jobs)
->name('Newsletter 2026-05')
->onQueue('mail-marketing')
->then(fn(Batch $b) => Log::info("Batch {$b->id} completed"))
->catch(fn(Batch $b, Throwable $e) => Log::error("Batch {$b->id} failed", ['e' => $e]))
->allowFailures()
->dispatch();
Chunk di 50 recipient per job: bilanciamento tra granularità (per resume in caso di failure parziale) e overhead di queue. Sotto 20 = troppi job small. Sopra 200 = retry su tutto il chunk per un errore singolo.
Horizon: monitoring e auto-balancing
Laravel Horizon è essenziale sopra volume 10k/giorno. Configurazione tipica:
// config/horizon.php
'environments' => [
'production' => [
'mail-transactional' => [
'connection' => 'redis',
'queue' => ['mail-transactional'],
'balance' => 'auto',
'maxProcesses' => 15,
'minProcesses' => 3,
'tries' => 3,
'timeout' => 30,
],
'mail-marketing' => [
'connection' => 'redis',
'queue' => ['mail-marketing', 'mail-batch'],
'balance' => 'simple',
'maxProcesses' => 5,
'tries' => 2,
'timeout' => 60,
],
],
],
balance: auto sposta worker tra queue in base al backlog. maxProcesses: 15 = scaling automatico fino a 15 worker, scende a 3 quando la queue è quasi vuota. La dashboard Horizon (/horizon) mostra throughput, retry rate e failed jobs in tempo reale.
Webhook eventi
Il provider invia webhook quando un messaggio è consegnato, bouncato, aperto, cliccato. Endpoint Laravel:
// routes/api.php
Route::post('/webhooks/email', [EmailWebhookController::class, 'handle'])
->middleware('tsmtp.webhook.signature');
// app/Http/Controllers/EmailWebhookController.php
public function handle(Request $request)
{
$event = $request->input('event');
$messageId = $request->input('message_id');
$idempotencyKey = $request->input('idempotency_key');
match($event) {
'delivered' => EmailDelivery::firstOrCreate(['message_id' => $messageId], [
'delivered_at' => $request->input('timestamp'),
'recipient' => $request->input('recipient'),
]),
'bounce' => HandleBounceJob::dispatch($messageId, $request->input('bounce')),
'complaint' => SuppressRecipientJob::dispatch($request->input('recipient'), 'complaint'),
'click' => RecordEngagementJob::dispatch($messageId, 'click'),
default => Log::warning("Unknown event: {$event}"),
};
return response()->noContent();
}
⚠️ Attenzione: i webhook arrivano in ordine non garantito e possono essere duplicati (provider retry). UsafirstOrCreateo unidempotency_keyper evitare doppia scrittura. Verifica SEMPRE la signature HMAC del webhook prima di processare, altrimenti chiunque può iniettare eventi fake.
Suppression list locale
Mantieni una tabella email_suppressions sincronizzata con il provider e controllata prima di ogni dispatch:
// app/Mail/CanCheckSuppression.php trait
public function shouldQueue(): bool
{
if (EmailSuppression::where('email', $this->recipient)->exists()) {
Log::info("Skipped suppressed: {$this->recipient}");
return false;
}
return true;
}
Performance: 6 errori comuni
- Inline send senza queue in controller: blocca la response HTTP per 500-2000ms
- Worker singolo: un blast satura il worker, transactional vanno in ritardo
- Cache view non attiva: ogni render Blade compila il template (10x più lento).
php artisan view:cachein deploy - Logging inline su DB: scrivere ogni invio in DB blocca il worker. Usa structured log file o async
- Memory leak su batch grandi:
User::all()in batch carica tutto in RAM. Usacursor()ochunk() - Mancata supervisione Horizon: Horizon richiede supervisord o systemd per restart automatico. Senza, un crash uccide tutti i worker
Test in development
MAIL_MAILER=log scrive le email in storage/logs/laravel.log invece di inviarle. MAIL_MAILER=array le tiene in memoria per Pest/PHPUnit. Per testing realistico, Mailtrap o un'istanza maildev locale catturano le mail senza spedirle davvero.
Riferimenti
- Laravel Mail docs
- Laravel Horizon docs
- Laravel Bus Batch
- Supervisord (raccomandato per Horizon)
Laravel scala bene a volumi elevati se rispetti il pattern "queue tutto, retry intelligente, throttle per classe, webhook per eventi". Il provider deve fare la sua parte assorbendo i picchi e fornendo idempotency-key. Target SMTP espone driver Laravel ufficiale (transport tsmtp) che firma automaticamente con Message-ID e Idempotency-Key, registra webhook tipizzati per ogni evento e fornisce Send-Time Firewall che blocca dispatch a recipient in suppression prima che il worker contatti SMTP.