Event-driven architecture con PHP: dall'evento al handler senza accoppiamento

Event-driven architecture con PHP: dall'evento al handler senza accoppiamento

Il 21 maggio 2025 mi ha chiamato il direttore IT di un'azienda bergamasca del settore produzione e distribuzione di componentistica elettrica industriale, fatturato annuo di circa 19 milioni di euro, 65 dipendenti di cui 12 sulla piattaforma IT interna e 53 operativi nei due stabilimenti produttivi. L'azienda gestiva i suoi ordini cliente e la produzione attraverso un gestionale Laravel 10 custom scritto internamente negli ultimi sette anni, progressivamente esteso per adattarsi a ogni nuovo processo di business introdotto dal management. Il problema non era di performance in senso stretto - il sistema girava ragionevolmente. Il problema era di modificabilità: ogni volta che si doveva introdurre una nuova regola di business, anche molto piccola ("aggiungere una notifica al responsabile qualità per ordini sopra 50.000 euro", "inviare una comunicazione al venditore quando un ordine viene annullato", "registrare automaticamente una riga di budget mensile quando un ordine arriva da un nuovo cliente"), la modifica richiedeva mediamente 4 giornate di lavoro dello sviluppatore senior più 2 giornate di test QA. Il motivo era sempre lo stesso: il controller OrderController::confirm() era diventato un file di 780 righe con 14 effetti collaterali diversi cablati in sequenza - invio di 4 email transazionali distinte, aggiornamento del magazzino su 3 moduli, registrazione in contabilità, notifiche al venditore e al responsabile produzione, aggiornamento di 3 dashboard statistici, creazione di un ticket nel sistema di tracking, sincronizzazione con il CRM esterno. Ogni volta che serviva toccare uno di questi, il rischio di rompere gli altri 13 era reale e il team interno aveva imparato a temere quel file.

La soluzione tecnicamente interessante è stata introdurre event-driven architecture in modo incrementale e non distruttivo, trasformando il controller monolitico in un emettitore di eventi di dominio a cui si agganciano handler indipendenti, ciascuno responsabile di un singolo effetto collaterale. In dodici giornate distribuite in cinque settimane, senza una singola giornata di fermo produzione e con deploy graduali sotto feature flag, abbiamo trasformato un controller di 780 righe con 14 side effect cablati in un controller di 40 righe che emette un singolo evento OrderConfirmed e undici handler separati (tre effetti collaterali minori sono stati nel frattempo considerati obsoleti e rimossi) ciascuno di 30-80 righe. Il tempo medio per introdurre una nuova regola di business sull'evento OrderConfirmed è sceso da 6 giornate complessive a circa mezza giornata - un miglioramento di 12x che ha liberato il team senior da un carico ricorrente che era diventato il collo di bottiglia del ciclo di sviluppo. Il costo consulenziale dell'intervento è stato 7.800 euro. Il valore stimato dal direttore IT sulla prima annualità è superiore a 90.000 euro in ore di sviluppo liberate, senza contare la riduzione dei bug di regressione introdotti involontariamente dai cambiamenti che nei dodici mesi precedenti erano stati 34 - scesi a 2 nei dodici mesi successivi all'intervento.

Questo articolo descrive il metodo operativo con cui introduco event-driven architecture in applicazioni PHP esistenti, basato sull'esperienza di circa 20 progetti simili negli ultimi sei anni. Il principio guida che applico è uno: event-driven architecture non è un upgrade architetturale da fare ovunque - è uno strumento specifico per disaccoppiare processi business che hanno effetti collaterali multipli e indipendenti. Applicato nel contesto giusto, è trasformativo. Applicato ovunque, aggiunge complessità operativa senza beneficio.

Perché event-driven architecture è la risposta corretta a un controller che è diventato incontrollabile?

Il pattern architetturale che genera sistematicamente controller di 500-1.000 righe è quello che in letteratura si chiama fat controller: il controller riceve la richiesta HTTP, esegue tutta la logica di business necessaria per soddisfarla, e orchestra tutti gli effetti collaterali in sequenza sincrona. Il pattern funziona perfettamente per i primi sei mesi di vita di un progetto, quando gli effetti collaterali sono 2-3 e relativamente semplici. Ma ogni nuova regola di business aggiunge un nuovo blocco di codice al controller, e dopo due o tre anni di sviluppo il controller diventa una struttura così complessa che nessuno osa toccarla senza un refactoring preliminare.

Il vero problema di un controller fat non è il numero di righe - è il grado di accoppiamento fra le logiche al suo interno. Nel caso del cliente bergamasco, prima dell'intervento, il confirm() conteneva codice che assomigliava a questo:

public function confirm(Request $request, Order $order): RedirectResponse
{
    DB::transaction(function () use ($order) {
        $order->update(['status' => 'confirmed', 'confirmed_at' => now()]);
        foreach ($order->items as $item) {
            $this->warehouseService->decrementStock($item->product_id, $item->qty);
        }
        Mail::to($order->customer->email)->send(new OrderConfirmationMail($order));
        Mail::to($order->salesman->email)->send(new SalesConfirmationMail($order));
        if ($order->total >= 50000) {
            Mail::to('[email protected]')->send(new QualityReviewMail($order));
        }
        $this->accountingService->recordOrder($order);
        $this->crmSync->sendOrderToExternalCRM($order);
        $this->statisticsUpdater->updateSalesDashboards($order);
        $this->ticketSystem->createProductionTicket($order);
    });
    return redirect()->route('orders.show', $order);
}

I problemi strutturali sono molteplici. Primo, tutti gli effetti collaterali sono sincroni - il tempo di risposta dell'utente che conferma l'ordine è la somma del tempo di tutti gli effetti. Secondo, se uno qualsiasi degli effetti fallisce con eccezione, l'intera transazione rollback e l'ordine non viene confermato nonostante forse tre effetti su quattordici siano andati a buon fine. Terzo, aggiungere un nuovo effetto significa aggiungere una nuova linea al controller e un nuovo servizio da iniettare, con impatto sul setup di test e sul contratto del costruttore. Quarto, testare un singolo effetto collaterale in isolamento è praticamente impossibile - devi mockarne tredici altri. Quinto, il team non si fida più a modificare il controller, quindi ogni nuova feature viene "infilata" dove possibile, generando ancora più accoppiamento.

La soluzione event-driven risolve tutti e cinque i problemi contemporaneamente, a patto di essere implementata con disciplina. Il pattern è questo. Il controller emette un singolo evento di dominio (OrderConfirmed) che rappresenta il fatto di business avvenuto, ignora completamente quali effetti collaterali devono essere innescati da quel fatto. Ognuno degli effetti collaterali è implementato come un handler separato - una classe dedicata che ascolta l'evento e gestisce in isolamento il suo specifico effetto. L'infrastruttura di event dispatcher (Laravel Events, Symfony EventDispatcher, o un message broker esterno come RabbitMQ o Redis Streams) si occupa di instradare l'evento a tutti gli handler registrati. Questo pattern è descritto in modo strutturato nella reference ufficiale di Martin Fowler sui pattern Event-Driven Architecture, e rappresenta una delle decomposizioni architetturali più potenti del design orientato ai servizi.

Se stai valutando un intervento di refactoring architetturale di questo tipo su un progetto Laravel o Symfony, nel mio profilo professionale trovi il dettaglio delle trasformazioni event-driven che ho guidato in codebase PHP di PMI italiane, sempre con approccio di introduzione incrementale e senza interruzione del ciclo di sviluppo produttivo del team.

Laravel Events vs Symfony EventDispatcher vs message broker esterno: quando scegliere cosa

Il PHP moderno offre tre livelli distinti di infrastruttura event-driven, ognuno con il suo caso d'uso preciso. Il primo livello è Laravel Events nativo del framework, basato su un pattern observer in-process, documentato nella reference ufficiale di Laravel sugli eventi e i listener. Gli eventi sono classi PHP semplici (class OrderConfirmed { public function __construct(public readonly Order $order) {} }), gli handler sono altre classi PHP registrate in EventServiceProvider, il dispatch è sincrono di default ma ogni handler può essere marcato queueable per esecuzione asincrona via Laravel Queues. Questa soluzione è quella che applico nel 70% dei casi nelle PMI italiane, perché è zero-infrastructure (non richiede message broker esterni) ed è perfettamente sufficiente per scale sotto i 1.000 eventi/minuto.

Il secondo livello è Symfony EventDispatcher, il componente standalone che può essere usato in qualunque applicazione PHP (incluso Laravel con qualche adattamento). È più potente e più esplicito del Laravel Events, con supporto nativo per event propagation stopping e per priorities fra handler multipli sullo stesso evento. Lo uso in progetti Symfony nativi e in progetti ibridi dove serve il controllo fine sulla sequenza di esecuzione degli handler.

Il terzo livello è l'event bus esterno via message broker dedicato - RabbitMQ, Kafka, Redis Streams, AWS SQS/SNS. Questo approccio è appropriato quando gli eventi devono attraversare confini di servizio (microservizi indipendenti, diversi processi fisici, integrazioni con sistemi esterni che consumano eventi), quando serve garanzia di persistenza dell'evento (non perso se il producer crasha prima dell'invio), o quando il volume supera i limiti in-process del singolo worker PHP. È lo scenario in cui stanno entrando i sistemi enterprise strutturati, ma per la stragrande maggioranza delle PMI è sovradimensionato rispetto al problema reale.

Sul cliente bergamasco, abbiamo scelto Laravel Events per tre motivi concreti. Primo, il sistema era monolitico e tutti gli handler vivevano nello stesso processo PHP - zero beneficio a introdurre un message broker. Secondo, il team interno conosceva Laravel bene, zero curva di apprendimento. Terzo, il volume eventi era di circa 200 eventi/minuto al picco - ampiamente gestibile da un event dispatcher in-process. La scelta di tenere l'infrastruttura semplice ha permesso di concentrare l'investimento architetturale sulla disciplina di decomposizione in handler, che è la parte che produce davvero il valore.

Il pattern in pratica: da controller di 780 righe a 40 righe con undici handler indipendenti

La trasformazione del controller del cliente bergamasco è avvenuta in cinque fasi incrementali, ognuna deployata in produzione separatamente e verificata con logging e monitoring prima di procedere alla successiva. La prima fase è stata l'introduzione del singolo evento OrderConfirmed emesso alla fine del controller originale, con tutti gli effetti collaterali ancora in sequenza sincrona nel controller. Questo sembrerebbe inutile - e lo è, dal punto di vista funzionale - ma serve come punto di ancoraggio: il controller ora comunica il fatto di business, senza ancora modificare come gli effetti sono gestiti. Il diff di questa fase è minimo, il rischio è zero, il sistema continua a funzionare identicamente.

// controllers/OrderController.php - fase 1
public function confirm(Request $request, Order $order): RedirectResponse
{
    DB::transaction(function () use ($order) {
        $order->update(['status' => 'confirmed', 'confirmed_at' => now()]);
        // [tutti i 14 effetti collaterali restano qui]
        event(new OrderConfirmed($order));
    });
    return redirect()->route('orders.show', $order);
}

La seconda fase è stata la migrazione di un singolo effetto collaterale per volta da "codice dentro il controller" a "handler separato registrato sull'evento OrderConfirmed". Per ogni migrazione: creazione della classe handler, test unitari dell'handler, registrazione nell'EventServiceProvider, rimozione del codice corrispondente dal controller, deploy in produzione, monitoraggio per 2-3 giorni prima della migrazione successiva. Il primo effetto migrato è stato l'invio email di conferma al cliente finale, perché era il più semplice e meno critico (in caso di bug, l'email non parte ma l'ordine è comunque confermato). Il pattern dell'handler è molto compatto:

namespace App\Listeners\Order;

use App\Events\OrderConfirmed;
use App\Mail\OrderConfirmationMail;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Mail;

final class SendCustomerConfirmationEmail implements ShouldQueue
{
    public string $queue = 'emails';

    public function handle(OrderConfirmed $event): void
    {
        Mail::to($event->order->customer->email)
            ->send(new OrderConfirmationMail($event->order));
    }
}

L'interfaccia ShouldQueue è il pezzo chiave: segna l'handler come queueable, quindi Laravel lo esegue in un worker asincrono invece che in sequenza sincrona nel ciclo di request. La coda dedicata emails è servita da worker dedicati, isolando le performance dell'invio email da tutti gli altri handler. Questa architettura di job asincroni beneficia degli stessi pattern di testing affidabile che ho descritto nel mio articolo sulla modernizzazione dei job in coda Laravel con Queue::fake e withFakeQueueInteractions su Laravel 12, che è il riferimento metodologico per testare handler event-driven in modo isolato.

La terza fase ha ripetuto la stessa procedura per ciascuno dei rimanenti 13 effetti collaterali. Uno alla volta, con verifica in produzione, senza mai introdurre più di un cambiamento per deploy. L'intero processo è durato cinque settimane complessive, con due deploy per settimana. In nessun momento l'applicazione ha avuto funzionalità degradate o bug introdotti. La quarta fase è stata la rimozione degli effetti collaterali obsoleti scoperti durante il refactoring: tre degli effetti originali (un sistema di notifica che andava a un indirizzo email non più utilizzato, un trigger verso un CRM precedentemente sostituito, un aggiornamento di statistiche mai visualizzate) sono stati eliminati in accordo con il management. Questa è una conseguenza tipica di un refactoring event-driven fatto bene: la luce accesa sui side effect mostra quali di loro non servono più.

La quinta e ultima fase è stata la documentazione architetturale del nuovo flusso: un diagramma chiaro che mostra come l'evento OrderConfirmed si ramifica verso gli undici handler residui, con tabella di responsabilità (chi fa cosa, in quale coda, con quale priority), e la definizione delle policy di failure - cosa succede se un handler fallisce (retry automatico tre volte, alert in Sentry, nessun rollback dell'ordine). Questa documentazione è diventata la base per introdurre nuove regole di business velocemente: qualunque nuova regola è "un nuovo handler che ascolta OrderConfirmed", senza toccare il controller né gli handler esistenti.

Pattern avanzati: idempotenza, replay e saga pattern per processi multi-step

Una volta consolidata l'architettura base event-driven con handler asincroni, emergono tre pattern avanzati che sono fondamentali per la robustezza del sistema in produzione e che applico sistematicamente nei progetti più complessi.

Il primo pattern è l'idempotenza degli handler. Gli handler asincroni queueable possono essere eseguiti più volte in caso di fallimento temporaneo e retry automatico - devono essere scritti in modo che l'esecuzione multipla dello stesso evento produca lo stesso risultato finale. Per l'handler di invio email, questo significa deduplicare via un event_id univoco. Per l'handler di decremento magazzino, significa controllare se il decremento è già stato applicato (tramite una tabella processed_events con chiave (event_id, handler_name) e transazione atomica). L'idempotenza non è una nice to have, è un requisito architetturale: senza di essa, i retry trasformano le anomalie di rete in bug applicativi silenziosi.

Il secondo pattern è l'event replay. Tenere una traccia completa di tutti gli eventi di dominio emessi dal sistema (via tabella domain_events o via un event store esterno) permette di riapplicare gli eventi su un sistema ricostruito da zero e riprodurre esattamente lo stato corrente. Nel caso del cliente bergamasco, questo pattern è stato usato concretamente quando è stato introdotto un nuovo handler UpdateMonthlyBudget due mesi dopo il go-live: invece di scrivere una script ad-hoc per popolare le view storiche, abbiamo replay-ato gli eventi OrderConfirmed degli ultimi 12 mesi, e il nuovo handler ha costruito il dato storico senza alcuna logica ad-hoc.

Il terzo pattern è il saga pattern per processi multi-step che coprono più bounded context. Un saga è una sequenza di eventi e handler correlati che implementano un processo di business lungo (esempio: "conferma ordine" → "prenotazione magazzino" → "richiesta pagamento" → "emissione fattura"), con gestione esplicita dei compensation events in caso di fallimento a uno step intermedio. Il saga pattern è potente ma complesso, lo applico solo quando il processo di business lo richiede davvero - nelle PMI italiane tipiche, raramente serve un saga formale, mentre una semplice sequenza di eventi con retry automatici è sufficiente.

Il risultato finale del cliente bergamasco dopo il go-live completo, misurato a dodici mesi dall'intervento, è stato il seguente. Tempo medio di introduzione di una nuova regola di business sull'evento OrderConfirmed sceso da 6 giornate a 0,5 giornate (miglioramento 12x). Numero di bug di regressione introdotti involontariamente su features esistenti sceso da 34 nei dodici mesi precedenti a 2 nei dodici mesi successivi. Tempo di risposta del controller confirm() sceso da 3,1 secondi medi a 180 millisecondi (gli handler asincroni sono stati sottratti dalla request sincrona). Tempo totale di completamento end-to-end degli effetti collaterali (tutti gli handler asincroni completati) sceso da 3,1 secondi a una media di 4,5 secondi - più lungo, ma invisibile all'utente perché asincrono. Costo consulenziale complessivo: 7.800 euro. Ore di sviluppo liberate misurate dal direttore IT a dodici mesi: oltre 180 giornate-uomo, valorizzate dal suo controllo di gestione in circa 90.000 euro. ROI di oltre 11 sulla prima annualità, più benefici qualitativi sulla serenità del team e sulla capacità organizzativa di rispondere velocemente a nuove richieste del management. Il team interno ha replicato successivamente lo stesso pattern su altri tre flussi di business critici (annullamento ordine, ricezione merce, emissione fattura), riducendo progressivamente il debito architetturale accumulato in sette anni di sviluppo.

Se guidi un team di sviluppo PHP che si trova a lavorare su controller o servizi che sono cresciuti organicamente oltre i 500 righe, con effetti collaterali multipli cablati in sequenza sincrona e paura diffusa di toccare il codice, l'introduzione disciplinata di event-driven architecture è quasi sempre l'investimento architetturale con il miglior ROI per team di 4-15 sviluppatori su codebase Laravel o Symfony. Il segreto del successo non è l'infrastruttura tecnologica scelta - è la disciplina di decomposizione graduale senza interruzione del ciclo produttivo. Se vuoi confrontarti su una valutazione tecnica del tuo caso specifico e ricevere un'analisi preliminare dei tuoi controller "caldi" con una roadmap di refactoring event-driven calibrata sulla capacità reale del tuo team, contattami per una consulenza iniziale: in una mezza giornata di analisi guidata identifichiamo i tre-cinque flussi di business più candidati a un intervento event-driven, produciamo insieme un piano di decomposizione incrementale, e stimiamo realisticamente il ROI atteso sulla prima annualità.

Ultima modifica: