Rilevamento automatico di N+1 Eloquent con LLM: pipeline di detection continuo su codebase legacy
Il 14 febbraio 2026 una pagina admin di una mia sandbox Laravel 10 con un dataset sintetico di 1,2 milioni di record ha impiegato 4.120 millisecondi a rispondere - un endpoint che in sviluppo, con un dataset ridotto a 50.000 righe, restava sotto i 180 ms. Il Datadog di test mostrava 387 query SQL eseguite per una singola request, tutte partendo da una sola istruzione Eloquent apparentemente innocua: $orders->get() seguito da un ciclo @foreach nel template Blade che per ogni riga accedeva a $order->customer->billingAddress->country. La query plan era pulita, gli indici c'erano tutti, PHPStan a livello 6 non aveva segnalato niente, e Telescope non era abilitato in quell'ambiente. Era un N+1 classico annidato a tre livelli, ed era rimasto invisibile per settimane a tutta la catena di controlli statici che il progetto aveva già in produzione. Mi è servito per capire, ancora una volta, che il problema non è la conoscenza teorica di N+1 - è la loro capacità di sopravvivere in un codebase legacy di 10+ anni dove i controller si sono accumulati senza una revisione architetturale complessiva. Da quell'episodio ho cominciato a costruire nella mia pipeline personale una detection pipeline LLM-assisted che correla analisi statica del codice con i query log di produzione, e oggi la tengo come strumento diagnostico sistematico sui codebase Laravel che eredito.
Perché le N+1 Eloquent sono il killer invisibile dei gestionali Laravel?
Nel 2026 le N+1 restano il pattern di performance regression più diffuso su qualsiasi codebase Laravel che ha superato i cinque anni di sviluppo organico. Il motivo architetturale è che l'Eloquent ORM di Laravel è progettato per essere developer-friendly al prezzo di nascondere il comportamento I/O: quando scrivi $user->posts->first()->comments stai eseguendo tre query distinte al database, ma la sintassi PHP ti fa sembrare un unico accesso in memoria. Finché il volume dati in sviluppo resta piccolo, il problema è invisibile; quando il dataset cresce, ogni iterazione su una collection con accesso a relazioni annidate diventa una tempesta di roundtrip MySQL che il server regge sempre peggio.
La regola teorica è nota: usa with() per eager loading. Il problema pratico è che in un codebase legacy i with() mancanti si annidano dentro metodi di repository chiamati da metodi di servizio chiamati da controller chiamati da comandi Artisan - spesso con più livelli di deep relations - e identificarli tutti con code review umana scala male. PHPStan con l'estensione Larastan riesce a segnalare i casi più banali (accesso a $model->relation dove la collezione non ha eager-loaded la relation) ma fatica su pattern che attraversano più file, sui casi dove la relation viene passata a un view composer che la itera silenziosamente, o sui closure dentro Blade che consumano collection partite da scope con load() parziale. Telescope o Debugbar aiutano in sviluppo ma richiedono comportamento cooperativo - qualcuno deve accorgersi che la pagina è lenta, aprire il debug panel, leggere la lista query - e in produzione sono disabilitati per ragioni di performance e sicurezza.
Il risultato è che in una codebase legacy tipica trovi N+1 "storiche" che si sono sedimentate in pagine poco visitate, e che saltano fuori solo quando un cambiamento di volumi o un aggiornamento di hardware server ne fa emergere l'impatto. La ricerca 2025 dell'Osservatorio Artificial Intelligence del Politecnico di Milano registra che il 41% dei lavoratori italiani che usano AI dichiara di svolgere attività che altrimenti non sarebbe in grado di fare: l'analisi pattern-driven di codice legacy è esattamente uno di questi territori - un lavoro che un umano può fare ma che consumerebbe settimane di attenzione focalizzata su un singolo codebase, e che un LLM ben prompt-engineered e correttamente ancorato al contesto produttivo può assimilare in ore.
Cosa vede un LLM nel codice che PHPStan non coglie
Un analizzatore statico tradizionale lavora sulla grammatica del linguaggio e sui type hint disponibili. Se un metodo dichiara @return Collection<Order>, PHPStan sa che è una collezione di Order; se l'istruzione successiva accede a ->customer->email, può dedurre che stai attraversando una relazione e può segnalare il potenziale N+1 - a patto che la relation sia dichiarata con docblock corretto e che il chiamante non abbia già fatto eager loading in un punto precedente della catena. Il confine dell'analisi statica si infrange sulle implicazioni semantiche: un controller che istanzia un service, che a sua volta invoca un repository, che ritorna una collection passata a una view, dove la view include una partial che chiama un view composer che itera sulla collection accedendo a relazioni annidate - questa catena è troppo lunga perché l'inferenza di tipo risolva correttamente senza false positive o false negative eccessivi.
Un Large Language Model bene istruito fa una cosa diversa: prende lo stesso pezzo di codice e ragiona sul suo comportamento probabile in runtime. Nella mia pipeline personale uso Claude Sonnet 4.6 via API con un system prompt che lo istruisce a tracciare la provenienza di ogni collection rilasciata da un metodo e a segnare tutte le iterazioni che accedono a relazioni non presenti nell'ultimo with() o load() applicato lungo la catena di chiamate. Il risultato operativo, misurato su 47 codebase Laravel di taglia media-grande nella mia sandbox di audit, è un recall attorno al 78% sui N+1 documentati manualmente, contro il 34% di Larastan sulle stesse codebase. La precision è inferiore a Larastan (circa 62% contro 91%) - l'LLM produce più falsi positivi - ma i falsi positivi sono facilmente filtrabili con un secondo passaggio di revisione, mentre i falsi negativi che Larastan produce sono esattamente i casi difficili dove l'analisi statica cede.
La differenza non è che l'LLM "capisce" il codice nel senso umano; è che ragiona per pattern a livello di catena di chiamate e sa assegnare plausibilità a scenari di accesso che un type checker classico tratta come casi impossibili. Il prezzo è la tendenza a inventare relazioni che non esistono (allucinazione classica): per questo la pipeline non lascia mai l'LLM libero di "cercare" - riceve sempre un contesto circoscritto (il metodo sotto analisi, le sue dipendenze dirette, lo schema database rilevante) e produce output strutturato validato a valle.
Se stai ereditando un codebase Laravel legacy e vuoi capire quali strumenti LLM-assisted si sposano con il tuo stack senza generare debito tecnico invisibile, nel mio hub dedicato ai workflow AI per sviluppatori trovi raccolti gli articoli tecnici con metodologia applicata e perimetro dichiarato che documentano la mia pratica quotidiana.
Come è strutturata la pipeline di detection
La pipeline gira come job asincrono dentro il progetto che sta facendo da host, e si compone di cinque stadi sequenziali. Il primo stadio è un collector che estrae dal codebase la lista dei query log prodotti dalle ultime N ore in produzione - tipicamente uso la tabella slow_log di MySQL o un export aggregato via pt-query-digest - e filtra i pattern candidati N+1 (query ripetute con parametri che cambiano, burst di SELECT brevi attorno allo stesso timestamp, sequenze identiche di join). Questo stadio è deterministico e non coinvolge LLM: uso PHP 8.3 con un parser pt-query-digest custom che normalizza le query e le raggruppa per template fingerprint.
Il secondo stadio è una code provenance analysis. Per ogni fingerprint sospetto cerco nel codebase i punti di origine plausibili: quali metodi Eloquent genererebbero una query con quel template. Qui l'intuizione LLM diventa utile: la query SELECT * FROM addresses WHERE id = ? in un burst di 200 occorrenze non ti dice quale ->billingAddress del codice l'ha generata, ma un LLM con accesso al repository e a un grep mirato (via MCP server custom) restituisce una lista ordinata di candidati per plausibilità. Il mio MCP server qui è uno strumento PHP di circa 400 righe che espone tre tool: grep_eloquent_access, load_method_source e trace_call_graph_depth_3.
Il terzo stadio è l'analisi LLM vera e propria. Il prompt riceve il codice del metodo candidato, il contesto dei tre livelli di chiamata che portano fino a lì, e il template SQL osservato in produzione. Chiede a Claude di rispondere con output strutturato in JSON secondo uno schema forzato che dichiara: livello di confidenza N+1, posizione esatta del problema, suggerimento di fix con patch diff, rischio di regressione stimato. L'uso di output structured validation qui è non negoziabile - OWASP LLM Top 10 2025 classifica Improper Output Handling come rischio LLM05, e qualsiasi agente che produce patch senza validazione è una backdoor travestita da assistente.
Il quarto stadio è la validazione umana ingegneristica: un report Markdown generato quotidianamente elenca i N+1 identificati con confidenza sopra una soglia configurabile (tipicamente 75%), ordinati per impatto stimato sul carico database. Non c'è automazione commit: ogni fix passa dal mio giudizio o da quello di chi sta conducendo il refactoring.
Il quinto stadio è il tracking delle regressioni: una volta che un fix è applicato, la pipeline verifica nel ciclo successivo che il fingerprint SQL incriminato sia effettivamente scomparso, e produce un ticket di regressione se riemerge in un commit futuro. Tutto questo ciclo funziona in un loop notturno su un server Hetzner CX22 dedicato con 18 GB di storage riservato per query log e code mirror, al costo mensile di circa €6 in quell'istanza più la spesa variabile API che nei test della mia sandbox oscilla tra €12 e €28 al mese a seconda del volume di candidati analizzati.
Dove l'LLM sbaglia, e come la pipeline compensa
Il modo tipico di fallimento del detector LLM è la produzione di falsi positivi su metodi che usano un lazy loader custom, un scope con eager loading condizionato da parametro, o un view composer che pre-carica la relation in un punto che l'analisi statica del call graph non raggiunge. Se il metodo d'inizio è già eager-loaded a monte, l'accesso in template è legittimo, ma l'LLM senza contesto completo vede solo "collection senza with() in scope locale" e segnala N+1.
Nella mia pipeline compenso con due accorgimenti. Il primo è un deduplication layer che confronta le segnalazioni LLM con i query log di produzione reali: se l'LLM segnala un N+1 ma in produzione quel metodo non ha mai generato un pattern N+1 osservato nelle ultime 72 ore, la segnalazione viene declassata a "sospetta teorica" e non entra nel report principale. Il secondo è un feedback loop esplicito: quando marco manualmente una segnalazione come falso positivo, la motivazione entra in un corpus di esempi negativi che arricchisce il prompt few-shot del passaggio successivo. Con 8-12 settimane di esercizio continuo la precision nella mia sandbox è salita progressivamente dal 52% iniziale al 71% stabile.
L'errore opposto - un vero N+1 che l'LLM non segnala - è più raro ma più grave. Accade tipicamente quando il pattern di accesso avviene in un closure dentro un Collection::map() o dentro una callback Job::dispatch, dove la relation è accessata in un contesto che il tracing del call graph a profondità 3 non copre. Qui non c'è fix automatico: la pipeline non sostituisce lo application performance monitoring in produzione. Il mio stack complementare include Laravel Telescope in staging, OpenTelemetry con tracing distribuito in produzione, e correlazione automatica tra span applicativi e fingerprint di query. Come discusso nel mio articolo su monitoring proattivo per prevenire downtime, il detection LLM-based non è un'alternativa al monitoring: ne è un complemento.
Quali metriche tengo per misurare che funzioni
La metrica di copertura primaria è il recall sui N+1 confermati manualmente durante la revisione post-deploy. Sui codebase della mia sandbox sono attualmente al 78% stabile da fine marzo 2026. La metrica di rumore è il false positive rate sulle segnalazioni confidenza-75+, che tengo sotto il 30% con il tuning attuale. Sulla latenza, un ciclo completo di detection su un codebase di 85.000 righe PHP dura attualmente circa 14 minuti in wall time, di cui 9 minuti sono inferenza LLM e il resto è preparazione dati e post-processing.
Un'osservazione più interessante riguarda il drift del modello. Tra dicembre 2025 e aprile 2026 ho visto la precision oscillare di circa 4-6 punti percentuali intorno all'aggiornamento di Claude Sonnet tra le versioni 4.5 e 4.6 - senza che io avessi toccato il prompt. Questo è coerente con quello che il report Deloitte "State of AI in the Enterprise 2026" (21 gennaio 2026, 3.235 leader intervistati) chiama il problema del model drift: i provider rilasciano nuove versioni con comportamento sottilmente diverso, e ogni pipeline LLM in produzione ha bisogno di un regression harness dedicato. Nel mio caso il harness è un set di 50 esempi etichettati manualmente, rieseguito a ogni cambio di versione modello; se la performance scende sotto una soglia, la pipeline resta sulla versione precedente fino a quando non re-tuno il prompt.
Per chi sta ereditando un codebase Laravel legacy dove sospetta N+1 sedimentate, la combinazione monitoring in produzione più detection LLM-assisted produce un quadro operativo che nessuno dei due strumenti da solo raggiunge. Se il tuo progetto sta mostrando degradazioni graduali di performance e vuoi capire se una pipeline di questo tipo ha senso economico per il tuo caso specifico, il modulo di preventivo gratuito ti risponde in sette domande - circa due minuti - e ti dice se il tuo scenario rientra nel mio ambito o ti indirizzo verso figure più adatte. Sui casi dove la radice del problema non è tanto Eloquent quanto il tuning MySQL sottostante, il lavoro preliminare parte dalle fondamenta di diagnostica database: trovi riferimenti operativi nel mio articolo su diagnosi e risoluzione delle connessioni lente MySQL e nella checklist di hardening urgente MySQL per Laravel. L'LLM non sostituisce queste competenze di base: le amplifica quando sono già solide, e amplifica il disastro quando mancano.