Sistema di integrazione eventi con Kafka e PHP: architettura produttiva per PMI

Sistema di integrazione eventi con Kafka e PHP: architettura produttiva per PMI

A settembre 2025 mi ha contattato il direttore operativo di un'azienda del settore spedizioni e logistica con circa 80 dipendenti e un volume di 2.000-3.000 spedizioni al giorno distribuite su tre magazzini nel nord Italia. Il problema non era un singolo sistema che non funzionava - erano cinque sistemi legacy che dovevano scambiarsi informazioni in tempo reale e non ci riuscivano: il gestionale ordini (PHP 7.4 su Laravel 8), il WMS di magazzino (applicativo desktop Windows con API REST proprietaria), il sistema di tracking dei corrieri (integrazione SOAP con tre diversi vettori), il portale clienti (Vue.js + Laravel 10) e il modulo di fatturazione elettronica. Ogni volta che un pacco veniva spedito, cinque sistemi dovevano saperlo - e il meccanismo in produzione era un intreccio di chiamate REST sincrone, cronjob che pollavano database condivisi ogni 5 minuti, e un RabbitMQ installato tre anni prima che cadeva sotto carico durante i picchi del venerdì pomeriggio perdendo silenziosamente eventi di aggiornamento stato. Il risultato: clienti che vedevano lo stato "in preparazione" per pacchi già consegnati, magazzinieri che lavoravano su ordini già annullati, e il team amministrativo che passava due ore al giorno a riconciliare manualmente le incongruenze tra i sistemi.

Ho sostituito l'intero layer di comunicazione inter-sistema con Apache Kafka e un client PHP basato sull'estensione php-rdkafka. In tre settimane di lavoro, i cinque sistemi comunicavano attraverso un bus di eventi centralizzato con garanzia di delivery, replay degli eventi in caso di crash, e una latenza media sotto i 200 millisecondi dalla produzione dell'evento al consumo da parte di tutti i subscriber. Il volume: 50.000 eventi al giorno con perdita zero verificata su 90 giorni di produzione.

REST, RabbitMQ e Kafka a confronto: quando serve il message streaming?

La scelta tra questi tre approcci non è una questione di "quale sia migliore" in assoluto - è una questione di quale modello di comunicazione serve al tuo caso d'uso specifico. Ecco il confronto che presento ai clienti prima di prendere qualsiasi decisione architetturale:

CaratteristicaREST sincronoRabbitMQApache Kafka
ModelloRequest/response punto-puntoCoda messaggi con routingLog distribuito append-only
DurabilitàNessuna - se il consumer è offline, il messaggio si perdeSì, con conferma (ack)Sì, con retention configurabile (giorni/settimane)
ReplayImpossibileNon supportato nativamenteNativo - i consumer possono ripartire da qualsiasi offset
ThroughputLimitato dalla latenza di ogni chiamataDecine di migliaia msg/secMilioni di messaggi/sec con partitioning
Consumer multipliRichiede implementazione custom (webhook)Sì, con exchange fanoutNativo - ogni consumer group legge indipendentemente
Complessità operativaMinimaMedia (Erlang runtime, clustering)Alta (JVM, ZooKeeper/KRaft, dischi)
Caso d'uso idealeCRUD semplice, interazioni 1:1Task queue, job asincroni, workflowEvent sourcing, integrazione multi-sistema, audit trail

