Ridurre query e “peso” dei model in Laravel

Model::all() è spesso il primo metodo Eloquent che si impara: “dammi tutti i record di una tabella”. Funziona, è leggibile, e per dataset piccoli va benissimo. Ma in produzione diventa rapidamente un punto di accumulo di problemi: query più pesanti del necessario, consumo di memoria, tempi di risposta più lunghi e, soprattutto, una catena di query aggiuntive quando si accede a relazioni (il classico N+1).

In questo articolo partiamo da Model::all() e vediamo tecniche pratiche per:

  • ridurre il numero di query SQL eseguite;
  • ridurre la quantità di dati letti dal database;
  • ridurre il costo di idratazione dei Model Eloquent quando non serve;
  • mantenere il codice pulito e misurabile (profilazione e guardrail).

1) Cosa fa davvero Model::all()

Model::all() esegue una singola query SELECT * FROM ..., idrata tutti i record in istanze Eloquent, e restituisce una Collection in memoria. Semplificando:

use App\Models\User;

$users = User::all(); // SELECT * FROM users

Il problema non è “una query”: il problema è quanto carichi e cosa succede dopo. Tre scenari tipici:

  1. Dataset grande: porti in memoria migliaia di righe e colonne che non userai.
  2. Accesso a relazioni: ogni accesso lazy a una relazione può generare query aggiuntive (N+1).
  3. Lettura-only: stai solo mostrando dati, ma stai pagando il costo del Model (cast, accessors, eventi).

2) Primo taglio: non usare *, seleziona solo le colonne necessarie

Il modo più immediato per ridurre tempo e I/O è limitare le colonne.

use App\Models\User;

$users = User::query()
    ->select(['id', 'name', 'email'])
    ->get();

Se ti serve una singola colonna o un valore:

use App\Models\User;

$emails = User::query()->pluck('email');     // array/collection di email
$firstId = User::query()->value('id');      // un singolo valore
$count   = User::query()->count();          // aggregate senza idratare model

Regola pratica: se nel codice non usi una colonna, non leggerla dal database.

3) Ridurre il numero di query: evitare N+1 con eager loading mirato

Il punto dolente è spesso questo: carichi i model e poi, in un loop, accedi a una relazione.

use App\Models\Post;

$posts = Post::all();

foreach ($posts as $post) {
    echo $post->author->name; // ogni accesso può generare una query
}

Qui la query iniziale è una, ma ogni $post->author può diventare un’altra query. La soluzione è caricare le relazioni in anticipo:

use App\Models\Post;

$posts = Post::query()
    ->with(['author:id,name'])   // eager loading + select sulle colonne della relazione
    ->select(['id', 'title', 'author_id'])
    ->get();

foreach ($posts as $post) {
    echo $post->author->name;
}

Eager loading condizionale e “a richiesta”

Se in alcuni casi ti serve la relazione e in altri no, caricala in modo condizionale.

use App\Models\Post;

$query = Post::query()->select(['id','title','author_id']);

if (request()->boolean('include_author')) {
    $query->with('author:id,name');
}

$posts = $query->get();

Se hai già una collection e vuoi evitare duplicazioni:

use App\Models\Post;

$posts = Post::query()->select(['id','title','author_id'])->get();

// Carica solo se manca (utile in codice riusato)
$posts->loadMissing('author:id,name');

4) Ridurre “quanti model”: quando Eloquent è troppo, scendi di livello

Eloquent è comodo, ma idratare migliaia di istanze Model ha un costo. Se stai facendo una lettura semplice (liste, export, statistiche), puoi ridurre drasticamente il “peso” passando a strutture più leggere.

Query Builder con DB::table()

use Illuminate\Support\Facades\DB;

$rows = DB::table('users')
    ->select(['id','name','email'])
    ->where('active', true)
    ->get(); // restituisce oggetti stdClass, non Model

Base query senza idratazione Eloquent: toBase()

use App\Models\User;

$rows = User::query()
    ->select(['id','name','email'])
    ->toBase()   // ritorna Query Builder "base"
    ->get();

Se ti serve solo un elenco di ID per fare una seconda operazione, pluck evita model e riduce memoria:

use App\Models\Order;

$orderIds = Order::query()
    ->whereDate('created_at', now())
    ->pluck('id');

5) Ridurre memoria e query su grandi dataset: chunk, cursor e lazy

Model::all() carica tutto in una volta. Se devi processare molti record, preferisci iterazione a blocchi.

chunk() (batch in più query, memoria costante)

use App\Models\User;

User::query()
    ->select(['id','email'])
    ->orderBy('id')
    ->chunk(500, function ($users) {
        foreach ($users as $user) {
            // invio email, export, ecc.
        }
    });

cursor() (streaming, una query, iterazione lazy)

use App\Models\User;

foreach (
    User::query()
        ->select(['id','email'])
        ->where('active', true)
        ->orderBy('id')
        ->cursor()
    as $user
) {
    // attenzione: con cursor le relazioni lazy possono diventare N+1
}

lazyById() (streaming per ID, robusto su tabelle che cambiano)

use App\Models\User;

User::query()
    ->select(['id','email'])
    ->where('active', true)
    ->lazyById(500)
    ->each(function ($user) {
        // processamento
    });

Per processing massivo, chunk() e lazyById() evitano out-of-memory e riducono il rischio di saltare record quando la tabella è modificata durante l’elaborazione.

6) Ottimizzare relazioni: conta e aggrega senza caricare model correlati

Spesso ti servono solo conteggi o somme. Caricare l’intera relazione è inutile.

withCount()

use App\Models\User;

$users = User::query()
    ->select(['id','name'])
    ->withCount('posts') // aggiunge posts_count
    ->get();

withSum() e simili

use App\Models\Customer;

$customers = Customer::query()
    ->select(['id','name'])
    ->withSum('orders', 'total') // orders_sum_total
    ->get();

Se devi solo verificare esistenza, usa exists() invece di caricare collezioni:

use App\Models\User;

$hasAdmins = User::query()->where('role', 'admin')->exists();

7) Pagina invece di caricare tutto: paginate() e cursorPaginate()

Se stai mostrando una lista in UI, non esiste quasi mai un buon motivo per all(). Paginare riduce la query, la memoria e stabilizza i tempi di risposta.

use App\Models\Post;

$posts = Post::query()
    ->select(['id','title','published_at'])
    ->orderByDesc('published_at')
    ->paginate(20);

Per dataset grandi dove OFFSET diventa costoso, considera cursorPaginate():

use App\Models\Post;

$posts = Post::query()
    ->select(['id','title','published_at'])
    ->orderByDesc('published_at')
    ->cursorPaginate(20);

8) “Meno model” anche in output: Resource e DTO per evitare accessors costosi

In molte app, i costi non sono solo nel DB ma anche nella serializzazione: accessors, attributi append, cast complessi, e trasformazioni ripetute. Una strategia solida è rendere esplicita la forma dell’output con:

  • API Resources (trasformazione controllata);
  • DTO/ViewModel (oggetti leggeri per la vista);
  • select mirate + map (solo i campi necessari).
use App\Models\User;

$users = User::query()
    ->select(['id','name','email'])
    ->get()
    ->map(fn ($u) => [
        'id' => $u->id,
        'name' => $u->name,
        'email' => $u->email,
    ]);

9) Caching: ridurre query ripetitive e stabilizzare le performance

Se i dati cambiano poco e vengono richiesti spesso (menu, impostazioni, liste “statiche”), la cache riduce query e latenza. In Laravel puoi usare Cache::remember() con TTL o invalidazione esplicita.

use App\Models\Category;
use Illuminate\Support\Facades\Cache;

$categories = Cache::remember('categories:public', 600, function () {
    return Category::query()
        ->select(['id','name','slug'])
        ->orderBy('name')
        ->get();
});

La cache è efficace quando hai “molte letture” e “poche scritture”. Valuta sempre invalidazione e coerenza.

10) Misurare e prevenire regressioni: logging query e guardrail contro N+1

Ottimizzare senza misurare è un rischio. Alcune buone pratiche:

  • abilitare il logging delle query in ambiente di sviluppo;
  • usare strumenti come Debugbar/Telescope per vedere query e tempi;
  • aggiungere test che falliscono in presenza di N+1 (dove ha senso);
  • rendere “esplicite” le relazioni richieste in ogni endpoint.
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

DB::listen(function ($query) {
    Log::debug('SQL', [
        'sql' => $query->sql,
        'bindings' => $query->bindings,
        'time_ms' => $query->time,
    ]);
});

11) Una checklist rapida: sostituzioni tipiche a Model::all()

Obiettivo Invece di Usa
Limitare colonne Model::all() Model::select([...])->get()
Evitare N+1 loop su relazioni lazy with(), loadMissing(), select sulle relazioni
Solo conteggi/somme caricare relazione withCount(), withSum(), exists()
Liste in UI caricare tutto paginate(), cursorPaginate()
Processing massivo caricare tutto in memoria chunk(), lazyById(), cursor()
Lettura-only leggera model Eloquent DB::table(), toBase(), pluck()
Dati poco variabili query ripetute Cache::remember()

Conclusione

Model::all() è comodo, ma è anche il punto di partenza perfetto per ragionare su performance e qualità: riduci i dati letti (colonne e righe), riduci il numero di query (eager loading e aggregati), e riduci l’uso di Model quando ti serve solo una proiezione leggera dei dati (Query Builder, toBase(), pluck()).

Con queste tecniche puoi mantenere Eloquent dove aggiunge valore e “scendere di livello” dove serve efficienza, ottenendo endpoint più rapidi, prevedibili e facili da far crescere.

Torna su