Menu
Accedi Crea account
Guide

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.

08 Aug 2025 · 10 min di lettura · Target SMTP

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

QueueTipologiaPriorità HorizonWorker
mail-transactionalConferme ordine, reset password, OTP1 (alta)10
mail-notificationsAlert utente, summary giornaliero25
mail-marketingNewsletter, promo, drip campaign3 (bassa)3
mail-batchImportazioni massive una tantum42

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). Usa firstOrCreate o un idempotency_key per 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

  1. Inline send senza queue in controller: blocca la response HTTP per 500-2000ms
  2. Worker singolo: un blast satura il worker, transactional vanno in ritardo
  3. Cache view non attiva: ogni render Blade compila il template (10x più lento). php artisan view:cache in deploy
  4. Logging inline su DB: scrivere ogni invio in DB blocca il worker. Usa structured log file o async
  5. Memory leak su batch grandi: User::all() in batch carica tutto in RAM. Usa cursor() o chunk()
  6. 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 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.

Tag #laravel #php #queue #horizon
Condividi: X LinkedIn Email

Articoli correlati