Nel caso dell'azienda di spedizioni, REST sincrono era il primo approccio tentato e aveva fallito per un motivo strutturale: quando il WMS di magazzino era offline per manutenzione (ogni martedì sera, 30 minuti), tutte le chiamate REST dal gestionale ordini andavano in timeout e gli eventi di aggiornamento stato venivano persi. RabbitMQ era stato introdotto come soluzione, ma aveva due problemi: primo, non supportava nativamente il replay - quando un consumer crashava e ripartiva, i messaggi già consumati erano persi e il sistema andava fuori sync; secondo, il cluster RabbitMQ di tre nodi richiedeva manutenzione Erlang che nessuno in azienda era in grado di gestire, e durante un upgrade andato male nel marzo 2025 il cluster era rimasto offline 4 ore con perdita di circa 800 eventi. Kafka risolveva entrambi i problemi: la retention del log permette il replay da qualsiasi punto temporale, e i consumer group ripartono automaticamente dall'ultimo offset committato in caso di restart. Questo tipo di decisione architetturale - scegliere lo strumento giusto in base ai requisiti reali, non alla popolarità del momento - è al centro del mio approccio alla consulenza infrastrutturale, dove l'obiettivo è sempre costruire sistemi che funzionano in produzione per anni senza sorprese.

Producer PHP per Kafka: dall'estensione rdkafka alla produzione

L'estensione php-rdkafka è il binding PHP per librdkafka, la libreria C ad alte prestazioni mantenuta da Confluent. È la scelta che uso in produzione per tutti i progetti Kafka+PHP: stabile, performante, e con supporto per PHP 8.1+ e tutte le funzionalità di Kafka incluse transazioni e idempotent producer. L'installazione su Debian 12 richiede librdkafka-dev e l'estensione PECL:

# Installazione php-rdkafka su Debian 12 con PHP 8.2
apt-get install -y librdkafka-dev
pecl install rdkafka
echo "extension=rdkafka.so" > /etc/php/8.2/mods-available/rdkafka.ini
phpenmod rdkafka
systemctl reload php8.2-fpm

Il producer è la parte più semplice dell'architettura. Nel gestionale ordini Laravel, ogni evento significativo (ordine creato, ordine confermato, spedizione partita, consegna confermata) viene pubblicato su un topic Kafka dedicato:

// Service class per la pubblicazione eventi su Kafka
// Registrato come singleton nel service container di Laravel
class KafkaEventPublisher
{
    private RdKafka\Producer $producer;

    public function __construct(
        private readonly LoggerInterface $logger,
    ) {
        $config = new RdKafka\Conf();
        $config->set('metadata.broker.list', config('kafka.brokers'));
        $config->set('enable.idempotence', 'true');
        $config->set('acks', 'all');
        $config->set('max.in.flight.requests.per.connection', '5');
        $config->set('retries', '3');

        // Callback per errori di delivery
        $config->setDrMsgCb(function (RdKafka\Producer $producer, RdKafka\Message $message) {
            if ($message->err !== RD_KAFKA_RESP_ERR_NO_ERROR) {
                $this->logger->error('Kafka delivery fallita', [
                    'topic' => $message->topic_name,
                    'error' => rd_kafka_err2str($message->err),
                    'payload' => $message->payload,
                ]);
            }
        });

        $this->producer = new RdKafka\Producer($config);
    }

    public function publish(string $topic, string $key, array $payload): void
    {
        $topicInstance = $this->producer->newTopic($topic);

        // La key determina la partizione: tutti gli eventi
        // dello stesso ordine finiscono nella stessa partizione
        // garantendo l'ordinamento per singolo ordine
        $topicInstance->produce(
            partition: RD_KAFKA_PARTITION_UA,
            msgflags: 0,
            payload: json_encode($payload, JSON_THROW_ON_ERROR),
            key: $key,
        );

        // Flush sincrono: attendi conferma dal broker
        $this->producer->flush(5000);
    }

    public function __destruct()
    {
        // Flush finale: non perdere messaggi in coda
        $this->producer->flush(10000);
    }
}

Tre configurazioni sono critiche per un producer in produzione. enable.idempotence=true garantisce che un messaggio non venga scritto due volte anche in caso di retry dopo un timeout di rete - Kafka assegna un sequence number per producer session e scarta i duplicati a livello di broker. acks=all richiede che tutti i replica del topic confermino la scrittura prima di considerare il messaggio committato - con un replication factor di 3 (lo standard), perdi il messaggio solo se tre dischi muoiono contemporaneamente. Il flush() nel distruttore è un dettaglio che la documentazione ufficiale di Apache Kafka enfatizza e che molti tutorial omettono: senza quel flush, i messaggi ancora nel buffer interno di librdkafka vengono persi quando il processo PHP termina.

