---
title: 'Eloquent und große Datenmengen: chunk, lazy und cursor | DevSense'
description: 'Wie Sie in Laravel Hunderttausende Zeilen ohne Speichermangel verarbeiten und exportieren: N+1 und preventLazyLoading, chunk vs. chunkById, lazy und cursor, PDO-Pufferung, CSV-Streaming, Query Builder und rohes SQL für Reports.'
faq:
    - { question: 'Worin unterscheidet sich chunk() von cursor() in Laravel?', answer: 'chunk() führt viele Abfragen mit LIMIT und OFFSET aus und übergibt der Closure eine Collection aus N Models, unterstützt also Eager Loading. cursor() führt eine einzige Abfrage aus und erzeugt die Models einzeln über einen Generator, beherrscht aber kein Eager Loading – und der PDO-Treiber hält standardmäßig trotzdem das gesamte rohe Abfrageergebnis im Speicher.' }
    - { question: 'Warum überspringt chunk() Datensätze, wenn man sie innerhalb der Schleife aktualisiert?', answer: 'chunk() blättert per OFFSET durch das Ergebnis. Ändert die Schleife eine Spalte, nach der die Abfrage filtert (etwa processed = false → true), fallen die verarbeiteten Zeilen aus der Ergebnismenge, und der nächste OFFSET springt über unverarbeitete Zeilen hinweg. chunkById() blättert über den Primärschlüssel (WHERE id > letzte) und hat dieses Problem nicht.' }
    - { question: 'Warum führt cursor() trotzdem zu Speichermangel?', answer: 'Die Models werden einzeln erzeugt, aber PDO holt standardmäßig das gesamte Abfrageergebnis vom Server und hält es im Client-Puffer – so arbeiten sowohl pdo_mysql (gepufferte Abfragen) als auch pdo_pgsql. Bei Millionen Zeilen passt dieser Puffer nicht in memory_limit. Für solche Mengen verwenden Sie lazyById() oder einen serverseitigen Datenbank-Cursor.' }
    - { question: 'Wann sollte man Eloquent zugunsten von Query Builder oder rohem SQL aufgeben?', answer: 'Wenn keine Models gebraucht werden: Aggregate und Reports (GROUP BY, Fensterfunktionen), Massen-UPDATEs und INSERT ... SELECT, Export flacher Daten. Eloquent erzeugt pro Zeile ein Objekt, wendet Casts und Events an – bei Hunderttausenden Zeilen kostet das zusätzliche Sekunden und Hunderte Megabyte. Dabei werden Model-Events und Observer bei Massenoperationen nicht ausgelöst.' }
published: '2026-10-03'
---
# Eloquent und große Datenmengen: N+1, chunk, lazy und cursor ohne Speichermangel

Der Report „alle Bestellungen des Jahres als CSV“ funktioniert auf Staging mit fünftausend Bestellungen und stürzt in Produktion mit `Allowed memory size of 536870912 bytes exhausted` ab. Der nächtliche Befehl zur Neuberechnung von Bonuspunkten verarbeitet die Hälfte der Kunden und beendet sich stillschweigend – am nächsten Tag stellt sich heraus, dass jeder zweite Kunde übersprungen wurde. Eine Seite mit einer Liste von fünfzig Artikeln feuert zweihundert Datenbankabfragen ab.

Alle drei Geschichten drehen sich um dieselbe Fähigkeit: zu verstehen, was Eloquent mit Datenbank und Speicher macht, und das passende Werkzeug für die Datenmenge zu wählen. In diesem Artikel geht es um N+1, den Unterschied zwischen `chunk`, `chunkById`, `lazy` und `cursor`, warum `cursor` nicht vor Speichermangel schützt, wie man einen Export mit einer Million Zeilen streamt und wann es ehrlicher ist, SQL zu schreiben.

**Verwandte Leitfäden:** [Abfrageoptimierung](database-query-optimization) · [Datenbankindizes](database-indexes-deep-dive) · [PostgreSQL für Dashboards](postgresql-for-dashboards) · [Laravel-Queues in Produktion](laravel-queues-production)

## Inhalt

* [Woher der Speichermangel kommt](#why-memory)
* [N+1: Hunderte Abfragen statt zwei](#n-plus-one)
* [Methodenübersicht für große Ergebnismengen](#methods)
* [chunk() und die OFFSET-Falle](#chunk)
* [lazy() und lazyById(): ein Stream statt Pakete](#lazy)
* [cursor() und die PDO-Pufferung](#cursor)
* [CSV-Export mit einer Million Zeilen](#export)
* [Wann Query Builder oder rohes SQL nötig ist](#query-builder)
* [Wie man misst](#measure)
* [Häufige Fehler](#common-mistakes)
* [Checkliste](#checklist)
* [Quiz zur Selbstkontrolle](#self-test-quiz)

---

<a id="why-memory"></a>
## Woher der Speichermangel kommt

`Order::where('year', 2026)->get()` macht drei Dinge:

1. Es führt die Abfrage aus und holt **alle Zeilen** des Ergebnisses in den PHP-Speicher.
2. Es erzeugt **pro Zeile ein Model-Objekt**: Attribute, eine Kopie der Originalwerte für das Change-Tracking, Casts, Relationen.
3. Es legt die Models in einer **Collection** ab, die so lange lebt wie die Variable.

Ein Eloquent-Model belegt ein Vielfaches des Speichers der eigentlichen Tabellenzeile. Zehntausend Models sind meist kein Problem, eine Million sprengt garantiert das `memory_limit`. Deshalb gibt es bei großen Mengen nur ein Ziel: **niemals das gesamte Ergebnis auf einmal im Speicher halten** – weder Models noch rohe Zeilen.

---

<a id="n-plus-one"></a>
## N+1: Hunderte Abfragen statt zwei

Der Klassiker: eine Artikelliste mit Autorennamen.

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

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

Eine Abfrage für die Artikel plus je eine Abfrage für den Autor jedes Artikels – 51 Abfragen. Kommen Tags und die Anzahl der Kommentare dazu, greift die Seite zweihundertmal auf die Datenbank zu. Die Lösung ist **Eager Loading**:

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

Jetzt sind es vier Abfragen: Artikel, Autoren, Tags (mit Pivot-Tabelle) und die Kommentarzählung per Subquery. `author:id,name` lädt nur die benötigten Spalten – den Schlüssel `id` müssen Sie unbedingt angeben.

Damit N+1 nicht unbemerkt zurückkehrt, verbieten Sie Lazy Loading außerhalb der Produktion:

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

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

In Entwicklung und Tests wirft der Zugriff auf eine nicht geladene Relation eine Exception, in Produktion läuft der Code weiter. Neuere Laravel-Versionen bieten auch den umgekehrten Ansatz – `Model::automaticallyEagerLoadRelationships()`: Beim ersten Zugriff auf eine Relation wird sie sofort für alle Models der Collection nachgeladen. Das ist ein praktisches Sicherheitsnetz, doch ein explizites `with()` bleibt verständlicher: Am Code sieht man, welche Daten die Seite braucht.

---

<a id="methods"></a>
## Methodenübersicht für große Ergebnismengen

| Methode | Abfragen | Gleichzeitig im Speicher | Eager Loading | Sicher bei Änderung des Filters |
|-------|----------|-----------------------|---------------|---------------------------------|
| `get()` | 1 | Alle Models | Ja | — |
| `chunk(N)` | Viele (`LIMIT/OFFSET`) | N Models | Ja | **Nein** |
| `chunkById(N)` | Viele (`WHERE id > ?`) | N Models | Ja | Ja |
| `lazy(N)` | Viele (`LIMIT/OFFSET`) | N Models, einzeln geliefert | Ja | **Nein** |
| `lazyById(N)` | Viele (`WHERE id > ?`) | N Models, einzeln geliefert | Ja | Ja |
| `cursor()` | 1 | 1 Model + das gesamte rohe Ergebnis im PDO-Puffer | **Nein** | Ja |

Die Standardregel für Hintergrund-Jobs und Befehle: **`lazyById()`** oder **`chunkById()`**. Sie begrenzen den Speicher, unterstützen `with()` und überspringen keine Datensätze.

---

<a id="chunk"></a>
## chunk() und die OFFSET-Falle

`chunk()` teilt die Ergebnismenge per `LIMIT` und `OFFSET` in Seiten auf:

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

Dieser Code verarbeitet ungefähr die Hälfte der Kunden. Das erste Paket sind die Zeilen 1–1000; nach der Verarbeitung erfüllen sie `bonus_recalculated = false` nicht mehr. Die zweite Abfrage verlangt `OFFSET 1000` – die Ergebnismenge hat sich aber bereits um tausend Zeilen verschoben, und die Kunden 1001–2000 werden übersprungen. Es gibt keinen Fehler, der Befehl endet erfolgreich.

`chunkById()` blättert über den Primärschlüssel: Jede folgende Abfrage lautet `WHERE id > :last_id ORDER BY id LIMIT 1000`. Eine verschobene Ergebnismenge stört ihn nicht:

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

`OFFSET` hat noch ein zweites Problem – die Performance. Um `OFFSET 900000 LIMIT 1000` zu liefern, liest die Datenbank trotzdem 900 000 Zeilen und verwirft sie. Die letzten Pakete dauern um ein Vielfaches länger als die ersten. Keyset-Pagination nutzt den Index des Primärschlüssels und kostet in jeder Tiefe gleich viel.

> [!WARNING]
> **Gruppieren Sie Bedingungen mit `orWhere`.** `chunkById()` und `lazyById()` fügen ihre eigene Bedingung `id > ?` hinzu. Enthält Ihre Abfrage `orWhere`, entsteht ohne Klammern `a = 1 OR b = 2 AND id > 100`, und die Pagination bricht. Packen Sie Ihre Bedingungen in eine 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() und lazyById(): ein Stream statt Pakete

`lazy()` macht intern dasselbe wie `chunk()`, gibt aber eine `LazyCollection` zurück – einen Stream von Models, den man mit einem normalen `foreach` durchlaufen und auf den man Collection-Methoden anwenden kann:

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

Die Methoden der `LazyCollection` werden lazy ausgeführt: `filter` und `each` verarbeiten die Models, sobald sie ankommen, und im Speicher liegt nur das aktuelle Paket mit 1000 Stück. `with()` funktioniert – die Relationen werden für jedes Paket mit einer eigenen Abfrage nachgeladen. Für den Durchlauf in umgekehrter Reihenfolge gibt es `lazyByIdDesc()`.

Code mit `lazyById()` liest sich wie eine normale Schleife, deshalb ist er in neuen Aufgaben angenehmer als `chunkById()` mit Closure.

---

<a id="cursor"></a>
## cursor() und die PDO-Pufferung

`cursor()` führt **eine einzige** Abfrage aus und erzeugt die Models über einen Generator einzeln:

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

Sieht ideal aus, hat aber zwei Einschränkungen.

**Kein Eager Loading.** Im Speicher liegt nur ein Model, deshalb greift `with()` nicht. Ein Zugriff auf `$order->customer` in der Schleife ist wieder N+1, und zwar über die gesamte Ergebnismenge.

**Das gesamte rohe Ergebnis liegt trotzdem im Speicher.** PDO holt das Abfrageergebnis standardmäßig komplett vom Server und hält es im Client-Puffer:

* **pdo_mysql** arbeitet im Modus gepufferter Abfragen (`PDO::MYSQL_ATTR_USE_BUFFERED_QUERY = true`);
* **pdo_pgsql** erhält von libpq das gesamte Abfrageergebnis auf einmal.

Die Models werden einzeln erzeugt, aber das Array der rohen Zeilen für eine Million Datensätze liegt im Prozessspeicher – und stößt früher oder später an das `memory_limit`. Die Laravel-Dokumentation empfiehlt für sehr große Mengen ausdrücklich `lazy()` statt `cursor()`.

Wenn wirklich ein einziger Durchlauf ohne Pagination nötig ist, gibt es zwei ehrliche Wege zum Streaming.

**MySQL: ungepufferte Abfrage über eine separate Connection.**

```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],
]),
```

Solange das Ergebnis nicht vollständig gelesen ist, ist die Connection belegt: Eine weitere Abfrage lässt sich darüber nicht ausführen. Deshalb nutzt man eine solche Connection nur zum Lesen des Streams, Schreibzugriffe laufen über die Haupt-Connection.

**PostgreSQL: serverseitiger Cursor.**

```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
        }
    }
});
```

Der Cursor lebt auf dem Server, und PHP erhält die Daten in Portionen zu 5000 Zeilen. Ein Cursor ohne `WITH HOLD` existiert nur innerhalb einer Transaktion. Diese stundenlang offen zu halten, ist keine gute Idee: Eine lange Transaktion hindert VACUUM daran, alte Zeilenversionen aufzuräumen.

In der Praxis deckt `lazyById()` 95 % der Aufgaben ab; ein serverseitiger Cursor ist nötig, wenn nicht nach dem Primärschlüssel sortiert wird oder die Abfrage komplex und teuer ist, sodass man sie nicht für jedes Paket erneut ausführen will.

---

<a id="export"></a>
## CSV-Export mit einer Million Zeilen

Ein typischer Export ohne Speichermangel besteht aus drei Kniffen: nur die benötigten Spalten auswählen, keine Models erzeugen und das Ergebnis in einen Stream statt in einen String schreiben.

```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()` statt eines Models – heraus kommen leichtgewichtige `stdClass`-Objekte ohne Casts und Change-Tracking. Bei einer auf einem Model aufgebauten Abfrage erreicht man dasselbe mit `->toBase()`.
* `select()` begrenzt die Spalten: `SELECT *` zieht `TEXT`-Felder mit, die im CSV nicht gebraucht werden.
* `streamDownload()` sendet die Daten an den Client, während sie erzeugt werden; die Antwort wird nicht im Speicher zusammengebaut.

> [!NOTE]
> **Exporte, die länger als 30 Sekunden dauern, gehören in die Queue.** Ein HTTP-Request läuft in die Timeouts von PHP-FPM, nginx oder Load Balancer. Einen großen Export erzeugt man besser in einem Queue-Job als Datei auf der Festplatte oder in S3 und schickt dem Nutzer einen Link. Details zu Timeouts und zum Speicher der Worker finden Sie im Artikel über [Queues in Produktion](laravel-queues-production#heavy-jobs).

Achten Sie auf `whereYear()`: Eine Funktion über der Spalte verhindert die Nutzung des Index auf `created_at`. Bei großen Tabellen ist ein Bereich zuverlässiger – `whereBetween('created_at', [$from, $to])` (mehr dazu im Artikel über [Indizes](database-indexes-deep-dive)).

---

<a id="query-builder"></a>
## Wann Query Builder oder rohes SQL nötig ist

Eloquent ist praktisch, wenn man Models braucht: Geschäftslogik, Relationen, Events. Für die Arbeit mit Daten „en bloc“ ist es oft überflüssig.

**Aggregate in der Datenbank berechnen, nicht 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();
```

**Massenänderungen mit einer einzigen Abfrage.**

```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'],
);
```

Ein Massen-`update()` über den Eloquent-Builder setzt `updated_at`, **löst aber keine Model-Events und Observer aus**, wendet keine Mutatoren an und prüft `$fillable` nicht. Hängt am Event `updated` Logik (Cache leeren, Audit), muss sie explizit ausgeführt werden.

**Komplexe Abfragen als ehrliches SQL mit Parameterbindung.** `INSERT ... SELECT`, CTEs und Fensterfunktionen über `DB::select()` oder `selectRaw()` lesen sich besser als eine Kette aus zwanzig Builder-Methoden. Die wichtigste Regel: Werte nur über Platzhalter.

```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],
);
```

Benutzereingaben in einen SQL-String einzusetzen, ist der direkte Weg zur SQL-Injection, auch in einem „internen“ Report (mehr dazu im Artikel über [Web-Angriffe](web-attacks-and-prevention)).

---

<a id="measure"></a>
## Wie man misst

Nicht raten – messen, und zwar mit einer realistischen Datenmenge:

```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)` zeigt den Spitzenwert, nicht den aktuellen Wert – und genau die Spitze stößt an das `memory_limit`.
* Aktivieren Sie das Query-Log nur für die Dauer der Messung. In lang laufenden Befehlen wird es selbst zum Speicherleck: Jede Abfrage wird in einem Array abgelegt. Aus demselben Grund blähen Telescope und Debugbar, die Abfragen sammeln, den Speicher lang laufender Worker auf.
* Bei Abfragen, die nach der Behebung von N+1 langsam geblieben sind, schauen Sie sich den Ausführungsplan an – `EXPLAIN ANALYZE` (ausführlich in der [Masterclass zur Abfrageoptimierung](database-query-optimization)).

---

<a id="common-mistakes"></a>
## Häufige Fehler

**1. `chunk()` mit Änderung einer Spalte aus der Bedingung.**
Die Hälfte der Datensätze wird stillschweigend übersprungen. Verwenden Sie `chunkById()` oder `lazyById()`.

**2. `cursor()` als Heilmittel gegen Speichermangel.**
Die Models werden einzeln erzeugt, aber das rohe Ergebnis puffert PDO. Bei Millionen Zeilen – derselbe OOM, nur später.

**3. Zugriff auf Relationen innerhalb von `cursor()`.**
Eager Loading funktioniert nicht, es entsteht N+1 über die gesamte Ergebnismenge.

**4. `get()->sum()`, `get()->count()`, `all()->filter()`.**
In PHP berechnete Aggregate und Filter ziehen die ganze Tabelle in den Speicher. Rechnen Sie in der Datenbank.

**5. `orWhere` ohne Gruppierung zusammen mit `chunkById()`.**
Die Bedingung `id > ?` wird mit Ihrem `OR` verknüpft, und die Pagination bricht.

**6. Massen-`update()`, wo Model-Events nötig sind.**
Observer und Events werden nicht ausgelöst – der Cache wird nicht geleert, das Audit nicht geschrieben.

**7. Aktiviertes Query-Log in einem lang laufenden Befehl.**
Jede Abfrage bleibt im Speicher liegen. Aktivieren Sie das Log nur für Messungen.

---

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

1. Listen mit Relationen werden über `with()` und `withCount()` geladen; `preventLazyLoading()` ist außerhalb der Produktion aktiv.
2. Ergebnismengen von mehr als einigen tausend Zeilen werden mit `lazyById()` oder `chunkById()` verarbeitet, nicht mit `get()`.
3. `chunk()` und `lazy()` werden nicht verwendet, wenn die Schleife Spalten aus der Abfragebedingung ändert.
4. `cursor()` wird bewusst eingesetzt: ohne Relationen und im Wissen um die PDO-Pufferung.
5. Exporte wählen nur die benötigten Spalten aus, arbeiten ohne Models und schreiben in einen Stream.
6. Lange Exporte und Importe laufen in der Queue, nicht im HTTP-Request.
7. Aggregate und Massenänderungen werden in der Datenbank mit einer einzigen Abfrage ausgeführt.
8. Rohes SQL verwendet ausschließlich Parameterbindung.
9. Spitzenspeicher und Anzahl der Abfragen sind mit einer realistischen Datenmenge geprüft.

---

## Zusammenfassung

Eloquent ist nicht langsam – es tut genau das, worum man es bittet: Es lädt alles, was die Abfrage zurückgibt, und erzeugt pro Zeile ein Objekt. Große Datenmengen erfordern eine andere Frage: „Wie viel davon liegt gleichzeitig im Speicher?“ Für die Verarbeitung – `lazyById()`, für Reports – Aggregate in der Datenbank, für Exporte – ein Stream ohne Models, für Massenänderungen – eine einzige Abfrage. Und `chunk()` mit veränderlicher Bedingung sowie `cursor()` auf Millionen Zeilen sollen in der Liste häufiger Fehler bleiben, nicht in Ihrer Produktion.

---

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

### Frage 1: Ein Befehl setzt per `chunk(500)` `processed = true` für Datensätze, die nach der Bedingung `processed = false` ausgewählt wurden. Was passiert?
- A) Alle Datensätze werden verarbeitet, aber langsamer als mit `chunkById()`.
- B) Ungefähr die Hälfte der Datensätze wird übersprungen: Die Ergebnismenge verschiebt sich, während `OFFSET` wächst.
- C) Laravel wirft eine Exception wegen nebenläufiger Datenänderung.

<details>
<summary><b>Antworten anzeigen</b></summary>

**Antwort: B**
Nach der Verarbeitung des ersten Pakets erfüllen diese Zeilen die Bedingung nicht mehr, und die nächste Seite mit `OFFSET 500` springt über noch nicht verarbeitete Datensätze hinweg. `chunkById()` blättert über `id > letzte` und hängt nicht von der Verschiebung der Ergebnismenge ab.
</details>

### Frage 2: Warum kann `cursor()` bei einer Ergebnismenge von fünf Millionen Zeilen den Speicher erschöpfen, obwohl die Models einzeln erzeugt werden?
- A) PHP-Generatoren speichern alle zuvor gelieferten Werte.
- B) Der PDO-Treiber holt standardmäßig das gesamte Abfrageergebnis und hält es im Client-Puffer.
- C) `cursor()` lädt automatisch alle Relationen der Models.

<details>
<summary><b>Antworten anzeigen</b></summary>

**Antwort: B**
Sowohl pdo_mysql im gepufferten Modus als auch pdo_pgsql holen das Abfrageergebnis komplett ab. Für sehr große Mengen verwendet man `lazyById()`, eine ungepufferte MySQL-Connection oder einen serverseitigen PostgreSQL-Cursor.
</details>

### Frage 3: 300 000 abgelaufene Bestellungen sollen in den Status `expired` überführt werden. Am Event `updated` des Models `Order` hängt das Leeren des Caches. Welcher Ansatz ist korrekt?
- A) Eine Abfrage `Order::where(...)->update(['status' => 'expired'])` – die Events werden automatisch ausgelöst.
- B) Ein Massen-`update()` und danach explizites Leeren des Caches, oder `lazyById()` mit Speichern der Models, wenn für jeden Datensatz die Event-Logik nötig ist.
- C) `Order::where(...)->get()->each->update(...)` – das ist die schnellste Variante.

<details>
<summary><b>Antworten anzeigen</b></summary>

**Antwort: B**
Ein Massen-`update()` löst keine Model-Events und Observer aus. Wird deren Logik gebraucht, führt man sie nach der Abfrage explizit aus oder verarbeitet die Models als Stream über `lazyById()`. Variante C lädt alle 300 000 Models in den Speicher.
</details>