Salta al contenuto principale
Architettura5 febbraio 2026· 11 min di lettura

Il tuo ORM non ti sta aiutando. Ti sta nascondendo il problema.

Active Record e Data Mapper promettono di semplificare l'accesso al database. In realtà nascondono N+1, overhead e query inefficienti. Ecco l'alternativa.

Il tuo ORM non ti sta aiutando. Ti sta nascondendo il problema.
ormdatabaselaravelsymfonyeloquentdoctrinesqlperformanceai
Condividi:

Il codice è elegante. I model sono puliti. Le relazioni sono definite con una riga. Ogni controller rispetta le convenzioni del framework. La code review passa senza un commento. E intanto, dietro le quinte, il database sta processando 200 query per caricare una singola pagina.

Questo è il paradosso dell'ORM: più il codice sembra "fatto bene" secondo le best practice del framework, più è probabile che il database stia soffrendo in silenzio. Eloquent in Laravel, Doctrine in Symfony — il pattern cambia, il problema resta.

Se sei un CTO, un tech lead, o uno sviluppatore senior che si è mai chiesto perché il prodotto è lento nonostante il codice sia "pulito", questo articolo è per te.

Cosa fa davvero un ORM (e cosa ti fa credere di fare)

Un ORM — Object-Relational Mapper — è uno strato di astrazione tra il tuo codice e il database. L'idea è semplice: invece di scrivere SQL, lavori con oggetti. Invece di SELECT * FROM users WHERE id = 1 scrivi User::find(1). Più leggibile, più integrato con il linguaggio, meno contesto da cambiare.

Il problema non è l'idea. È quello che succede quando smetti di pensare a cosa c'è sotto.

L'ORM ti fa credere che il database sia un dettaglio implementativo — qualcosa di cui non devi preoccuparti. In realtà il database è il componente più critico della tua applicazione. Le query che fai, quante ne fai, come sono strutturate — determinano se il tuo prodotto risponde in 50ms o in 5 secondi. L'ORM non elimina questa complessità. La nasconde.

Active Record vs Data Mapper: due filosofie, stesso punto cieco

Active Record (Eloquent, ActiveRecord di Rails)

Il pattern Active Record fonde il model con la persistenza. L'oggetto User è la rappresentazione della riga nel database. Sa come salvarsi, come caricare le relazioni, come fare query. È comodo, immediato, e produttivo dal primo giorno.

// Sembra innocuo
$users = User::all();
foreach ($users as $user) {
    echo $user->posts->count();
}

Questo codice è perfettamente idiomatico in Laravel. Rispetta le convenzioni. La code review passa. E genera N+1 query: una per caricare gli utenti, poi una per ogni utente per contare i post. Con 100 utenti, sono 101 query. Con 1.000, sono 1.001.

Il pattern Active Record rende questo tipo di errore invisibile. Il codice non sembra sbagliato. Sembra elegante. Il problema emerge solo quando misuri, e la maggior parte dei team non misura finché qualcosa non è visibilmente rotto.

Data Mapper (Doctrine)

Il Data Mapper separa il dominio dalla persistenza. Le entità non sanno come vengono salvate — c'è un EntityManager che se ne occupa. Architetturalmente è più pulito, e per applicazioni complesse questa separazione ha un valore reale.

Ma il problema delle query resta. Anzi, se ne aggiunge uno.

// DQL: SQL con un cappello diverso
$query = $em->createQuery(
    'SELECT u, COUNT(p) FROM App\Entity\User u
     LEFT JOIN u.posts p GROUP BY u'
);

Con Doctrine finisci a scrivere DQL — un linguaggio di query che assomiglia a SQL ma non è SQL. Ha le sue regole, le sue limitazioni, i suoi gotcha. E in più c'è l'overhead di hydration: Doctrine deve trasformare ogni risultato in un oggetto PHP completo, con metadata, proxy, e tracking delle modifiche. Per una lettura semplice, questo overhead è puro spreco.

In pratica: con Active Record non ti accorgi delle query che fai. Con Data Mapper le scrivi in un dialetto diverso e paghi il costo di un'astrazione che spesso non ti serve.

Il punto cieco comune

Entrambi i pattern condividono lo stesso difetto fondamentale: ti allontanano dal database. Ti fanno pensare in termini di oggetti invece che in termini di dati. E il database non ragiona per oggetti — ragiona per set, per indici, per piani di esecuzione.