Consumer con gestione degli offset: ripartire senza perdere eventi

Il consumer è la parte che distingue un'architettura Kafka robusta da un giocattolo. Il principio è: il consumer legge dal log, elabora l'evento, e solo dopo aver completato l'elaborazione committa l'offset. Se il consumer crasha prima del commit, al restart riparte dall'ultimo offset committato e rielabora gli stessi eventi - per questo ogni consumer deve essere idempotente, esattamente come il sync log che ho descritto nel mio articolo sull'integrazione con sistemi ERP legacy.

// Consumer Kafka come comando Laravel Artisan
// Eseguito come servizio systemd con restart automatico
class ConsumeShipmentEvents extends Command
{
    protected $signature = 'kafka:consume-shipments';

    public function handle(): int
    {
        $config = new RdKafka\Conf();
        $config->set('metadata.broker.list', config('kafka.brokers'));
        $config->set('group.id', 'portale-clienti');
        $config->set('auto.offset.reset', 'earliest');
        $config->set('enable.auto.commit', 'false');

        $consumer = new RdKafka\KafkaConsumer($config);
        $consumer->subscribe(['spedizioni.stato']);

        while (true) {
            $message = $consumer->consume(1000);

            if ($message->err === RD_KAFKA_RESP_ERR_NO_ERROR) {
                try {
                    $evento = json_decode($message->payload, true);

                    // Elaborazione idempotente: usa l'ID come chiave
                    // Se l'evento è già stato processato, skip
                    $this->processaEvento($evento);

                    // Commit DOPO l'elaborazione riuscita
                    $consumer->commit($message);

                } catch (\Throwable $e) {
                    Log::error('Errore elaborazione evento Kafka', [
                        'topic' => $message->topic_name,
                        'offset' => $message->offset,
                        'error' => $e->getMessage(),
                    ]);
                    // NON committare: l'evento verrà rielaborato
                    // al prossimo ciclo dopo il backoff
                    sleep(5);
                }
            }
        }

        return self::SUCCESS;
    }
}

Il parametro enable.auto.commit=false è fondamentale: con l'auto-commit abilitato (il default), Kafka committa l'offset periodicamente a prescindere dal fatto che l'evento sia stato elaborato con successo - il che significa che un crash durante l'elaborazione causa la perdita dell'evento. Con il commit manuale, hai il controllo completo: committa solo quando sei certo che l'evento è stato processato. Il trade-off è che in caso di crash potresti rielaborare un evento già processato - ma questo è gestibile con l'idempotenza nel consumer (verifica se l'evento è già stato applicato prima di eseguirlo).

Topic design: come organizzare gli eventi per 50.000 messaggi al giorno

La struttura dei topic è una decisione architetturale che prendi una volta e con cui convivi per anni. Nel progetto dell'azienda di spedizioni, ho usato un topic per dominio di business:

  • ordini.lifecycle - eventi di creazione, conferma, modifica, annullamento ordini
  • spedizioni.stato - eventi di cambio stato spedizione (preparazione, partito, in transito, consegnato)
  • magazzino.movimenti - eventi di carico, scarico, rettifica giacenza
  • fatturazione.documenti - eventi di generazione fattura, nota di credito, invio SDI

Ogni topic ha 6 partizioni - un numero che bilancia il parallelismo dei consumer con l'overhead di gestione. La key di partizionamento è l'ID dell'ordine o della spedizione: tutti gli eventi relativi allo stesso ordine finiscono nella stessa partizione, garantendo che un singolo consumer li elabori in ordine cronologico. Senza questa garanzia di ordinamento, un consumer potrebbe ricevere l'evento "consegnato" prima dell'evento "partito" e aggiornare lo stato in modo incoerente.

La retention è configurata a 7 giorni per i topic ad alto volume (spedizioni.stato, 30.000+ eventi/giorno) e a 30 giorni per quelli a basso volume (fatturazione.documenti, poche centinaia al giorno). La retention non è solo una questione di spazio disco - è la finestra temporale entro cui puoi fare replay di eventi in caso di bug nel consumer. Quando un mese dopo il go-live abbiamo scoperto che il consumer del portale clienti aveva un bug nella gestione degli stati di reso, abbiamo corretto il bug, resettato l'offset del consumer group a 5 giorni prima, e rielaborato 150.000 eventi in 12 minuti - tutti gli stati dei clienti sono tornati corretti senza nessun intervento manuale. Con REST o RabbitMQ, quel bug avrebbe richiesto un'estrazione manuale dal database del gestionale ordini e una riconciliazione riga per riga.

Deployment su VPS Linux: Kafka senza Kubernetes

Kafka ha la reputazione di essere un sistema complesso da gestire, e lo è - in un cluster multi-nodo distribuito su più data center. Ma per una PMI con 50.000 eventi al giorno, un singolo broker Kafka su un VPS dedicato Hetzner (AX52, 8 core, 64 GB RAM, NVMe) è più che sufficiente, e la complessità operativa è paragonabile a quella di un MySQL ben configurato.

L'installazione su Debian 12 con Kafka 3.7+ (che usa KRaft invece di ZooKeeper, eliminando una dipendenza che era la fonte principale di complessità operativa) si riduce a: installare il JDK 17, scaricare il binario Kafka, generare un cluster ID, formattare lo storage KRaft e creare un servizio systemd. Il monitoring lo faccio con il JMX exporter per Prometheus e Grafana, lo stesso stack che uso per il monitoring di MySQL e PHP-FPM sugli altri server dei clienti.

Il punto critico del deployment non è Kafka in sé - è il disco. Kafka è un sistema I/O-bound: scrive e legge sequenzialmente dal disco per ogni messaggio. Su un NVMe, 50.000 messaggi al giorno sono un carico trascurabile (meno di 1 MB/s di throughput sostenuto). Ma se installi Kafka su un VPS con disco virtio standard, le latenze di scrittura possono salire a decine di millisecondi per messaggio, e sotto carico il broker diventa il collo di bottiglia dell'intera architettura. La regola: Kafka vuole NVMe, o quantomeno SSD dedicati con throughput sequenziale garantito.

Il consumer PHP gira come servizio systemd con Restart=always e RestartSec=5: se il processo crasha, systemd lo riavvia in 5 secondi e il consumer riprende dall'ultimo offset committato. Non serve Supervisor, non serve Docker, non serve Kubernetes. Un servizio systemd su un VPS Debian è il deployment più semplice e più affidabile per un consumer Kafka in una PMI - e lo dico dopo aver gestito anche architetture container-based dove la complessità aggiuntiva di Docker e dell'orchestrazione non era giustificata dal volume di traffico.

L'architettura Kafka+PHP che ho implementato per l'azienda di spedizioni ha azzerato le incongruenze tra i cinque sistemi nel giro di una settimana dal go-live. I clienti del portale vedono lo stato aggiornato della spedizione entro 200 millisecondi dall'evento reale, i magazzinieri lavorano su dati sincronizzati in tempo reale, e il team amministrativo ha recuperato le due ore al giorno che spendeva in riconciliazione manuale - circa 500 ore l'anno di lavoro umano che ora vengono gestite da un broker Kafka e quattro consumer PHP. Se gestisci un'infrastruttura con più sistemi che devono scambiarsi dati e il tuo attuale meccanismo di sincronizzazione (REST polling, cronjob su database condivisi, o code di messaggi che perdono eventi) mostra segni di fragilità, contattami per una valutazione architetturale: in una giornata di lavoro mappiamo i flussi di dati tra i tuoi sistemi, identifichiamo i punti di fragilità e progettiamo l'architettura event-driven che elimina le riconciliazioni manuali e le perdite di dati.

Ultima modifica: