CQRS in PHP: separare letture e scritture per applicazioni Laravel ad alto carico

CQRS in PHP: separare letture e scritture per applicazioni Laravel ad alto carico

Ad agosto 2025 mi ha chiamato il CTO di una piattaforma SaaS italiana del settore logistica merci - 90 dipendenti, fatturato annuo intorno ai 14 milioni di euro, circa 3.200 aziende clienti che usano il sistema per gestire spedizioni, calcolo tariffe e fatturazione elettronica. Il problema concreto che gli era emerso era un conflitto strutturale nel database: l'applicazione serviva contemporaneamente due workload molto diversi fra loro - operazioni transazionali (OLTP) come la creazione di spedizioni e la scrittura di DDT che devono essere rapide e consistenti, e operazioni analitiche (OLAP) come la reportistica sulle marginalità per cliente, le dashboard di KPI operativi, gli export Excel dei movimenti mensili - che il singolo database MySQL 8.0 faticava a gestire entrambi sotto carico. Il pattern si manifestava così: quando un cliente enterprise alle 10 del mattino lanciava il report trimestrale dei propri movimenti (una query che toccava 8 tabelle joined con aggregazioni su 1,5 milioni di righe), la query girava per 20-30 secondi saturando il disco NVMe del database server, e nel frattempo tutti gli utenti attivi vedevano le loro operazioni transazionali rallentare del 40-60%. Il team aveva provato read replica e caching aggressivo senza risolvere - perché il problema architetturale era più profondo.

In dieci settimane ho riprogettato la parte analitica del sistema con un'implementazione di CQRS (Command Query Responsibility Segregation) pragmatica per Laravel. Non CQRS puristico con event sourcing (over-engineering per questo caso), ma CQRS "light": separazione di modelli Eloquent per le scritture (normalizzati, transazionali, consistenti) da modelli per le letture analitiche (denormalizzati, ottimizzati per query, aggiornati asincronamente via job). Al termine del progetto: le query analitiche che prima impiegavano 20-30 secondi girano in 120-400 ms sui read model denormalizzati, le operazioni transazionali non sono più rallentate dalle query di reporting, e il database server lavora al 35% della capacità invece che al 85% di prima. Questo articolo descrive il pattern operativo, le scelte pragmatiche che ho fatto rispetto al CQRS purist, e soprattutto quando vale la complessità aggiuntiva versus quando è over-engineering.

CQRS spiegato senza parolerini: separare read e write come pattern architetturale

Il pattern CQRS, introdotto formalmente da Greg Young nella sua guida canonica del 2010 disponibile integralmente nel libro "CQRS Documents by Greg Young" distribuito come PDF pubblico, parte da un'osservazione semplice: in molti sistemi di business le operazioni di scrittura (creare una spedizione, modificare un cliente) hanno requisiti completamente diversi da quelle di lettura (visualizzare un report, elencare le spedizioni degli ultimi 30 giorni). Le scritture hanno bisogno di consistency, validation, regole di business complesse. Le letture hanno bisogno di velocità, denormalizzazione, aggregation efficiente.

Il pattern propone di modellare queste due categorie in modo separato, con modelli dati diversi, strutture diverse, talvolta database diversi. I command (istruzioni di scrittura) modificano uno stato normalizzato che rispetta tutte le regole di business. I query (istruzioni di lettura) leggono uno stato alternativo, eventualmente denormalizzato, eventualmente pre-aggregato, ottimizzato per le specifiche interrogazioni richieste dall'applicazione. I due stati sono mantenuti in sync attraverso eventi emessi dalle scritture e consumati dai read model.

Il valore dell'approccio emerge in scenari specifici, non in ogni applicazione. Se la tua applicazione è CRUD standard con poche query e workload bilanciato, CQRS è over-engineering. Se invece hai un'applicazione dove le letture analitiche iniziano a dominare, a conflittare strutturalmente con le scritture, e a creare problemi di performance che non si risolvono con indici e caching, CQRS diventa la risposta architetturale corretta.

I tre livelli di CQRS: da "light" a "pure CQRS + event sourcing"

Una delle ragioni per cui CQRS viene mal compreso è che l'articolo originale di Greg Young e molti articoli successivi descrivono la forma più pura del pattern - con event sourcing, event store dedicato, read model materializzati da eventi. Questa forma pura è potente ma è molto complessa da implementare e ha senso solo in scenari business-critical dove il valore del tracciamento completo degli eventi giustifica la complessità. Per la maggior parte delle PMI italiane, l'implementazione pragmatica è più leggera.

Nei miei progetti distinguo tre livelli di CQRS.

CQRS Light - separazione di modelli di lettura e scrittura senza event sourcing. I modelli Eloquent di scrittura mantengono lo stato normalizzato. I modelli di lettura (che chiamo "read model" o "projections") sono tabelle denormalizzate aggiornate asincronamente tramite Laravel Job triggered da Eloquent events. Nessun event store, nessuna ricostruzione di stato da eventi. Il pattern è descritto in dettaglio in questo articolo e copre il 70% dei casi pratici.

CQRS Medium - separazione con message bus esplicito. I command sono data class che passano attraverso un Command Bus, elaborati da Command Handler dedicati. Le query passano attraverso un Query Bus, elaborate da Query Handler. Il codice dei controller diventa molto sottile - riceve la request, costruisce command/query, lo passa al bus. Questo livello aggiunge struttura e testabilità ma mantiene la persistenza standard.

CQRS Pure + Event Sourcing. I command producono eventi nell'event store, lo stato corrente è ricostruito dagli eventi. I read model sono proiezioni dell'event stream. Complessità massima, valore massimo per sistemi dove il valore dell'audit trail completo e della capacità di ricostruire stati passati giustifica il costo. Raramente applicabile a PMI italiane - tipicamente rilevante per fintech, insurance, sistemi critici con compliance normativa stringente.

Sul cliente logistica ho applicato il livello Light, con pattern di CQRS Medium introdotti selettivamente solo sui moduli più complessi. Il trade-off complessità/beneficio è stato ottimale per il loro contesto.

L'implementazione: write model normalizzato, read model denormalizzato

Il cuore dell'implementazione è la convivenza di due strutture di database parallele. Partiamo dal write model, che rimane il modello Eloquent "tradizionale". Schema semplificato per il caso spedizioni logistica:

<?php
// app/Models/Spedizione.php - WRITE MODEL (normalizzato)
class Spedizione extends Model
{
    protected $fillable = [
        'mittente_id', 'destinatario_id', 'data_ritiro',
        'peso_kg', 'volume_cm3', 'tipo_servizio_id',
    ];

    protected $casts = [
        'data_ritiro' => 'datetime',
        'peso_kg' => 'decimal:2',
    ];

    public function mittente() {
        return $this->belongsTo(Cliente::class, 'mittente_id');
    }

    public function destinatario() {
        return $this->belongsTo(Cliente::class, 'destinatario_id');
    }

    public function eventi() {
        return $this->hasMany(EventoSpedizione::class);
    }

    // ... altri metodi di business
}

Questo modello è quello che viene scritto nei controller di creazione/modifica spedizione. Ha foreign key verso clienti, relazioni con eventi_spedizione, e logica di business per transizioni di stato (ritirata → in_consegna → consegnata). È ottimizzato per coerenza, non per velocità di lettura analitica.

Il read model per le analitiche è una tabella completamente separata:

-- migrations/create_spedizioni_analytics.sql
CREATE TABLE spedizioni_analytics (
    spedizione_id BIGINT PRIMARY KEY,
    cliente_mittente_id BIGINT NOT NULL,
    cliente_mittente_ragione_sociale VARCHAR(200) NOT NULL,
    cliente_mittente_categoria VARCHAR(50),
    cliente_destinatario_id BIGINT,
    cliente_destinatario_ragione_sociale VARCHAR(200),
    data_ritiro DATE NOT NULL,
    data_consegna DATE,
    mese_ritiro INT NOT NULL, -- YYYYMM per aggregation veloce
    anno_ritiro INT NOT NULL,
    peso_kg DECIMAL(10,2),
    volume_cm3 DECIMAL(12,2),
    tipo_servizio VARCHAR(50),
    tariffa_finale DECIMAL(10,2),
    margine_calcolato DECIMAL(10,2),
    stato VARCHAR(30) NOT NULL,
    giorni_consegna INT, -- calcolato
    is_consegnata BOOLEAN NOT NULL DEFAULT 0,
    INDEX idx_mittente_mese (cliente_mittente_id, mese_ritiro),
    INDEX idx_mese_servizio (mese_ritiro, tipo_servizio),
    INDEX idx_anno (anno_ritiro)
);

Questa tabella include tutti i campi necessari per le query analitiche senza JOIN - la ragione sociale del cliente è duplicata direttamente, il mese di ritiro è pre-calcolato come integer, il margine è pre-computato, il flag is_consegnata è un boolean denormalizzato. Una query di "fatturato trimestrale per categoria cliente" diventa una singola query su questa tabella:

SELECT
    cliente_mittente_categoria,
    COUNT(*) as n_spedizioni,
    SUM(tariffa_finale) as fatturato,
    SUM(margine_calcolato) as margine
FROM spedizioni_analytics
WHERE mese_ritiro BETWEEN 202504 AND 202506
GROUP BY cliente_mittente_categoria;

Questa query gira in ~80 ms su 1,5M di righe con l'indice appropriato. La stessa query sul modello normalizzato richiederebbe JOIN a 4 tabelle e girerebbe in 15-20 secondi.

Il sync asincrono: come mantenere aggiornato il read model

La chiave dell'architettura è il meccanismo che mantiene il read model in sincronia con il write model. Il pattern che uso è un Observer Eloquent sul write model che dispatcha un job di sync dopo ogni save. Il job aggiorna (o crea) la riga corrispondente nella tabella analytics:

<?php
// app/Observers/SpedizioneObserver.php
namespace App\Observers;

use App\Models\Spedizione;
use App\Jobs\SyncSpedizioneAnalytics;

class SpedizioneObserver
{
    public function saved(Spedizione $spedizione): void
    {
        SyncSpedizioneAnalytics::dispatch($spedizione->id)
            ->onQueue('analytics-sync');
    }

    public function deleted(Spedizione $spedizione): void
    {
        SyncSpedizioneAnalytics::dispatch(
            $spedizione->id,
            deleted: true
        )->onQueue('analytics-sync');
    }
}

// app/Jobs/SyncSpedizioneAnalytics.php
namespace App\Jobs;

use App\Models\Spedizione;
use App\Models\SpedizioneAnalytics;

class SyncSpedizioneAnalytics implements ShouldQueue
{
    public int $tries = 3;
    public int $backoff = 10;

    public function __construct(
        public int $spedizioneId,
        public bool $deleted = false,
    ) {}

    public function handle(): void
    {
        if ($this->deleted) {
            SpedizioneAnalytics::where('spedizione_id', $this->spedizioneId)->delete();
            return;
        }

        $spedizione = Spedizione::with(['mittente', 'destinatario', 'eventi', 'tipoServizio'])
            ->find($this->spedizioneId);

        if (!$spedizione) return;

        $dataConsegna = $spedizione->eventi
            ->firstWhere('tipo', 'consegnata')?->created_at;

        SpedizioneAnalytics::updateOrCreate(
            ['spedizione_id' => $spedizione->id],
            [
                'cliente_mittente_id' => $spedizione->mittente_id,
                'cliente_mittente_ragione_sociale' => $spedizione->mittente->ragione_sociale,
                'cliente_mittente_categoria' => $spedizione->mittente->categoria,
                'cliente_destinatario_id' => $spedizione->destinatario_id,
                'cliente_destinatario_ragione_sociale' => $spedizione->destinatario?->ragione_sociale,
                'data_ritiro' => $spedizione->data_ritiro->toDateString(),
                'data_consegna' => $dataConsegna?->toDateString(),
                'mese_ritiro' => (int) $spedizione->data_ritiro->format('Ym'),
                'anno_ritiro' => (int) $spedizione->data_ritiro->format('Y'),
                'peso_kg' => $spedizione->peso_kg,
                'volume_cm3' => $spedizione->volume_cm3,
                'tipo_servizio' => $spedizione->tipoServizio->nome,
                'tariffa_finale' => $spedizione->calcolaTariffaFinale(),
                'margine_calcolato' => $spedizione->calcolaMargine(),
                'stato' => $spedizione->stato,
                'giorni_consegna' => $dataConsegna
                    ? $spedizione->data_ritiro->diffInDays($dataConsegna)
                    : null,
                'is_consegnata' => $spedizione->stato === 'consegnata',
            ]
        );
    }
}

L'architettura ha tre caratteristiche operative importanti. Primo: asincrono. Il job gira su una coda dedicata (analytics-sync) processata da worker Horizon separati dai worker principali. Un picco di scritture non blocca né degrada le operazioni utente - al peggio, il read model risulta "indietro" di 30-60 secondi durante i picchi, cosa accettabile per reportistica. Secondo: idempotente. updateOrCreate garantisce che job duplicati (che possono succedere per retry) producono stesso risultato. Terzo: ricostruibile. Se il read model si corrompe o diventa incoerente, posso rifarlo completamente con un command Artisan:

php artisan analytics:rebuild-spedizioni

Questo command dispatcha SyncSpedizioneAnalytics per ogni spedizione nel DB, ricostruendo l'intera tabella analytics da zero. Sul cliente logistica questa operazione richiede 45 minuti per 1,5M di record - procedura che ho eseguito 2 volte in produzione quando abbiamo cambiato la logica di calcolo del margine e dovevo riallineare i dati storici.

Stai cercando un Consulente Informatico esperto per progettare un'architettura CQRS pragmatica su Laravel per applicazioni con workload misto OLTP/OLAP, senza cadere nelle trappole dell'event sourcing over-engineered? Nel mio profilo professionale trovi l'esperienza concreta su architetture scalabili, Laravel enterprise e piattaforme SaaS B2B con requisiti di performance specifici.

Le query analitiche: come sfruttare il read model denormalizzato

La parte consumer del read model è dove emerge il beneficio di performance. Le query analitiche della dashboard manageriale girano direttamente sulla tabella spedizioni_analytics:

<?php
// app/Services/Analytics/SpedizioniAnalyticsService.php
namespace App\Services\Analytics;

use App\Models\SpedizioneAnalytics;

class SpedizioniAnalyticsService
{
    public function fatturatoMensileByCategoriaCliente(int $annoMese): array
    {
        return SpedizioneAnalytics::selectRaw('
                cliente_mittente_categoria,
                COUNT(*) as n_spedizioni,
                SUM(tariffa_finale) as fatturato,
                SUM(margine_calcolato) as margine,
                AVG(giorni_consegna) as giorni_consegna_medi
            ')
            ->where('mese_ritiro', $annoMese)
            ->where('is_consegnata', true)
            ->groupBy('cliente_mittente_categoria')
            ->orderByDesc('fatturato')
            ->get()
            ->toArray();
    }

    public function topClientiByFatturatoRange(string $dataInizio, string $dataFine, int $limit = 20): array
    {
        return SpedizioneAnalytics::selectRaw('
                cliente_mittente_id,
                cliente_mittente_ragione_sociale,
                COUNT(*) as n_spedizioni,
                SUM(tariffa_finale) as fatturato,
                SUM(margine_calcolato) as margine
            ')
            ->whereBetween('data_ritiro', [$dataInizio, $dataFine])
            ->groupBy('cliente_mittente_id', 'cliente_mittente_ragione_sociale')
            ->orderByDesc('fatturato')
            ->limit($limit)
            ->get()
            ->toArray();
    }

    public function trendConsegneUltimiNMesi(int $mesi = 12): array
    {
        return SpedizioneAnalytics::selectRaw('
                mese_ritiro,
                COUNT(*) as totali,
                SUM(CASE WHEN is_consegnata THEN 1 ELSE 0 END) as consegnate,
                AVG(giorni_consegna) as giorni_medi
            ')
            ->where('mese_ritiro', '>=', (int) now()->subMonths($mesi)->format('Ym'))
            ->groupBy('mese_ritiro')
            ->orderBy('mese_ritiro')
            ->get()
            ->toArray();
    }
}

Ogni query è senza JOIN, con GROUP BY e aggregation su colonne indicizzate. Sul cliente logistica, le 47 query analitiche della dashboard sono state tutte riscritte in questo stile - la più complessa (analisi di profittabilità cross-cliente con breakdown temporale) gira in 180 ms contro i 22 secondi precedenti, e viene cached in Redis per 10 minuti per ulteriore efficienza.

I trade-off onesti: eventual consistency e duplicazione dati

Il pattern CQRS Light introduce due compromessi che devono essere dichiarati ai clienti non-tecnici prima di adottarlo. Primo: eventual consistency. Il read model è aggiornato asincrono rispetto al write model. Tipicamente l'aggiornamento avviene entro 2-5 secondi dal write, ma può essere più tardivo durante picchi di carico. Questo significa che immediatamente dopo una modifica ai dati, le query analitiche potrebbero non riflettere quella modifica. Per la maggior parte di reportistica manageriale questo è accettabile (nessuno attende dashboard aggiornato al secondo), per alcuni casi d'uso non lo è (es: "mostra lo stato attuale della spedizione X" deve leggere dal write model, non dal read model).

Secondo: duplicazione dei dati. La tabella spedizioni_analytics contiene informazioni ridondanti rispetto al write model. Se un cliente cambia ragione sociale, il valore duplicato nel read model deve essere aggiornato. Il pattern di sync asincrono gestisce questo (un update al cliente dispatcha un job che aggiorna tutte le spedizioni denormalizzate), ma richiede attenzione: se qualche pattern di modifica non viene catturato da un Observer, il read model può divergere dal write nel tempo. La disciplina è di trattare il read model come derivato e avere sempre la capacità di ricostruirlo dal write model autoritativo.

Sul cliente logistica, il primo mese dopo il deploy ha fatto emergere 3 pattern di divergenza che non avevo catturato inizialmente: modifiche diretto via SQL da parte del DBA senza Observer trigger, import batch di dati storici che saltavano Eloquent events, eliminazione fisica di record che andava propagata. Tutte e tre sono state risolte aggiungendo Observer mancanti o rifrattando procedure di import per triggering dei Job di sync. Il pattern complessivo di verifica dell'integrità è stato: ogni venerdì notte, confronto aggregato fra write e read model (conta record per mese, somme di colonne critiche); se divergenza oltre 0.1%, alert al team per indagine.

Quando NON usare CQRS: i segnali di over-engineering

È importante essere onesti su quando CQRS (anche nella versione Light) è errore architetturale. Il pattern ha tre contro-indicazioni chiare.

Primo: applicazioni CRUD standard con workload bilanciato. Se la tua applicazione è un gestionale tipico dove le letture e le scritture hanno volumi simili e nessuno dei due domina, CQRS introduce complessità non necessaria. Il costo di mantenere due modelli in sync è maggiore del beneficio prestazionale. Il pattern di architettura pulita Laravel con refactoring controller service repository che descrivo in un articolo dedicato è più appropriato in questi casi.

Secondo: applicazioni con requisiti di consistency forte. Se il business richiede che letture siano sempre up-to-date al secondo (es: sistemi di trading finanziario, e-commerce dove il count di stock deve essere esatto), l'eventual consistency di CQRS Light è incompatibile. In questi casi le soluzioni alternative sono read replica MySQL/PostgreSQL (che hanno consistency sub-second), index ottimizzati, caching aggressivo, o database distribuiti con consistency strong.

Terzo: team piccoli senza esperienza architetturale. Implementare CQRS richiede disciplina di pattern: Observer corretti, Job resilienti, procedure di rebuild, monitoring di divergenza. Un team di 2-3 developer senza esperienza precedente può gestire l'iniziale implementazione ma fa fatica nel mantenerla nel tempo. Se il team è piccolo, preferisco approcci più semplici (read replica, caching) che comunque risolvono molti problemi senza la complessità strutturale di CQRS.

I risultati misurati a 6 mesi sul cliente logistica

A sei mesi dal deploy, le metriche del cliente sono queste. Tempo medio di risposta delle query analitiche: 140 ms (contro 18 secondi pre-CQRS). P99 delle query analitiche: 550 ms (contro 40+ secondi). Carico medio CPU database: 35% (contro 78%). Numero di incident di performance legati a "dashboard lenta" e segnalati dal business: 0 negli ultimi 6 mesi (contro 4-5 al mese nel periodo peggiore pre-CQRS). Tempo di sync del read model medio: 3.2 secondi dal write (P99 12 secondi durante picchi orari). Numero di divergenze rilevate dal controllo settimanale: 2 in 6 mesi, entrambe minori e risolte rapidamente con procedura di rebuild parziale.

Il beneficio qualitativo più importante è emerso dopo 2-3 mesi: la nuova architettura ha sbloccato l'aggiunta di nuove feature analitiche che prima erano tecnicamente inattuabili. Prima di CQRS, ogni nuova query analitica richiesta dal business era un potenziale stress per il DB; dopo, le nuove feature si aggiungono semplicemente aggiungendo indici alla tabella analytics o aggregazioni pre-computate. Il team ha introdotto 11 nuove analytics nei 6 mesi post-CQRS, contro le 2-3 che aveva introdotto nei 12 mesi precedenti. La velocità di iterazione è il ROI più importante.

Se gestisci un'applicazione Laravel dove la reportistica analitica ha iniziato a confliggere strutturalmente con le operazioni transazionali, e read replica / caching non sono più sufficienti per mantenere performance accettabili, contattami per una valutazione architetturale: in due settimane analizzo il pattern di utilizzo del tuo database, identifico gli scenari dove CQRS Light produrrebbe il massimo beneficio calibrato sul tuo contesto, disegno lo schema dei read model e il pattern di sync, e ti consegno un piano di migrazione incrementale che non richiede fermo produzione né riscritture pesanti. L'obiettivo è permetterti di servire workload analitici demanding mantenendo solidità e velocità delle operazioni transazionali, trasformando la dashboard lenta in asset operativo invece di vincolo architetturale.

Ultima modifica: