Job asincroni con le queue di Laravel: il caso della fatturazione elettronica

Job asincroni con le queue di Laravel: il caso della fatturazione elettronica

La fatturazione elettronica è il caso da manuale per spiegare perché le queue esistono. Quando un gestionale emette una fattura verso il Sistema di Interscambio dell'Agenzia delle Entrate, non sta facendo una scrittura locale veloce: sta dialogando con un servizio esterno che può metterci secondi a rispondere, che restituisce gli esiti (la ricevuta di consegna, o quella di scarto) in modo asincrono e con tempi che vanno dai minuti ai giorni, e che non perdona né i timeout né i duplicati. Provare a gestire tutto questo dentro la richiesta HTTP dell'utente che clicca "emetti fattura" è la ricetta per un timeout in faccia al cliente e, peggio, per uno stato incoerente: la richiesta scade, ma non sai se la fattura è partita o no. In un gestionale reale per una PMI questo è esattamente il punto dove l'elaborazione sincrona crolla, ed è dove le queue di Laravel 12 trasformano un processo fragile in uno robusto. Vediamo come strutturare i job asincroni per un flusso che non può perdere dati: il job che invia, il retry con backoff, l'idempotenza che impedisce i doppi invii, la gestione dei fallimenti e il monitoraggio dello stato.

Perché la fatturazione elettronica non si fa nella richiesta HTTP

La regola che applico è semplice: se un'operazione chiama un servizio esterno, può essere lenta o può fallire in modo transitorio, allora non vive nel ciclo richiesta-risposta dell'utente, vive in una coda. La fatturazione elettronica le spunta tutte e tre. L'invio al SDI è una chiamata di rete a un sistema che non controlli; la sua latenza è imprevedibile; e l'esito definitivo non arriva nemmeno nella stessa interazione, perché il SDI accetta il file e poi, più tardi, comunica se è stato consegnato al destinatario o scartato per un errore formale.

Una coda non rende l'applicazione più veloce per magia: sposta il lavoro lento e fallibile fuori dalla richiesta dell'utente, dove può fallire e ritentare senza che nessuno se ne accorga. È un cambio di architettura, non un'ottimizzazione.

Mettere questo in coda cambia la natura del problema. L'utente clicca, la richiesta accoda un job e torna subito con un "fattura in elaborazione", mentre il lavoro pesante avviene in background su un worker. La richiesta HTTP resta veloce e non dipende più dalla salute del SDI in quel preciso istante. È lo stesso disaccoppiamento che applico quando un'applicazione deve parlare con API esterne tramite l'HTTP client di Laravel: l'integrazione con un terzo non deve mai poter bloccare l'esperienza dell'utente.

Il job che invia: ShouldQueue e un timeout sensato

Un job di Laravel che implementa ShouldQueue viene serializzato e messo in coda al dispatch, e un worker lo raccoglie e lo esegue fuori dal ciclo della richiesta (i meccanismi sono tutti nella documentazione ufficiale delle queue di Laravel). La struttura di base è questa:

namespace App\Jobs;

use App\Models\Invoice;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

class SendInvoiceToSdi implements ShouldQueue
{
    use Queueable;

    public int $tries = 5;
    public int $timeout = 120; // l'invio al SDI non deve durare in eterno

    public function __construct(public int $invoiceId) {}

    public function handle(SdiClient $sdi): void
    {
        $invoice = Invoice::findOrFail($this->invoiceId);
        // l'idempotenza vive qui, vedi sotto
        $sdi->send($invoice->toFatturaPaXml());
    }
}

Da notare un dettaglio che evito sempre: nel costruttore passo l'id della fattura, non l'oggetto Eloquent intero. Il job viene serializzato e finisce nella coda; serializzare un modello con tutte le sue relazioni è fragile e fa lavorare il worker su dati potenzialmente stantii. Passare l'id e ricaricare il modello fresco dentro handle() è il pattern corretto. Il timeout impedisce che un worker resti appeso indefinitamente su una chiamata al SDI che non risponde.

Se stai progettando l'architettura asincrona di un gestionale e vuoi impostarla perché regga i picchi senza perdere lavoro, nel mio profilo professionale trovi l'esperienza concreta su piattaforme Laravel ad alto traffico, dalla coda al monitoraggio.

Retry con backoff: il SDI è esterno e può non rispondere

Un servizio esterno fallisce, è una certezza statistica, non un'ipotesi. La domanda giusta non è "se", è "cosa fa il mio sistema quando succede". Laravel gestisce i tentativi con la proprietà $tries e, soprattutto, con un backoff progressivo che dilaziona i ritentativi invece di martellare un servizio già in difficoltà.

public int $tries = 5;

/**
 * Attesa crescente tra i tentativi: 10s, 30s, 60s, 120s.
 * Non martellare il SDI quando è lento o sotto carico.
 */
public function backoff(): array
{
    return [10, 30, 60, 120];
}

// fermarsi dopo un certo tempo, a prescindere dai tentativi residui
public function retryUntil(): \DateTime
{
    return now()->addHours(6);
}

Il backoff a intervalli crescenti è la differenza tra un retry educato e un denial of service involontario contro il servizio a cui ti appoggi. Aggiungo quasi sempre retryUntil, che mette un tetto temporale: oltre una certa finestra, insistere non ha senso e il job va trattato come fallito. Questa stessa disciplina di retry e timeout su chiamate asincrone verso servizi esterni l'ho descritta parlando di Laravel Horizon per orchestrare chiamate asincrone con cost tracking, perché il pattern è identico, che il servizio esterno sia il SDI o un'API di terze parti.

L'idempotenza: il vincolo che impedisce i doppi invii

Qui sta il cuore ingegneristico del problema, ed è dove le implementazioni ingenue falliscono. Se un job riprova, devi avere la garanzia assoluta che non reinvii una fattura già inviata. Un doppio invio al SDI non è un fastidio, è un documento fiscale duplicato, con conseguenze contabili reali. L'idempotenza non è un optional, è il requisito che rende sicuro il retry.

La soluzione che uso è una macchina a stati sulla fattura, con il cambio di stato dentro una transazione, così che un secondo tentativo veda lo stato aggiornato e non reinvii:

public function handle(SdiClient $sdi): void
{
    $invoice = Invoice::findOrFail($this->invoiceId);

    // guardia di idempotenza: se è già partita, non rifare nulla
    if ($invoice->status !== InvoiceStatus::Pending) {
        return;
    }

    DB::transaction(function () use ($sdi, $invoice) {
        $invoice->update(['status' => InvoiceStatus::Sending]);
        $receiptId = $sdi->send($invoice->toFatturaPaXml());
        $invoice->update([
            'status' => InvoiceStatus::Sent,
            'sdi_receipt_id' => $receiptId,
        ]);
    });
}

A livello di dispatch, Laravel offre anche ShouldBeUnique, che impedisce di accodare due job per la stessa fattura contemporaneamente: utile come prima barriera, ma non sostituisce la guardia di stato dentro handle(), perché i due meccanismi proteggono da problemi diversi (la duplicazione in coda contro la duplicazione all'esecuzione). La regola che ripeto è che l'idempotenza si progetta sul dato, non si spera sul comportamento della coda.

Gestire i fallimenti senza perdere dati

Non tutti i fallimenti sono uguali, e confonderli è l'errore tipico. Un SDI che non risponde è un fallimento transitorio: si ritenta. Una fattura scartata per un errore formale è un fallimento permanente: ritentare non servirà mai, serve l'intervento di una persona che corregga il documento. Il sistema deve distinguere i due casi.

Quando un job esaurisce i tentativi, Laravel lo registra nella tabella failed_jobs e invoca il metodo failed(), il punto giusto per portare la fattura in uno stato di errore visibile e per avvisare qualcuno:

public function failed(\Throwable $e): void
{
    Invoice::where('id', $this->invoiceId)
        ->where('status', InvoiceStatus::Sending)
        ->update(['status' => InvoiceStatus::Failed]);

    // alerting: un fallimento permanente di una fattura non deve restare silenzioso
    Log::critical('Invio fattura fallito', ['invoice' => $this->invoiceId, 'error' => $e->getMessage()]);
}

Il principio non negoziabile è che un dato fiscale non sparisce mai in silenzio. Una fattura che fallisce deve finire in uno stato esplicito, recuperabile e monitorato, non evaporare in un log che nessuno legge. La tabella failed_jobs è la rete di sicurezza che ti permette di reispezionare e, dove ha senso, rilanciare il lavoro perso.

Su questo costruisco sempre una procedura di recupero esplicita, perché la tabella failed_jobs da sola è un cimitero se nessuno la guarda. Concretamente: un controllo periodico che segnala le fatture rimaste in stato di errore oltre una soglia, la possibilità di rilanciare un singolo job fallito con queue:retry dopo aver corretto la causa, e una distinzione netta tra ciò che si può rilanciare così com'è (un SDI tornato disponibile) e ciò che richiede prima una correzione del documento (uno scarto formale). Il punto è che il fallimento non è la fine del processo, è semplicemente uno stato del processo, con una sua via d'uscita documentata e presidiata da una persona competente quando serve giudizio.

Il worker deve girare davvero: supervisor, restart e deploy

C'è un dettaglio operativo che fa la differenza tra una coda che funziona in sviluppo e una che regge in produzione, ed è banale solo finché non ti morde: il worker è un processo a vita lunga che deve restare acceso. Lanciare php artisan queue:work a mano non è una soluzione di produzione, perché alla prima disconnessione o al primo riavvio del server la coda si ferma e le fatture restano accodate senza che nessuno le elabori. Il worker va gestito da un process manager come Supervisor o un'unit di systemd, configurato per riavviarlo automaticamente se muore.

Il secondo punto è il deploy. Un worker carica il codice in memoria all'avvio e non lo ricarica da solo: se distribuisci una nuova versione del job senza riavviare i worker, continuano a eseguire il codice vecchio. Per questo nel flusso di deploy ci deve essere sempre un php artisan queue:restart, che segnala ai worker di terminare in modo pulito al termine del job corrente e ripartire con il codice aggiornato. Dimenticarlo è una delle cause più sottili di bug "fantasma" in cui il comportamento in produzione non corrisponde al codice che hai distribuito. Quando la coda gira su Redis, Horizon aggiunge a tutto questo una dashboard e un controllo dei worker che rendono la gestione molto più solida, ma il principio resta: una coda è affidabile quanto il processo che la consuma, e quel processo va sorvegliato come qualunque altro servizio critico.

Quando un processo va messo in coda, e quando no?

La regola pratica che do è una griglia a tre domande: l'operazione chiama un servizio esterno o fa lavoro pesante? Può fallire in modo transitorio e beneficiare di un retry? Bloccarla degraderebbe l'esperienza dell'utente? Se la risposta a una qualsiasi è sì, va in coda. La fatturazione elettronica risponde sì a tutte e tre. Al contrario, una validazione veloce in memoria o una scrittura locale dentro la stessa transazione non vanno in coda: accodarle aggiunge complessità e latenza senza alcun beneficio. Mettere tutto in coda per moda è un anti-pattern tanto quanto non usarla mai.

Il monitoraggio dello stato: la coda non è il dominio

Un'ultima distinzione che fa la differenza tra un sistema che funziona in demo e uno che regge in produzione: lo stato del job non è lo stato di business. Il worker può aver completato il job con successo (la fattura è stata accettata dal SDI), ma per l'utente la fattura non è ancora "consegnata" finché non arriva la ricevuta asincrona. Per questo tengo sempre separati i due piani: lo stato della coda lo osservo con strumenti come Horizon, mentre lo stato della fattura (in attesa, inviata, consegnata, scartata) vive nel dominio, sul modello, ed è quello che mostro all'utente. Per gestire l'esito asincrono del SDI uso tipicamente un canale separato, un job schedulato che interroga gli esiti o un endpoint che riceve le notifiche, che aggiorna lo stato di dominio quando la ricevuta arriva, sfruttando anche le capacità di Redis oltre il caching, su code e pub/sub per tenere reattivo il sistema.

Messo tutto insieme, il quadro racconta una verità che vale ben oltre la fatturazione elettronica: le queue non servono a "rendere l'app più veloce", servono a rendere affidabile ciò che dipende da fattori che non controlli. Un processo che parla con un sistema esterno, che non può perdere dati e non può duplicarli, chiede quattro cose precise, e Laravel le offre tutte: l'esecuzione fuori dalla richiesta, il retry con backoff per i fallimenti transitori, l'idempotenza progettata sul dato per i ritentativi sicuri, e una separazione netta tra lo stato tecnico della coda e lo stato di business che l'utente vede. La fatturazione elettronica è solo l'esempio più nitido, perché sbagliarla ha conseguenze fiscali; ma lo stesso schema regge i pagamenti, le integrazioni con i corrieri, l'invio massivo di email e qualunque dialogo con un terzo. Se stai costruendo o rivedendo un gestionale dove un'integrazione esterna critica gira ancora dentro la richiesta HTTP, e ogni tanto va in timeout o genera duplicati, contattami per una consulenza diretta: di solito spostare quel processo in una coda ben progettata risolve insieme la lentezza e l'incoerenza dei dati.

Ultima modifica: