---
title: 'Eloquent e grandi dataset: chunk, lazy e cursor | DevSense'
description: 'Come elaborare ed esportare centinaia di migliaia di righe in Laravel senza esaurire la memoria: N+1 e preventLazyLoading, chunk contro chunkById, lazy e cursor, buffering di PDO, streaming CSV, Query Builder e SQL grezzo per i report.'
faq:
    - { question: "Che differenza c'è tra chunk() e cursor() in Laravel?", answer: "chunk() esegue molte query con LIMIT e OFFSET e passa alla closure una collection di N model, quindi supporta l'eager loading. cursor() esegue una sola query e crea i model uno alla volta tramite un generator, ma non supporta l'eager loading, e il driver PDO per impostazione predefinita tiene comunque in memoria l'intero risultato grezzo della query." }
    - { question: 'Perché chunk() salta dei record se li aggiorno dentro il ciclo?', answer: "chunk() scorre il risultato tramite OFFSET. Se il ciclo modifica la colonna su cui filtra la query (per esempio processed = false → true), le righe elaborate escono dalla selezione e l'OFFSET successivo salta quelle non ancora elaborate. chunkById() scorre per chiave primaria (WHERE id > ultimo) e non ha questo problema." }
    - { question: 'Perché cursor() esaurisce comunque la memoria?', answer: "I model vengono creati uno alla volta, ma PDO per impostazione predefinita riceve dal server l'intero risultato della query e lo conserva in un buffer lato client: funzionano così sia pdo_mysql (query bufferizzate) sia pdo_pgsql. Su milioni di righe questo buffer non sta nel memory_limit. Per questi volumi usate lazyById() o un cursore lato server del database." }
    - { question: "Quando conviene rinunciare a Eloquent in favore del Query Builder o dell'SQL grezzo?", answer: 'Quando i model non servono: aggregati e report (GROUP BY, window function), UPDATE massivi e INSERT ... SELECT, export di dati piatti. Eloquent crea un oggetto per ogni riga, applica cast ed eventi: su centinaia di migliaia di righe sono secondi e centinaia di megabyte sprecati. Tenete presente che nelle operazioni massive gli eventi dei model e gli observer non vengono invocati.' }
published: '2026-10-03'
---
# Eloquent e grandi dataset: N+1, chunk, lazy e cursor senza esaurire la memoria

Il report «tutti gli ordini dell'anno in CSV» funziona in staging, dove gli ordini sono cinquemila, e in produzione si schianta con `Allowed memory size of 536870912 bytes exhausted`. Il comando notturno di ricalcolo dei bonus elabora metà dei clienti e termina in silenzio: il giorno dopo si scopre che un cliente su due è stato saltato. La pagina con l'elenco di cinquanta articoli esegue duecento query sul database.

Tutte e tre le storie riguardano la stessa competenza: capire cosa fa Eloquent con il database e la memoria, e scegliere lo strumento giusto in base al volume dei dati. In questo articolo: N+1, la differenza tra `chunk`, `chunkById`, `lazy` e `cursor`, perché `cursor` non salva dall'esaurimento della memoria, come fare lo streaming di un export da un milione di righe e quando è più onesto scrivere SQL.

**Materiali correlati:** [Ottimizzazione delle query](database-query-optimization) · [Indici dei database](database-indexes-deep-dive) · [PostgreSQL per le dashboard](postgresql-for-dashboards) · [Code di Laravel in produzione](laravel-queues-production)

## Indice

* [Da dove nasce l'esaurimento della memoria](#why-memory)
* [N+1: centinaia di query invece di due](#n-plus-one)
* [Mappa dei metodi per grandi selezioni](#methods)
* [chunk() e la trappola dell'OFFSET](#chunk)
* [lazy() e lazyById(): un flusso invece di blocchi](#lazy)
* [cursor() e il buffering di PDO](#cursor)
* [Export CSV da un milione di righe](#export)
* [Quando servono il Query Builder o l'SQL grezzo](#query-builder)
* [Come misurare](#measure)
* [Errori comuni](#common-mistakes)
* [Checklist](#checklist)
* [Quiz di autovalutazione](#self-test-quiz)

---

<a id="why-memory"></a>
## Da dove nasce l'esaurimento della memoria

`Order::where('year', 2026)->get()` fa tre cose:

1. Esegue la query e porta **tutte le righe** del risultato nella memoria di PHP.
2. Crea **un oggetto model per ogni riga**: attributi, copia dei valori originali per il tracciamento delle modifiche, cast, relazioni.
3. Mette i model in una **collection**, che vive finché vive la variabile.

Un model Eloquent occupa in memoria molte volte più spazio della riga della tabella. Diecimila model di solito non sono un problema, un milione è la garanzia di sforare il `memory_limit`. Per questo, con grandi volumi, l'obiettivo è uno solo: **non tenere mai in memoria l'intero risultato in una volta**, né come model né come righe grezze.

---

<a id="n-plus-one"></a>
## N+1: centinaia di query invece di due

Il classico: un elenco di articoli con i nomi degli autori.

```php
$articles = Article::latest()->take(50)->get();

foreach ($articles as $article) {
    echo $article->author->name; // one extra query per article
}
```

Una query per gli articoli più una query per l'autore di ciascun articolo: 51 query. Aggiungete i tag e il numero di commenti, e la pagina fa duecento accessi al database. La soluzione è l'**eager loading**:

```php
$articles = Article::latest()
    ->with(['author:id,name', 'tags'])
    ->withCount('comments')
    ->take(50)
    ->get();
```

Ora le query sono quattro: articoli, autori, tag (con la tabella pivot) e conteggio dei commenti tramite subquery. `author:id,name` carica solo le colonne necessarie: la chiave `id` va indicata obbligatoriamente.

Perché l'N+1 non ritorni di nascosto, vietate il lazy loading fuori dalla produzione:

```php
// app/Providers/AppServiceProvider.php
use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    Model::preventLazyLoading(! $this->app->isProduction());
}
```

In sviluppo e nei test l'accesso a una relazione non caricata lancerà un'eccezione, mentre in produzione il codice continuerà a funzionare. Le versioni recenti di Laravel offrono anche l'approccio opposto, `Model::automaticallyEagerLoadRelationships()`: al primo accesso a una relazione, questa viene caricata subito per tutti i model della collection. È una comoda rete di sicurezza, ma un `with()` esplicito resta più chiaro: dal codice si vede quali dati servono alla pagina.

---

<a id="methods"></a>
## Mappa dei metodi per grandi selezioni

| Metodo | Query | In memoria contemporaneamente | Eager loading | Sicuro se cambia il filtro |
|-------|----------|-----------------------|---------------|---------------------------------|
| `get()` | 1 | Tutti i model | Sì | — |
| `chunk(N)` | Molte (`LIMIT/OFFSET`) | N model | Sì | **No** |
| `chunkById(N)` | Molte (`WHERE id > ?`) | N model | Sì | Sì |
| `lazy(N)` | Molte (`LIMIT/OFFSET`) | N model, restituiti uno alla volta | Sì | **No** |
| `lazyById(N)` | Molte (`WHERE id > ?`) | N model, restituiti uno alla volta | Sì | Sì |
| `cursor()` | 1 | 1 model + l'intero risultato grezzo nel buffer di PDO | **No** | Sì |

La regola predefinita per job in background e comandi: **`lazyById()`** o **`chunkById()`**. Limitano la memoria, supportano `with()` e non saltano record.

---

<a id="chunk"></a>
## chunk() e la trappola dell'OFFSET

`chunk()` divide la selezione in pagine tramite `LIMIT` e `OFFSET`:

```php
Customer::where('bonus_recalculated', false)
    ->chunk(1000, function (Collection $customers) {
        foreach ($customers as $customer) {
            $customer->recalculateBonus(); // sets bonus_recalculated = true
        }
    });
```

Questo codice elaborerà circa metà dei clienti. Il primo blocco sono le righe 1–1000; dopo l'elaborazione non soddisfano più `bonus_recalculated = false`. La seconda query chiede `OFFSET 1000`, ma la selezione si è già spostata di mille righe, e i clienti 1001–2000 vengono saltati. Nessun errore: il comando termina con successo.

`chunkById()` scorre per chiave primaria: ogni query successiva è `WHERE id > :last_id ORDER BY id LIMIT 1000`. Lo spostamento della selezione non lo tocca:

```php
Customer::where('bonus_recalculated', false)
    ->chunkById(1000, function (Collection $customers) {
        foreach ($customers as $customer) {
            $customer->recalculateBonus();
        }
    });
```

L'`OFFSET` ha anche un secondo problema: le prestazioni. Per restituire `OFFSET 900000 LIMIT 1000` il database deve comunque leggere e scartare 900 000 righe. Gli ultimi blocchi impiegano molte volte più tempo dei primi. La paginazione per chiave (keyset) usa l'indice della chiave primaria e costa lo stesso a qualsiasi profondità.

> [!WARNING]
> **Raggruppate le condizioni con `orWhere`.** `chunkById()` e `lazyById()` aggiungono una propria condizione `id > ?`. Se la vostra query contiene `orWhere`, senza parentesi si ottiene `a = 1 OR b = 2 AND id > 100`, e la paginazione si rompe. Racchiudete le vostre condizioni in una closure:
>
> ```php
> Customer::where(function ($query) {
>     $query->where('tier', 'gold')->orWhere('lifetime_cents', '>', 1_000_000);
> })->chunkById(1000, fn (Collection $customers) => /* ... */);
> ```

---

<a id="lazy"></a>
## lazy() e lazyById(): un flusso invece di blocchi

Internamente `lazy()` fa la stessa cosa di `chunk()`, ma restituisce una `LazyCollection`: un flusso di model che si può percorrere con un normale `foreach` e su cui si possono applicare i metodi delle collection:

```php
Customer::where('newsletter', true)
    ->with('subscription')
    ->lazyById(1000)
    ->filter(fn (Customer $customer) => $customer->subscription?->isActive())
    ->each(fn (Customer $customer) => SendDigest::dispatch($customer->id));
```

I metodi di `LazyCollection` vengono eseguiti in modo lazy: `filter` ed `each` elaborano i model man mano che arrivano, e in memoria c'è solo il blocco corrente di 1000 elementi. `with()` funziona: le relazioni vengono caricate per ogni blocco con una query separata. Per scorrere in ordine inverso c'è `lazyByIdDesc()`.

Il codice con `lazyById()` si legge come un normale ciclo, quindi nei nuovi task è più comodo da usare di `chunkById()` con una closure.

---

<a id="cursor"></a>
## cursor() e il buffering di PDO

`cursor()` esegue **una sola** query e, tramite un generator, crea i model uno alla volta:

```php
foreach (Order::where('status', 'paid')->cursor() as $order) {
    // only one Order model is hydrated at a time
}
```

Sembra perfetto, ma ci sono due limitazioni.

**Niente eager loading.** In memoria c'è un solo model, quindi `with()` non viene applicato. Accedere a `$order->customer` dentro il ciclo significa di nuovo N+1, per giunta sull'intera selezione.

**L'intero risultato grezzo resta comunque in memoria.** Per impostazione predefinita PDO preleva dal server tutto il risultato della query e lo conserva in un buffer lato client:

* **pdo_mysql** lavora in modalità query bufferizzate (`PDO::MYSQL_ATTR_USE_BUFFERED_QUERY = true`);
* **pdo_pgsql** riceve da libpq l'intero risultato della query in un colpo solo.

I model vengono creati uno alla volta, ma l'array di righe grezze per un milione di record sta nella memoria del processo, e prima o poi sbatte contro il `memory_limit`. La documentazione di Laravel raccomanda esplicitamente `lazy()` al posto di `cursor()` per volumi molto grandi.

Se serve proprio un unico passaggio senza paginazione, ci sono due modi onesti per fare streaming.

**MySQL: query non bufferizzata su una connessione dedicata.**

```php
// config/database.php — a dedicated connection for exports
'mysql_unbuffered' => array_merge(config('database.connections.mysql'), [
    'options' => [PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => false],
]),
```

Finché il risultato non è stato letto fino in fondo, la connessione è occupata: non si può eseguire un'altra query su di essa. Per questo una connessione del genere si usa solo per leggere il flusso, mentre le scritture passano dalla connessione principale.

**PostgreSQL: cursore lato server.**

```php
DB::transaction(function () {
    DB::statement(
        'DECLARE export_cursor NO SCROLL CURSOR FOR
         SELECT id, customer_id, total_cents, created_at FROM orders WHERE created_at >= ?',
        [now()->startOfYear()],
    );

    while ($rows = DB::select('FETCH 5000 FROM export_cursor')) {
        foreach ($rows as $row) {
            // stream $row somewhere
        }
    }
});
```

Il cursore vive sul server e PHP riceve i dati a porzioni di 5000 righe. Un cursore senza `WITH HOLD` esiste solo all'interno della transazione. Tenerla aperta per ore non è una buona idea: una transazione lunga impedisce a VACUUM di ripulire le vecchie versioni delle righe.

In pratica `lazyById()` copre il 95% dei casi, mentre il cursore lato server serve quando l'ordinamento non è per chiave primaria o la query è complessa e costosa da rieseguire per ogni blocco.

---

<a id="export"></a>
## Export CSV da un milione di righe

Un tipico export che non esaurisce la memoria si basa su tre accorgimenti: selezionare solo le colonne necessarie, non creare model e scrivere il risultato in uno stream, non in una stringa.

```php
// app/Http/Controllers/OrderExportController.php
use Symfony\Component\HttpFoundation\StreamedResponse;

public function __invoke(Request $request): StreamedResponse
{
    $year = (int) $request->validate(['year' => ['required', 'integer', 'min:2020']])['year'];

    return response()->streamDownload(function () use ($year) {
        $out = fopen('php://output', 'w');
        fputcsv($out, ['id', 'customer_id', 'total', 'created_at']);

        DB::table('orders')
            ->select(['id', 'customer_id', 'total_cents', 'created_at'])
            ->whereYear('created_at', $year)
            ->lazyById(5000)
            ->each(function (object $row) use ($out) {
                fputcsv($out, [
                    $row->id,
                    $row->customer_id,
                    number_format($row->total_cents / 100, 2, '.', ''),
                    $row->created_at,
                ]);
            });

        fclose($out);
    }, "orders-{$year}.csv", ['Content-Type' => 'text/csv']);
}
```

* `DB::table()` al posto del model: in uscita ci sono oggetti `stdClass` leggeri, senza cast e senza tracciamento delle modifiche. Per una query costruita su un model, lo stesso risultato si ottiene con `->toBase()`.
* `select()` limita le colonne: `SELECT *` si porta dietro campi `TEXT` che nel CSV non servono.
* `streamDownload()` invia i dati al client man mano che vengono generati: la risposta non viene assemblata in memoria.

> [!NOTE]
> **Un export che dura più di 30 secondi va in coda.** La richiesta HTTP andrà a sbattere contro i timeout di PHP-FPM, nginx o del load balancer. Un export di grandi dimensioni è meglio generarlo in un job in coda, scrivendolo su un file su disco o su S3, e inviare all'utente un link. I dettagli su timeout e memoria dei worker sono nell'articolo sulle [code in produzione](laravel-queues-production#heavy-jobs).

Fate attenzione a `whereYear()`: una funzione applicata alla colonna impedisce di usare l'indice su `created_at`. Per tabelle grandi è più affidabile un intervallo, `whereBetween('created_at', [$from, $to])` (maggiori dettagli nell'articolo sugli [indici](database-indexes-deep-dive)).

---

<a id="query-builder"></a>
## Quando servono il Query Builder o l'SQL grezzo

Eloquent è comodo quando servono i model: logica di business, relazioni, eventi. Per lavorare sui dati «in blocco» spesso è di troppo.

**Calcolate gli aggregati nel database, non in PHP.**

```php
// Bad: loads every order into PHP to sum one column
$total = Order::where('status', 'paid')->get()->sum('total_cents');

// Good: the database returns one number
$total = Order::where('status', 'paid')->sum('total_cents');

// Reports: grouping and window functions belong in SQL
$daily = DB::table('orders')
    ->selectRaw('date(created_at) as day, count(*) as orders, sum(total_cents) as revenue')
    ->where('created_at', '>=', now()->subDays(30))
    ->groupByRaw('date(created_at)')
    ->orderBy('day')
    ->get();
```

**Modifiche massive con una sola query.**

```php
// One UPDATE instead of loading and saving 200 000 models
Order::where('status', 'pending')
    ->where('created_at', '<', now()->subDays(30))
    ->update(['status' => 'expired']);

// Batch upsert for imports: one statement per batch of rows
DB::table('product_prices')->upsert(
    $rows,                       // array of ['sku' => ..., 'price_cents' => ..., 'updated_at' => ...]
    uniqueBy: ['sku'],
    update: ['price_cents', 'updated_at'],
);
```

Un `update()` massivo tramite il builder di Eloquent imposta `updated_at`, ma **non invoca gli eventi dei model né gli observer**, non applica i mutator e non controlla `$fillable`. Se sull'evento `updated` è appesa della logica (svuotamento della cache, audit), va eseguita esplicitamente.

**Query complesse in SQL onesto con binding dei parametri.** `INSERT ... SELECT`, CTE e window function tramite `DB::select()` o `selectRaw()` si leggono meglio di una catena di venti metodi del builder. La regola principale: i valori passano solo tramite placeholder:

```php
// Safe: values are bound, never concatenated into SQL
$rows = DB::select(
    'SELECT customer_id, sum(total_cents) AS spent
     FROM orders WHERE created_at >= ? GROUP BY customer_id HAVING sum(total_cents) > ?',
    [$from, 100_000],
);
```

Inserire l'input dell'utente in una stringa SQL è la strada maestra verso la SQL injection, anche in un report «interno» (maggiori dettagli nell'articolo sugli [attacchi web](web-attacks-and-prevention)).

---

<a id="measure"></a>
## Come misurare

Non tirate a indovinare: misurate su un volume di dati realistico:

```php
$start = hrtime(true);
DB::enableQueryLog();

// ... code under test ...

logger()->info('export stats', [
    'queries' => count(DB::getQueryLog()),
    'peak_mb' => round(memory_get_peak_usage(true) / 1024 / 1024, 1),
    'ms' => (int) ((hrtime(true) - $start) / 1_000_000),
]);
```

* `memory_get_peak_usage(true)` mostra il picco, non il valore corrente: è proprio il picco a sbattere contro il `memory_limit`.
* Attivate il query log solo per la durata della misurazione. Nei comandi lunghi diventa esso stesso un leak: ogni query viene salvata in un array. Per lo stesso motivo Telescope e Debugbar, che raccolgono le query, gonfiano la memoria dei worker a lunga esecuzione.
* Per le query rimaste lente dopo aver corretto l'N+1, guardate il piano di esecuzione con `EXPLAIN ANALYZE` (in dettaglio nella [masterclass sull'ottimizzazione delle query](database-query-optimization)).

---

<a id="common-mistakes"></a>
## Errori comuni

**1. `chunk()` modificando una colonna della condizione.**
Metà dei record viene saltata in silenzio. Usate `chunkById()` o `lazyById()`.

**2. `cursor()` come rimedio all'esaurimento della memoria.**
I model vengono creati uno alla volta, ma il risultato grezzo viene bufferizzato da PDO. Su milioni di righe è lo stesso OOM, solo più tardi.

**3. Accedere alle relazioni dentro `cursor()`.**
L'eager loading non funziona, e si ottiene un N+1 sull'intera selezione.

**4. `get()->sum()`, `get()->count()`, `all()->filter()`.**
Aggregati e filtri calcolati in PHP trascinano in memoria l'intera tabella. Calcolate nel database.

**5. `orWhere` senza raggruppamento insieme a `chunkById()`.**
La condizione `id > ?` si incolla al vostro `OR`, e la paginazione si rompe.

**6. `update()` massivo dove servono gli eventi del model.**
Observer ed eventi non vengono invocati: la cache non viene svuotata, l'audit non viene scritto.

**7. Query log attivo in un comando lungo.**
Ogni query si deposita in memoria. Attivate il log solo per le misurazioni.

---

<a id="checklist"></a>
## Checklist

1. Gli elenchi con relazioni vengono caricati tramite `with()` e `withCount()`; `preventLazyLoading()` è attivo fuori dalla produzione.
2. Le selezioni oltre qualche migliaio di righe vengono elaborate con `lazyById()` o `chunkById()`, non con `get()`.
3. `chunk()` e `lazy()` non si usano se il ciclo modifica colonne presenti nella condizione della query.
4. `cursor()` si usa consapevolmente: senza relazioni e conoscendo il buffering di PDO.
5. Gli export selezionano solo le colonne necessarie, lavorano senza model e scrivono su uno stream.
6. Export e import lunghi vengono eseguiti in coda, non in una richiesta HTTP.
7. Aggregati e modifiche massive vengono eseguiti nel database con una sola query.
8. L'SQL grezzo usa esclusivamente il binding dei parametri.
9. Picco di memoria e numero di query sono verificati su un volume di dati realistico.

---

## Conclusione

Eloquent non è lento: fa esattamente ciò che gli è stato chiesto, cioè carica tutto ciò che la query ha restituito e crea un oggetto per ogni riga. I grandi dataset richiedono un'altra domanda: «quanto di tutto questo starà in memoria contemporaneamente?» Per l'elaborazione, `lazyById()`; per i report, aggregati nel database; per gli export, uno stream senza model; per le modifiche massive, una sola query. E lasciate che `chunk()` con una condizione che cambia e `cursor()` su milioni di righe restino nell'elenco degli errori comuni, non nella vostra produzione.

---

<a id="self-test-quiz"></a>
## Quiz di autoverifica

### Domanda 1: Un comando imposta `processed = true` sui record selezionati con la condizione `processed = false`, usando `chunk(500)`. Cosa succede?
- A) Tutti i record verranno elaborati, ma più lentamente che con `chunkById()`.
- B) Circa metà dei record verrà saltata: la selezione si sposta mentre l'`OFFSET` cresce.
- C) Laravel lancerà un'eccezione per modifica concorrente dei dati.

<details>
<summary><b>Mostra la risposta</b></summary>

**Risposta: B**
Dopo l'elaborazione del primo blocco, quelle righe non soddisfano più la condizione, e la pagina successiva con `OFFSET 500` salta i record non ancora elaborati. `chunkById()` scorre per `id > ultimo` e non dipende dallo spostamento della selezione.
</details>

### Domanda 2: Perché `cursor()` può esaurire la memoria su una selezione di cinque milioni di righe, anche se i model vengono creati uno alla volta?
- A) I generator di PHP conservano tutti i valori restituiti in precedenza.
- B) Il driver PDO per impostazione predefinita riceve l'intero risultato della query e lo conserva in un buffer lato client.
- C) `cursor()` carica automaticamente tutte le relazioni dei model.

<details>
<summary><b>Mostra la risposta</b></summary>

**Risposta: B**
Sia pdo_mysql in modalità bufferizzata sia pdo_pgsql prelevano il risultato della query per intero. Per volumi molto grandi si usano `lazyById()`, una connessione MySQL non bufferizzata o un cursore lato server di PostgreSQL.
</details>

### Domanda 3: Bisogna portare 300 000 ordini scaduti allo stato `expired`. Sull'evento `updated` del model `Order` è appeso lo svuotamento della cache. Quale approccio è corretto?
- A) Una sola query `Order::where(...)->update(['status' => 'expired'])`: gli eventi scatteranno automaticamente.
- B) Un unico `update()` massivo seguito da uno svuotamento esplicito della cache, oppure `lazyById()` salvando i model, se per ogni record serve la logica dell'evento.
- C) `Order::where(...)->get()->each->update(...)`: è l'opzione più veloce.

<details>
<summary><b>Mostra la risposta</b></summary>

**Risposta: B**
Un `update()` massivo non invoca gli eventi dei model né gli observer. Se la loro logica serve, la si esegue esplicitamente dopo la query oppure si elaborano i model in streaming con `lazyById()`. L'opzione C carica in memoria tutti i 300 000 model.
</details>