Laravel: quando serve un Job in coda e quando basta defer()

Il problema: mandare un’email non dovrebbe rallentare la risposta
Un cliente compila un form, l’ordine va a buon fine, e a quel punto succedono altre due o
tre cose: un’email di conferma, una riga di log, magari un webhook verso un gestionale.
Nessuna di queste deve far aspettare l’utente che guarda la rotellina di caricamento.
Fino a Laravel 11 la risposta strutturata era una sola: un Job in coda. ProcessOrder::dispatch($order),
un driver (database o Redis), un worker che gira con queue:work, un supervisore che lo tiene
vivo (supervisord o systemd), magari Horizon per vederci qualcosa. Per un’email di conferma è
un’infrastruttura sproporzionata: se il worker si pianta, nessuno se ne accorge finché la coda
non si riempie e i clienti iniziano a chiedere “ma l’ordine è confermato?”.
La soluzione: defer()
Laravel 11 ha aggiunto l’helper defer(): esegue una closure dopo che la risposta HTTP è
già stata inviata al browser. Niente coda, niente tabella jobs, niente worker da tenere su.
use function Illuminate\Support\defer;
Route::post('/ordini', function (Request $request) {
$order = Order::create($request->validated());
defer(fn () => Mail::to($order->email)->send(new OrderConfirmed($order)));
return response()->json($order, 201);
});
Il cliente riceve la risposta subito; l’email parte un istante dopo, nello stesso processo PHP.
Come funziona sotto: niente magia, solo FastCGI
defer() incapsula la closure in un oggetto DeferredCallback e lo accoda in una
DeferredCallbackCollection tenuta nel container. A fine richiesta, il metodo terminate()
di un middleware (InvokeDeferredCallbacks) recupera tutti i callback accumulati e li esegue —
sfruttando il fatto che PHP-FPM tiene vivo il processo anche dopo che la risposta è stata
inviata al client. Non è un thread separato, è lo stesso processo che continua a girare per
un momento dopo aver chiuso la connessione.
Per default un deferred non gira se la risposta è un errore (status 4xx o 5xx): se
l’ordine fallisce la validazione, l’email di conferma non deve partire. Per forzarlo comunque:
defer(fn () => Log::info('richiesta arrivata'))->always();
Si può anche dare un nome al callback, utile se in certi casi va annullato prima che scatti:
defer(fn () => Notification::send($user, new WelcomeEmail), 'welcomeMail');
// più avanti nello stesso ciclo di richiesta, se serve:
defer()->forget('welcomeMail');
Quando invece serve ancora una coda vera
defer() non sostituisce le code, le affianca. Restano da mandare a un Job vero:
- Task lunghi: encoding di un video, importazione di un CSV da migliaia di righe. Un
deferred che gira per minuti tiene occupato un processo PHP-FPM che potrebbe servire
un’altra richiesta — con un pool limitato è un modo elegante per saturare il server. - Task che devono sopravvivere a un crash: se il processo PHP-FPM muore a metà del
deferred (riavvio del server, OOM killer), quel lavoro è perso per sempre. Un Job in coda
su database o Redis resta lì e riparte. - Retry e backoff: un Job fallito si può ritentare con
->retryAfter()o tentativi
progressivi. Un deferred fallisce e basta: non c’è dashboard, non c’è log dei tentativi
se non quello che scrivi a mano. - Lavoro non legato a una richiesta HTTP: task schedulati, comandi Artisan, elaborazioni
batch notturne.defer()esiste solo nel contesto di una risposta (HTTP o job), non ha senso
fuori da lì. - Scalare su più macchine: una coda su Redis permette a più worker, anche su server
diversi, di consumare lo stesso carico. Un deferred gira solo dov’è arrivata la richiesta.
Cosa evitare
Non usarlo per Swoole/Octane senza verificare. Swoole definisce una sua funzione globale
defer() con una firma diversa da quella di Laravel: se il progetto gira su Octane con
l’estensione Swoole, il conflitto di nomi è reale, non teorico. Vale la pena controllarlo
prima di introdurre l’helper in un progetto che usa già Octane.
Non usarlo per “e se fallisce nessuno se ne accorge”. Un deferred che silenziosamente
manda in errore un webhook verso un gestionale terzo è un problema che si scopre tardi.
Se il task è abbastanza importante da avere bisogno di un retry o di un avviso quando fallisce,
è un Job, non un deferred: la domanda giusta non è “quanto è veloce” ma “cosa succede se va male”.
Non impilarne troppi per abitudine. Tre o quattro deferred su una singola richiesta vanno
bene; usarlo come sostituto sistematico delle code, “tanto è più semplice”, sposta solo il
problema — e lo sposta in un posto dove non si vede finché non è già successo.
