Eloquent ORM avanzato: evitare le query N+1 nei gestionali Laravel
C'è un tipo di bug che non rompe niente in sviluppo e affonda la produzione, e per questo è il più pericoloso: la query N+1. In locale, con dieci righe di dati di prova, la pagina è istantanea e nessuno sospetta nulla. In produzione, con qualche migliaio di righe e le loro relazioni, la stessa pagina genera centinaia o migliaia di query a ogni caricamento, il database fuma e i tempi di risposta passano da millisecondi a secondi. In un gestionale reale ho visto una lista di ordini apparentemente innocua scatenare oltre cinquecento query a ogni apertura, semplicemente perché per ogni ordine il template leggeva il cliente e le righe d'ordine una alla volta. Nessun errore, nessun avviso: solo lentezza che cresce con i dati. Vediamo come si genera questo killer silenzioso, come si rende visibile con gli strumenti giusti, come lo si elimina con un eager loading fatto bene, e quali pattern Eloquent avanzati tengono il numero di query costante anche con relazioni annidate e aggregati.
Cos'è una query N+1 e perché in sviluppo non la vedi
Il nome descrive esattamente il problema. Carichi una lista di N elementi con una query (la "1"), e poi, per ogni elemento, accedi a una relazione che Eloquent non ha precaricato: il lazy loading scatta e fa una query in più per ciascuno, per un totale di N query aggiuntive. Una lista di 500 ordini in cui ogni riga mostra il nome del cliente esegue una query per la lista e 500 query per i clienti: 501 query per mostrare una pagina.
// Innocuo all'apparenza, disastroso sotto carico
$orders = Order::limit(500)->get(); // 1 query
foreach ($orders as $order) {
echo $order->customer->name; // +1 query PER ORDINE: 500 query
}Il motivo per cui non lo vedi in sviluppo è puramente quantitativo: con dieci ordini di prova sono undici query, girano in pochi millisecondi e la pagina vola. Il difetto è già lì, ma è invisibile perché il costo è proporzionale al numero di righe, e in sviluppo le righe sono poche. È esattamente il genere di problema che non si trova guardando il codice riga per riga, ma misurando, ed è per questo che la prima mossa non è ottimizzare, è rendere visibile.
Individuarle: gli strumenti che le rendono visibili
Non si ottimizza ciò che non si misura. Per le query N+1 ci sono diversi strumenti, e il migliore dipende dal contesto. In sviluppo, Laravel Debugbar mostra il conteggio delle query per ogni pagina: se quel numero cresce quando aggiungi righe ai dati, hai un N+1. In ambienti più realistici, Laravel Telescope registra ogni query eseguita da una richiesta e segnala esplicitamente le occorrenze di N+1, ed è lo strumento che uso quando voglio capire cosa succede su uno staging con dati veri, come ho descritto parlando di Telescope per il debugging avanzato in produzione. Per un colpo d'occhio rapido basta anche ascoltare le query a mano:
DB::listen(fn ($query) => Log::debug($query->sql));Ma la tecnica più efficace, quella che consiglio di tenere attiva in sviluppo, è trasformare il lazy loading in un errore. Laravel permette di disabilitare il caricamento pigro delle relazioni, così ogni accesso a una relazione non precaricata lancia un'eccezione invece di fare silenziosamente una query in più:
// in AppServiceProvider::boot(), solo in ambiente locale
Model::preventLazyLoading(! app()->isProduction());Con questa riga attiva, un N+1 smette di essere un problema di prestazioni invisibile e diventa un'eccezione che esplode in faccia allo sviluppatore nel momento esatto in cui scrive il codice difettoso. È la differenza tra scoprire il problema in sviluppo e scoprirlo da un cliente arrabbiato per la lentezza. La sposto sempre a false in produzione, perché lì un'eccezione del genere farebbe più danni del problema che previene.
Se gestisci un'applicazione gestionale che sotto carico rallenta e non sai da dove cominciare, nel mio profilo professionale trovi l'esperienza concreta sull'ottimizzazione di applicazioni Laravel ad alta complessità, dove il numero di query è quasi sempre la prima cosa da mettere in ordine.
L'eager loading fatto bene, e i suoi tranelli
La soluzione di base al N+1 è l'eager loading: dire a Eloquent di precaricare le relazioni in un numero fisso di query, invece di andarle a prendere una per una. Si fa con with(), e per le relazioni annidate si usa la notazione a punto.
// 501 query diventano 2: una per gli ordini, una per tutti i clienti
$orders = Order::with('customer')->limit(500)->get();
// relazioni annidate: ordini, righe d'ordine e prodotto di ogni riga, in 3 query totali
$orders = Order::with('items.product', 'customer')->get();L'eager loading ha però due tranelli che vedo ignorare di continuo. Il primo è l'over-fetching: precaricare relazioni che non servono in quella pagina, o caricare tutte le colonne quando ne usi due. Su una lista, selezionare solo le colonne necessarie della relazione riduce drasticamente i dati trasferiti.
// carica solo le colonne che servono davvero
$orders = Order::with('customer:id,name,vat_number')->get();Il secondo tranello è il momento sbagliato: a volte hai già una collezione caricata e ti accorgi solo dopo che ti serve una relazione. Lì non rifai la query da capo, usi load() per fare l'eager loading sulla collezione esistente, o loadMissing() per caricarla solo se non è già presente, evitando di ricaricare ciò che hai già.
I pattern avanzati: aggregati e relazioni annidate senza esplodere
Il N+1 non riguarda solo i nomi delle relazioni, riguarda anche gli aggregati. Mostrare in una lista "quanti articoli ha ogni ordine" con $order->items->count() ricarica tutte le righe di ogni ordine solo per contarle: un N+1 mascherato da innocente conteggio. La risposta corretta sono i metodi di aggregazione che Eloquent calcola in un'unica query aggiuntiva:
// conteggio, somma e media calcolati dal database, non in PHP
$orders = Order::withCount('items')
->withSum('items', 'total')
->withExists('refunds')
->get();
// $order->items_count, $order->items_sum_total, $order->refunds_existsUn altro pattern prezioso è l'eager loading vincolato, che precarica solo le righe della relazione che ti interessano, con una closure:
// precarica solo le righe spedite, non tutte
$orders = Order::with(['items' => fn ($q) => $q->where('shipped', true)])->get();E quando una relazione polimorfica punta a tipi diversi, morphWith permette di eager-loadare i giusti modelli per ciascun tipo, evitando l'N+1 anche sulle relazioni morphTo. Questi strumenti hanno un filo comune: spostano il lavoro dal ciclo PHP, dove ogni iterazione è una query, al database, dove lo stesso risultato si ottiene in un colpo solo. È lo stesso principio dei pattern di performance per le query lente in Eloquent: far fare al database ciò che il database fa meglio.
Quando l'eager loading non basta: chunk, cursor e subquery
Su dataset grandi anche l'eager loading corretto può non bastare, perché caricare in memoria centomila modelli con le loro relazioni satura la RAM prima di rallentare il database. Lì cambiano gli strumenti. Per processare grandi quantità di righe senza farle stare tutte in memoria insieme si usa chunk() o lazy(), che lavorano a blocchi:
// processa a blocchi di 1.000, memoria costante anche su milioni di righe
Order::with('customer')->chunk(1000, function ($orders) {
foreach ($orders as $order) { /* ... */ }
});Per i casi in cui vorresti un dato aggregato da una relazione senza nemmeno caricarla, le subquery sono l'arma fine. Aggiungere come colonna calcolata "la data dell'ultimo ordine di ogni cliente" con una subquery evita sia il join costoso sia l'N+1, e si fa con addSelect:
// l'ultimo ordine di ogni cliente come colonna, senza join né relazione caricata
$customers = Customer::addSelect(['last_order_at' => Order::select('created_at')
->whereColumn('customer_id', 'customers.id')
->latest()
->limit(1),
])->get();Questo tipo di subquery, applicato bene, è ciò che trasforma una pagina di reportistica da decine di query e diversi secondi a una singola query veloce. La regola che applico è di salire di livello solo quando serve: prima l'eager loading, poi gli aggregati nativi, poi le subquery, poi il chunking per la memoria. Ogni livello risolve un problema diverso, e usarli a sproposito complica il codice senza beneficio.
Quante query dovrebbe fare una pagina?
La risposta operativa, quella che uso come metro, è netta: il numero di query di una pagina dovrebbe essere costante, indipendente dal numero di righe mostrate. Una lista di 10 ordini e una di 1.000 ordini devono fare lo stesso numero di query, magari quattro o cinque. Se aggiungendo righe ai dati il numero di query cresce, hai un N+1, punto. Questa è la verifica più rapida e affidabile: non guardi il codice, guardi come scala il conteggio delle query al crescere dei dati. L'obiettivo non è "poche query", è query in numero costante, perché è la costanza a garantire che la pagina regga quando i dati crescono. Tenere d'occhio questa metrica nel tempo, in produzione, è esattamente il genere di lavoro per cui esistono strumenti come Pulse per il monitoraggio applicativo nativo: le query lente e numerose sono il sintomo che emerge per primo quando un gestionale comincia a soffrire.
L'indice giusto sotto la query giusta
Ridurre il numero di query è il primo passo, ma non è l'ultimo, e ignorare il secondo è l'errore di chi si ferma a metà. Una volta che l'eager loading ha portato le 501 query a 2, quelle 2 query devono anche essere veloci, e qui entrano gli indici del database. Quando precarichi i clienti di una lista di ordini, Eloquent esegue di fatto un WHERE customer_id IN (...): se la colonna customer_id non ha un indice, il database fa una scansione completa della tabella per ogni eager loading, e ti ritrovi con poche query lentissime al posto di tante query veloci.
Eliminare l'N+1 senza mettere gli indici sulle chiavi esterne è come svuotare la vasca col secchio invece che col cucchiaino: meglio, ma non è ancora aprire lo scarico. Il numero di query e la velocità di ogni query sono due ottimizzazioni distinte, e servono entrambe.
La regola pratica è che ogni colonna usata in una JOIN, in un WHERE o nelle clausole di eager loading dovrebbe avere un indice. Su Laravel questo si esprime nelle migration con $table->foreignId('customer_id')->index() o con una foreignId(...)->constrained(), che crea anche il vincolo. Verificare il piano di esecuzione di una query lenta con EXPLAIN è il modo per capire se un indice manca o non viene usato, e dovrebbe far parte del bagaglio di chiunque ottimizzi un gestionale. La combinazione vincente è sempre la stessa: poche query, ciascuna sostenuta dall'indice giusto.
Il caso reale: da cinquecento query a una manciata
Torniamo alla lista di ordini da cui siamo partiti. Le oltre cinquecento query nascevano da tre N+1 sovrapposti: il cliente di ogni ordine, le righe di ogni ordine, e un conteggio fatto in PHP. La correzione è stata chirurgica e segue esattamente la scala descritta sopra. Un with('customer:id,name') ha portato i clienti in una query sola; un withCount('items') ha sostituito il conteggio in PHP con un valore calcolato dal database; un withSum('items', 'total') ha fatto lo stesso per il totale mostrato in lista. Risultato: da oltre cinquecento query a quattro, e da diversi secondi a poche decine di millisecondi, senza toccare una sola riga di logica di business, solo cambiando come i dati venivano caricati.
Il punto che porto sempre a casa da questi interventi è che il N+1 non è un problema esotico da esperti di database, è la conseguenza naturale di un ORM comodo che nasconde le query dietro l'accesso a una proprietà. Eloquent rende così facile scrivere $order->customer->name che dimentichi che dietro c'è una query, e finché i dati sono pochi non te ne accorgi. La competenza non sta nell'evitare l'ORM, che resta uno strumento meraviglioso, sta nel sapere quando l'accesso a una relazione costa una query e nel progettare il caricamento di conseguenza. Si rende visibile il problema con gli strumenti giusti e con il lazy loading disabilitato in sviluppo, si elimina con l'eager loading e gli aggregati nativi, si sale a subquery e chunking quando la scala lo richiede, e si tiene sotto controllo la metrica che conta: query costanti, non proporzionali alle righe. Se hai un gestionale Laravel che si è fatto lento man mano che i dati crescevano, e ogni lista o report sembra arrancare, contattami per una consulenza diretta: nella mia esperienza, il primo giro di ottimizzazione sulle query N+1 restituisce quasi sempre il miglioramento più grande con lo sforzo più piccolo.