Quando il tuo ORM traduce $user->posts()->where('active', true)->get() in SQL, la query risultante potrebbe essere perfettamente ottimizzata o un disastro. Dipende da come l'ORM decide di tradurla, da quali indici esistono, da come sono strutturate le tabelle. E tu non lo vedi, perché stai guardando il codice PHP, non il query plan.

I costi nascosti: numeri reali

Parliamo di numeri concreti, perché "è lento" non basta.

N+1: il killer silenzioso

Una dashboard che mostra 50 ordini con cliente, prodotti e stato di spedizione. Con Eloquent "naive":

$orders = Order::all(); // 1 query
foreach ($orders as $order) {
    $order->customer->name;   // +50 query
    $order->products;         // +50 query
    $order->shipment->status; // +50 query
}
// Totale: 151 query

Con eager loading:

$orders = Order::with(['customer', 'products', 'shipment'])->get();
// Totale: 4 query

Con una raw query ottimizzata:

SELECT o.*, c.name as customer_name, s.status as shipment_status
FROM orders o
JOIN customers c ON o.customer_id = c.id
LEFT JOIN shipments s ON o.id = s.order_id
WHERE o.created_at > NOW() - INTERVAL 30 DAY;
-- 1 query, con i prodotti caricati a parte se servono

Da 151 query a 1. Non è un'ottimizzazione marginale — è la differenza tra una pagina che carica in 3 secondi e una che carica in 50ms.

Hydration overhead

Ogni volta che Doctrine o Eloquent trasformano un risultato SQL in un oggetto, c'è un costo. Per Doctrine, questo include la creazione di proxy, il tracking delle proprietà per il change detection, e la gestione dell'identity map. Per un report che legge 10.000 righe, questo overhead può rappresentare il 60-70% del tempo totale della richiesta.

La soluzione? Per le letture, usa array o DTO. Non hai bisogno di un oggetto completo con change tracking se stai solo mostrando dati.

Query che l'ORM non sa fare bene

Window functions, CTE, query ricorsive, aggregazioni complesse con HAVING, subquery correlate, LATERAL JOIN — il SQL moderno ha strumenti potenti che gli ORM non espongono o espongono male.

Se il tuo team si limita a quello che l'ORM offre, sta usando il 30% delle capacità del database. È come comprare un'auto sportiva e usarla solo in prima.

Le false promesse dell'ORM

"Con l'ORM le query sono sicure"

È vero che gli ORM usano prepared statements di default, il che protegge dalle SQL injection. Ma questa protezione non è esclusiva dell'ORM. Il query builder la offre uguale.

// Questo è sicuro quanto Eloquent, senza l'overhead dell'ORM
DB::table('users')
    ->where('email', $request->email)
    ->first();

E per le raw query, i prepared statements esistono da sempre:

DB::select('SELECT * FROM users WHERE email = ?', [$request->email]);

La sicurezza non è un motivo per usare un ORM completo. È un motivo per usare prepared statements, che sono disponibili ovunque.

"Con l'ORM puoi cambiare database"

Questa è la promessa più vuota dell'intero ecosistema ORM.

In teoria, usando l'ORM puoi passare da PostgreSQL a MySQL cambiando una configurazione. In pratica: quand'è l'ultima volta che qualcuno l'ha fatto?

I database hanno caratteristiche specifiche per cui li scegli. PostgreSQL ha JSONB, array nativi, full-text search avanzato, window functions superiori. MySQL ha un ecosistema di replica consolidato e performance eccellenti su read-heavy workload. SQLite è perfetto per embedded e testing.

Se scegli PostgreSQL per le sue feature e poi scrivi codice "database-agnostic" che non le usa, stai rinunciando ai vantaggi del database che hai scelto per una portabilità che non userai mai.

La libertà non sta nel poter cambiare database dopo. Sta nel scegliere bene in fase di analisi e poi sfruttare al massimo quello che hai.

L'AI scrive SQL meglio del tuo ORM

Ecco l'angolo che pochi stanno considerando.

Claude Code, Cursor, Codex — quando hanno accesso alle migrazioni e alla struttura del database, scrivono SQL di livello eccellente. Non SQL "che funziona" — SQL ottimizzato, con i JOIN giusti, gli indici considerati, le aggregazioni efficienti.

Perché? Perché un modello AI che vede lo schema reale ragiona sulla struttura dei dati. Sa quali colonne esistono, quali relazioni ci sono, quali indici sono disponibili. Genera query che rispettano la struttura, non query che passano attraverso un'astrazione.

Quando lo stesso modello AI genera codice Eloquent o Doctrine, il risultato è spesso corretto sintatticamente ma inefficiente strutturalmente. N+1 ovunque, eager loading mancante, query che l'ORM traduce in modo subottimale. L'AI non "vede" il costo delle query dietro l'astrazione, esattamente come uno sviluppatore junior.

Il setup ottimale: dai all'AI accesso a migrazioni, schema, e indici. Chiedigli raw SQL o query builder. Revisionerai query che un DBA senior approverebbe, invece di codice Eloquent che nasconde 47 query dietro tre righe eleganti.

Quando l'ORM va bene (sì, esiste)

Non sto dicendo di buttare via Eloquent o Doctrine. Sto dicendo di usarli dove hanno senso.

CRUD semplice. Creare, leggere, aggiornare, eliminare singoli record — l'ORM è perfetto. Il costo dell'astrazione è trascurabile e la produttività è alta.

Prototipi e MVP. Quando devi validare un'idea velocemente, l'ORM ti fa risparmiare tempo. Il debito tecnico è accettabile perché potresti buttare tutto tra un mese.

Relazioni semplici. Un utente con un profilo, un ordine con un cliente — relazioni dirette dove eager loading risolve il problema con una riga.

Dove l'ORM diventa un problema:

Report e dashboard. Aggregazioni, raggruppamenti, calcoli — SQL è nato per questo. L'ORM ci cammina sopra goffamente.

Performance critica. Endpoint ad alto traffico, API che devono rispondere in millisecondi — ogni livello di astrazione è un costo.

Query complesse. Tutto ciò che richiede subquery, window functions, CTE, o logica condizionale articolata — scrivi SQL, sarà più leggibile e più performante.

L'alternativa pratica: query builder + raw SQL + monitoring

Ecco cosa consiglio a chi vuole uscire dalla trappola dell'ORM senza tornare al 2005.

Usa il query builder per le letture. DB::table() in Laravel, DBAL in Symfony. Hai i prepared statements, la composizione fluente, e zero overhead di hydration. Il codice è leggibile e sicuro.

Usa raw SQL per le query complesse. Non c'è niente di sbagliato in una query SQL ben scritta. È il linguaggio progettato per interrogare database. Usalo.

Tieni l'ORM per le scritture semplici. Creare e aggiornare record, gestire le relazioni base — qui l'ORM fa risparmiare tempo senza costi significativi.

Monitora le query. Laravel Debugbar, Clockwork, Doctrine Profiler — qualunque strumento, basta che lo usi. Se non sai quante query fa una pagina, non sai se hai un problema. La regola: se una pagina fa più di 10 query, probabilmente c'è da ottimizzare.

Misura prima di ottimizzare. Non riscrivere tutto in raw SQL per principio. Identifica i colli di bottiglia con dati reali, poi intervieni dove il guadagno è misurabile.

Il database merita rispetto, non un'astrazione

SQL esiste da 50 anni per un motivo: è un linguaggio espressivo, potente, e progettato specificamente per lavorare con i dati. Costruirci sopra strati di astrazione che lo nascondono non è progresso — è negazione.

L'ORM ha reso lo sviluppo più veloce nella fase iniziale. Ma ha anche creato una generazione di sviluppatori che non sa scrivere un JOIN, non sa leggere un query plan, e non sa spiegare perché la pagina ci mette 4 secondi a caricare.

Il tuo database è probabilmente il componente più sottovalutato del tuo stack. Inizia a trattarlo come il pezzo critico che è, non come un dettaglio da nascondere dietro un model.


Se il tuo prodotto ha problemi di performance e sospetti che il database sia il collo di bottiglia, parliamone. Un'analisi delle query spesso rivela miglioramenti drastici con interventi mirati.

Simone Giusti

Full Stack Developer specializzato in Laravel e Vue.js

Continua a leggere

Hai un progetto in mente?

Raccontami il tuo progetto e vediamo come posso aiutarti.

Parliamone