# Advanced Eloquent: queries

N+1, eager loading, aggregaten, subqueries, grote datasets, indexes en transactions in Laravel.

Multimedia & Creatieve Technologie · 3.1 The Agency · Zelfstudie · versie 1.0.0 · 2026-09-30

Tags: laravel, php, eloquent, database, performance

Slides: <https://slides.mctantwerp.be/nl/the-agency/08-eloquent-queries>

<!-- slide 2 -->

## Agenda

1. Het datamodel
2. N+1
3. Eager loading
4. Tellen en optellen
5. Filteren op relaties
6. Grote datasets
7. Indexes, transactions en upserts
8. Queries zien

> **Toelichting:** De hoofdstukken bouwen op elkaar. N+1 en eager loading zijn de basis, tellen, filteren en grote datasets gebruiken dezelfde ideeën. Indexes en de tools om queries te zien kan je ook los lezen als je ergens een trage pagina moet onderzoeken.

<!-- slide 3 -->

## Na deze les kan je

- Een N+1 herkennen in Blade, in een API resource of in een lus, en tellen hoeveel queries een pagina doet
- Relaties vooraf laden met `with()` en `load()`, ook genest en met een voorwaarde
- Tellen en optellen in de database met `withCount`, `withSum` en een subquery
- Filteren op een relatie met `whereHas`, en uitleggen wanneer een join beter past
- Een tabel van honderdduizend rijen verwerken zonder dat je geheugen volloopt
- De SQL achter je code zichtbaar maken, en een trage query versnellen met een index

> **Toelichting:** Gebruik deze lijst als checklist na de les. Kan je bij elk punt een voorbeeld uit je eigen project geven, dan zit het goed. Het laatste punt is het meest waard: zolang je de queries niet ziet, zie je de andere problemen ook niet.

<!-- slide 4 -->

## 1. Het datamodel

> **Toelichting:** Alle voorbeelden in deze les en in de les over modellen gebruiken hetzelfde datamodel. Zo moet je niet bij elke slide een nieuwe context leren, en zie je hoe de technieken samen in één project zitten.

<!-- slide 5 -->

### Een tool voor een agency

Stel: je bouwt een tool waarmee een agency uren schrijft en factureert, zoals Teamleader of Harvest. Een klant heeft projecten, een project heeft taken, op een taak schrijf je uren, en uren komen op een factuur. Dit model gebruik je in deze les en in de les over modellen.

- **clients**: id, name, vat_number, country
- **projects**: id, client_id, name, status, budget_hours
- **tasks**: id, project_id, title, due_on, done_at
- **time_entries**: id, task_id, user_id, invoice_id, minutes, spent_on
- **invoices**: id, client_id, number, status, total, due_on, paid_at
- **users**: id, name, email

> **Toelichting:** Teamleader, Harvest en Productive zijn tools die agencies echt gebruiken om uren te schrijven, je hoeft ze niet te kennen. Wil je meebouwen, maak dan per tabel een model met php artisan make:model Client -mf. De vlag -mf maakt er meteen een migration en een factory bij.

<!-- slide 6 -->

### De relaties

- *app/Models/Project.php*

  ```php
  public function client(): BelongsTo
  {
      return $this->belongsTo(Client::class);
  }

  public function tasks(): HasMany
  {
      return $this->hasMany(Task::class);
  }
  ```
- *app/Models/Task.php*

  ```php
  public function project(): BelongsTo
  {
      return $this->belongsTo(Project::class);
  }

  public function timeEntries(): HasMany
  {
      return $this->hasMany(TimeEntry::class);
  }
  ```

Lees je een relatie als property, zoals `$project->client`, dan krijg je het resultaat. Roep je de methode aan, zoals `$project->tasks()`, dan krijg je een query waarop je verder kan bouwen. `Client` heeft op dezelfde manier `projects()` en `invoices()`.

> **Toelichting:** Een relatie is een gewone methode die een Relation-object teruggeeft. Laravel maakt de dynamic property zelf: $project->client roept client() aan, voert de query uit en onthoudt het resultaat op dat model. Een tweede keer $project->client lezen doet dus geen nieuwe query op datzelfde model.

<!-- slide 7 -->

## 2. N+1

> **Toelichting:** N+1 is het performanceprobleem dat je het vaakst tegenkomt in Laravel-projecten. Het komt zelden door slordige code, wel door code die werkt met weinig data en traag wordt als de klant zijn tool echt gebruikt. Wie het herkent, lost de meeste trage pagina's op zonder een zwaardere server.

<!-- slide 8 -->

### Het projectoverzicht

#### ProjectController

```php
public function index(): View
{
    $projects = Project::all();

    return view('projects.index', [
        'projects' => $projects,
    ]);
}
```

#### projects/index.blade.php

```html
@foreach ($projects as $project)
    <h3>{{ $project->name }}</h3>
    <p>{{ $project->client->name }}</p>
@endforeach
```

Code die iedereen schrijft, en ze werkt. Met vijf projecten in je seeder merk je niets. Het probleem zit op regel 3.

> **Toelichting:** Project::all() haalt alle projecten op zonder hun relaties. Dat is prima zolang je in de view niets van een relatie leest. Het probleem ontstaat in de combinatie: een lijst in de controller en een relatie in de lus van de view.

<!-- slide 9 -->

### Wat de database ziet

```sql
select * from projects
select * from clients where clients.id = 3 limit 1
select * from clients where clients.id = 10 limit 1
select * from clients where clients.id = 17 limit 1
select * from clients where clients.id = 3 limit 1
... nog 46 keer
```

`$project->client` is een dynamic property. De eerste keer dat je hem leest, doet Eloquent een query voor die ene klant. Bij het volgende project gebeurt dat opnieuw, ook als het dezelfde klant is: klant 3 komt twee keer langs. Dat heet lazy loading, en in een lus geeft het één query per rij.

> **Toelichting:** Eloquent onthoudt een geladen relatie per model, niet per klant. Twee projecten van dezelfde klant zijn twee aparte modellen, dus twee queries. De limit 1 staat er omdat een belongsTo maar één model teruggeeft.

<!-- slide 10 -->

### N+1 in actie

Klik op **Eager loading** en daarna op **500**. Let op de teller: bij lazy loading groeit hij mee met het aantal projecten, bij eager loading blijft hij op 2.

Simulatie van het projectoverzicht met 10, 50 of 500 projecten en de naam van hun klant. Met Project::all() en lazy loading loopt de teller op tot 1 + N queries (51 bij 50 projecten): één select op projects en daarna één select op clients per rij. Met Project::with('client')->get() blijft hij op 2: de projects en één select op clients met where id in (...).

> **Toelichting:** De simulatie praat niet met een echte database, maar de SQL is dezelfde als wat Laravel stuurt, alleen zonder de aanhalingstekens rond de namen. In Debugbar zie je hetzelfde patroon: een lange lijst bijna identieke queries die alleen in het id verschillen. Dat is het herkenningsteken van een N+1.

<!-- slide 11 -->

### Waarom het pijn doet

- Elke query is een roundtrip naar de database: het netwerk over, de query parsen, het resultaat terug. Eén keer is dat niets, vijfhonderd keer niet.
- Lokaal met SQLite merk je het nauwelijks, want de database is een bestand op je laptop. In productie staat hij op een andere server.
- Het groeit met de data. Een overzicht met 20 projecten bij de oplevering heeft er na twee jaar misschien 800, en doet dan 801 queries.
- Een N+1 staat zelden in de controller. Hij zit in Blade, in een API resource of in een accessor, waar je niet meer ziet dat je in een lus zit.

> **Toelichting:** Een query naar een database op dezelfde machine duurt vaak minder dan een milliseconde, over een netwerk al snel een paar milliseconden. Vermenigvuldig dat met 800 en je pagina wacht seconden. Test daarom met een seeder die realistisch veel data maakt, niet met vijf rijen.

<!-- slide 12 -->

### Waar een N+1 zich verstopt

*app/Http/Resources/ProjectResource.php*

```php
public function toArray(Request $request): array
{
    return [
        'name' => $this->name,
        'client' => $this->client->name,
    ];
}
```

`ProjectResource::collection(Project::all())` leest voor elk project de klant: weer een N+1, en in de controller zie je er niets van. Schrijf in een resource liever `$this->whenLoaded('client')`. Dan zit de klant alleen in de JSON als de controller hem geladen heeft, en nooit via een extra query.

> **Toelichting:** whenLoaded geeft een MissingValue terug als de relatie niet geladen is, en een API resource laat die sleutel dan weg uit de JSON. Zo beslist de controller wat er geladen wordt, en volgt de resource. Meer over resources in de les over API development.

<!-- slide 13 -->

### Een pagina toont 30 taken, elk met de naam van de klant van hun project. Hoeveel queries zonder eager loading?

- A. 2
- B. 3
- C. 31
- D. 61

**Antwoord:** D. 61

1 query voor de taken, 30 voor het project van elke taak, en nog eens 30 voor de klant van elk project. 31 is verleidelijk, maar vergeet de tweede stap: ook project->client is lazy loaded, per project. Met with('project.client') zijn het er 3.

> **Toelichting:** Tel bij zulke vragen per stap in de keten: elke pijl in $task->project->client is een relatie die per model geladen wordt. Met with('project.client') laadt Laravel eerst de projecten van alle taken en daarna de klanten van al die projecten, telkens met één where in.

<!-- slide 14 -->

## 3. Eager loading

> **Toelichting:** Eager loading is de standaardoplossing voor een N+1: je zegt vooraf welke relaties je nodig hebt. Het kost één regel in de controller en maakt het aantal queries onafhankelijk van het aantal rijen.

<!-- slide 15 -->

### Eager loading met with()

- *Eloquent*

  ```php
  $projects = Project::with('client')->get();
  ```
- *SQL*

  ```sql
  select * from projects

  select * from clients
  where clients.id in (3, 10, 17, 24, ...)
  ```

Geen join. Eloquent haalt de projecten op, verzamelt alle `client_id`'s, haalt die klanten in één query op en hangt ze in PHP aan het juiste project. Twee queries, of er nu 10 of 5000 projecten zijn. Meer in de docs: [Eager loading](https://laravel.com/docs/13.x/eloquent-relationships#eager-loading).

> [!TIP]
> Alleen een paar kolommen nodig? `with('client:id,name')`. Zet de `id` er altijd bij, anders kan Eloquent niets koppelen.

> **Toelichting:** Omdat Eloquent geen join gebruikt, krijg je gewone modellen terug en blijven kolommen met dezelfde naam netjes gescheiden. Het werkt zelfs voor een relatie naar een andere database connection. Met duizenden ids wordt de where in lang, maar dat is zelden een probleem voor de database.

<!-- slide 16 -->

### Meerdere en geneste relaties

```php
$projects = Project::with([
    'client',
    'tasks.timeEntries',
])->get();
```

```sql
select * from projects
select * from clients where clients.id in (3, 10, 17, ...)
select * from tasks where tasks.project_id in (1, 2, 3, ...)
select * from time_entries where time_entries.task_id in (1, 2, 5, ...)
```

Een array laadt meerdere relaties, een punt laadt de relatie van een relatie. Elke relatie is één query, dus vier queries voor 50 projecten of voor 5000.

> **Toelichting:** Je kan ook een geneste array schrijven: with(['tasks' => ['timeEntries', 'comments']]). Laad alleen wat de pagina echt toont. Elke relatie is een query en elk model kost geheugen, dus een with() met tien relaties voor een pagina die er drie gebruikt, is ook verspilling.

<!-- slide 17 -->

### Eager loading met voorwaarden

```php
$projects = Project::with([
    'tasks' => fn (Builder $query) => $query
        ->whereNull('done_at')
        ->orderBy('due_on'),
])->get();
```

De closure past de query aan die de taken laadt: alleen open taken, de dringendste eerst. `$project->tasks` bevat dan alleen die taken.

> [!WARNING]
> Dit filtert de taken, niet de projecten. Een project zonder open taken staat er nog, met een lege collectie. Wil je alleen projecten met open taken, en meteen die taken erbij? Dan is het `withWhereHas()`.

> **Toelichting:** De Builder in de closure is de query op de tasks-tabel, dus elke where, orderBy of limit werkt erop. Sinds Laravel 11 geldt een limit in die closure per project, in oudere versies gold hij voor de hele eager load samen. Wil je projecten zonder open taken helemaal weglaten, dan heb je withWhereHas nodig.

<!-- slide 18 -->

### load() en loadMissing()

```php
public function show(Project $project): View
{
    $project->load([
        'client',
        'tasks.timeEntries.user',
    ]);

    $project->loadMissing('client');

    return view('projects.show', ['project' => $project]);
}
```

`load()` is eager loading achteraf. Met route model binding is het project er al, en laad je de relaties erbij.

`loadMissing()` laadt alleen wat er nog niet is. Handig in een resource of een action die niet weet wat de controller al deed.

> **Toelichting:** loadMissing kijkt per relatie of ze al geladen is en slaat ze dan over. Zo kan je in een action of een resource veilig loadMissing zetten zonder dubbel werk. Er bestaan ook loadCount en loadSum, om te tellen op modellen die je al hebt.

<!-- slide 19 -->

### Lazy loading verbieden

*app/Providers/AppServiceProvider.php*

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

    Model::handleLazyLoadingViolationUsing(function (Model $model, string $relation) {
        info('Lazy loading', ['model' => $model::class, 'relation' => $relation]);
    });
}
```

Lokaal en in je tests gooit elke lazy load nu een `LazyLoadingViolationException`. Je ziet een N+1 dus voor de klant hem ziet.

In productie wil je geen foutpagina voor de klant. Daar schrijf je de overtreding alleen in de log.

> **Toelichting:** Zet dit in elk nieuw project op dag één. Achteraf aanzetten in een bestaand project geeft vaak tientallen exceptions tegelijk, die je allemaal moet oplossen voor je verder kan. De logregel in productie vertelt je welke relatie op welk model het probleem is.

<!-- slide 20 -->

### preventLazyLoading staat aan. Je haalt één project op met find(1) en leest de naam van zijn klant. Wat gebeurt er?

- A. Een LazyLoadingViolationException
- B. Het werkt, met één extra query
- C. Het geeft null terug
- D. Laravel eager loadt de klant vanzelf

**Antwoord:** B. Het werkt, met één extra query

Laravel zet de check alleen aan op modellen uit een query die meer dan één resultaat gaf. Eén model met een lazy loaded relatie is geen N+1, dat is gewoon één query extra. Wie A kiest, denkt dat elke lazy load verboden is, maar het gaat Laravel om lazy loading in een lijst.

> **Toelichting:** Deze uitzondering is bewust: een detailpagina die één project toont en lazy zijn klant laadt, kost één query extra en dat is geen probleem. Daardoor krijg je met preventLazyLoading geen valse alarmen op show-pagina's, maar wel op elke lijst.

<!-- slide 21 -->

### Automatisch eager loaden

*app/Providers/AppServiceProvider.php*

```php
public function boot(): void
{
    Model::automaticallyEagerLoadRelationships();
}
```

Sinds Laravel 12 kan Eloquent het zelf. Lees je `$project->client` in een lus, dan laadt Laravel de klant voor alle projecten van die collectie in één keer.

> [!NOTE]
> Gebruik het als vangnet in een oud project, niet als vervanger van `with()`. Met `with()` zegt je code welke data een pagina nodig heeft, en dat zie je in een code review.

> **Toelichting:** automaticallyEagerLoadRelationships werkt per collectie: het onthoudt welke modellen samen uit één query kwamen. Een model dat je los ophaalt, zit niet in zo'n collectie, en daar verandert niets. De feature is nieuw, lees dus de docs van jouw Laravel-versie als je hem gebruikt.

<!-- slide 22 -->

### Terug naar de parent

- *nog altijd N+1*

  ```php
  $project->load('tasks');

  foreach ($project->tasks as $task) {
      echo $task->project->name;
  }
  ```
- *app/Models/Project.php*

  ```php
  public function tasks(): HasMany
  {
      return $this->hasMany(Task::class)
          ->chaperone();
  }
  ```

Links zijn de taken netjes geladen, en toch is het een N+1: een taak weet niet vanzelf dat hij bij dat project hoort, dus `$task->project` doet een query. `chaperone()` zet het project op elke taak die via deze relatie geladen wordt.

> **Toelichting:** Hetzelfde probleem bestaat bij polymorphic relaties zoals comments, en daar werkt chaperone() ook. Je kan het ook voor één query aanzetten met with(['tasks' => fn ($tasks) => $tasks->chaperone()]). Zonder chaperone herken je het in Debugbar aan een reeks queries naar projects met telkens hetzelfde id.

<!-- slide 23 -->

## 4. Tellen en optellen

> **Toelichting:** Een overzicht toont zelden alleen namen. Aantallen, totalen en laatste datums zijn waar een klant naar kijkt, en precies die schrijf je snel verkeerd met een lus in PHP. De database is gemaakt om te tellen en op te tellen, laat hem dat doen.

<!-- slide 24 -->

### withCount()

- *Eloquent*

  ```php
  $projects = Project::withCount('tasks')->get();

  $projects->first()->tasks_count;
  ```
- *SQL*

  ```sql
  select projects.*, (
      select count(*) from tasks
      where projects.id = tasks.project_id
  ) as tasks_count
  from projects
  ```

`withCount` zet een subquery in de select. Je krijgt de projecten terug met een extra attribuut `tasks_count`, in één query, en er wordt geen enkele taak als model geladen. Docs: [Aggregating related models](https://laravel.com/docs/13.x/eloquent-relationships#aggregating-related-models).

> **Toelichting:** Combineer je withCount met een eigen select, zet withCount dan na de select. Anders overschrijft je select de kolom met het aantal. Wil je het aantal later bijladen op modellen die je al hebt, gebruik dan loadCount('tasks').

<!-- slide 25 -->

### Drie keer tellen

| Code | Wat er gebeurt | Bij 50 projecten |
| --- | --- | --- |
| `$project->tasks->count()` | Laadt elke taak als model, en telt in PHP | 51 queries, duizenden modellen |
| `$project->tasks()->count()` | Een count-query per project | 51 queries |
| `withCount('tasks')` | Een subquery in de select | 1 query |

Het verschil zit in de haakjes. Zonder haakjes laad je de relatie en tel je een collectie. Met haakjes stuur je een `count(*)` naar de database, correct voor één project, maar in een lus opnieuw een N+1.

> **Toelichting:** De eerste variant is soms wel juist: heb je de taken toch al eager geladen omdat je ze toont, dan is count() op de collectie gratis. Het probleem is iets laden alleen om het te tellen.

<!-- slide 26 -->

### withSum() en withExists()

```php
$tasks = Task::withSum('timeEntries', 'minutes')->get();
$tasks->first()->time_entries_sum_minutes;

$clients = Client::withExists('invoices')->get();
$clients->first()->invoices_exists;
```

De som van een kolom van de relatie, per taak. De naam volgt het patroon relatie, functie, kolom. Ook `withMin`, `withMax` en `withAvg` bestaan.

`withExists` geeft een boolean: heeft deze klant minstens één factuur? Goedkoper dan tellen als je het aantal niet nodig hebt.

> **Toelichting:** Een som over een relatie zonder rijen geeft null, geen 0. Toon je het getal, schrijf dan $task->time_entries_sum_minutes ?? 0. Met as kies je een kortere naam: withSum('timeEntries as minutes_spent', 'minutes').

<!-- slide 27 -->

### Tellen met een voorwaarde

```php
$projects = Project::withCount([
    'tasks',
    'tasks as open_tasks_count' => fn (Builder $query) => $query->whereNull('done_at'),
])->get();
```

Met `as` geef je het resultaat een eigen naam, zodat je dezelfde relatie twee keer kan tellen: alle taken en de open taken. De closure werkt zoals bij eager loading met een voorwaarde, en past alleen de subquery aan.

> **Toelichting:** Met een alias kies je de volledige naam, Laravel plakt er niets meer achter. Twee keer dezelfde relatie tellen zonder alias gaat niet, want dan zouden beide tasks_count heten. Hetzelfde werkt voor withSum en withExists.

<!-- slide 28 -->

### Een subquery als kolom

- ```php
  $clients = Client::addSelect([
      'last_invoiced_at' => Invoice::select('created_at')
          ->whereColumn('client_id', 'clients.id')
          ->latest()
          ->limit(1),
  ])
      ->withCasts(['last_invoiced_at' => 'datetime'])
      ->get();
  ```
- ```sql
  select clients.*, (
      select created_at from invoices
      where client_id = clients.id
      order by created_at desc
      limit 1
  ) as last_invoiced_at
  from clients
  ```

De datum van de laatste factuur per klant, in één query. `whereColumn` koppelt de subquery aan de klant van de rij, en `withCasts` maakt er een Carbon-datum van in plaats van een string.

> **Toelichting:** whereColumn vergelijkt twee kolommen, geen kolom met een waarde. Daardoor verwijst de subquery naar de klant van de rij waarin ze zit, dat heet een gecorreleerde subquery. Zonder withCasts krijg je een string terug, want Eloquent weet niet dat een kolom die niet in de tabel staat een datum is.

<!-- slide 29 -->

### Sorteren op een subquery

```php
$clients = Client::orderByDesc(
    Invoice::select('created_at')
        ->whereColumn('client_id', 'clients.id')
        ->latest()
        ->limit(1)
)->get();
```

Dezelfde subquery kan ook in `orderBy`: de klanten met de recentste factuur bovenaan. Zonder subquery zou je alle facturen laden en in PHP sorteren. Docs: [Advanced subqueries](https://laravel.com/docs/13.x/eloquent#advanced-subqueries).

> **Toelichting:** Een subquery in orderBy wordt per klant uitgevoerd, dus een index op invoices.client_id en created_at helpt veel. Met paginate() werkt dit gewoon, want het blijft één query met een order by.

<!-- slide 30 -->

### Je wil per project het aantal open taken tonen. Wat kies je?

- A. Filteren en tellen op de geladen taken in de Blade
- B. Een count-query per project in de Blade
- C. withCount met een alias en een closure op done_at
- D. Task::all() en groupBy in PHP

**Antwoord:** C. withCount met een alias en een closure op done_at

withCount doet het in dezelfde query als de projecten, met een subquery, en laadt geen enkele taak. B lijkt zuinig omdat het een count(\*) is, maar het is één query per project in de lus, dus een N+1. A laadt alle taken als model, D laadt de hele tabel.

> **Toelichting:** Heb je dat aantal op meerdere plaatsen nodig, zet die withCount dan in een local scope op het model. Zo schrijf je de closure maar één keer. Scopes komen aan bod in de les over modellen.

<!-- slide 31 -->

## 5. Filteren op relaties

> **Toelichting:** Filteren op een relatie is iets anders dan een relatie laden. whereHas beslist welke rijen je terugkrijgt, with beslist wat er aan die rijen hangt. Die twee door elkaar halen is een van de meest voorkomende bugs met Eloquent.

<!-- slide 32 -->

### whereHas()

- *Eloquent*

  ```php
  $clients = Client::whereHas(
      'invoices',
      fn (Builder $query) => $query
          ->where('due_on', '<', today())
          ->whereNull('paid_at'),
  )->get();
  ```
- *SQL*

  ```sql
  select * from clients
  where exists (
      select * from invoices
      where clients.id = invoices.client_id
      and due_on < '2026-09-30'
      and paid_at is null
  )
  ```

Klanten met minstens één factuur die over tijd is. `whereHas` wordt een `where exists` met een subquery. Je krijgt klanten terug, elke klant één keer, ook als hij tien facturen over tijd heeft.

> **Toelichting:** whereHas kan ook tellen: whereHas('invoices', $callback, '>=', 3) geeft klanten met minstens drie facturen over tijd. De klanten komen zonder hun facturen terug. Wil je die ook tonen, dan heb je nog een with nodig, of withWhereHas.

<!-- slide 33 -->

### Varianten

| Code | Geeft je |
| --- | --- |
| `Project::has('tasks')` | Projecten met minstens één taak |
| `Project::doesntHave('tasks')` | Projecten zonder taken |
| `Project::whereRelation('client', 'country', 'BE')` | whereHas met één where, korter geschreven |
| `Project::whereBelongsTo($client)` | De projecten van die ene klant |
| `Project::withWhereHas('tasks', fn ...)` | Filtert de projecten en laadt dezelfde taken erbij |

Alle vijf staan in de docs onder [Querying relationship existence](https://laravel.com/docs/13.x/eloquent-relationships#querying-relationship-existence).

> **Toelichting:** whereBelongsTo werkt ook met een collectie van klanten, dan krijg je de projecten van al die klanten. doesntHave is handig om wezen op te sporen, zoals projecten zonder taken die niemand meer gebruikt.

<!-- slide 34 -->

### whereHas of join

```php
Client::whereHas('invoices', ...)
```

Elke klant één keer, gewone modellen, en het leest zoals de vraag. Met een index op `invoices.client_id` is `exists` meestal snel.

```php
Client::select('clients.*')
    ->join('invoices', 'invoices.client_id', '=', 'clients.id')
    ->distinct()
```

Nodig als je sorteert of selecteert op een kolom van de andere tabel. Je moet zelf dubbele rijen en kolomnamen bewaken.

> [!NOTE]
> Begin met `whereHas`. Kies pas een join als `explain()` toont dat de database er sneller van wordt, of als je op de andere tabel sorteert.

> **Toelichting:** In MySQL 8 en PostgreSQL zet de optimizer een exists vaak zelf om naar iets wat op een join lijkt. Het verschil in snelheid is daardoor kleiner dan oude blogposts beweren. Meet het met explain() op je echte data voor je iets herschrijft.

<!-- slide 35 -->

### Kolommen die botsen

```php
$clients = Client::join('invoices', 'invoices.client_id', '=', 'clients.id')->get();

route('clients.show', $clients->first());
```

Zonder `select` krijg je `*` van beide tabellen. Beide hebben een kolom `id`, en de laatste wint: `$client->id` is nu het id van een factuur. De link gaat naar de verkeerde klant, zonder foutmelding. Daarom altijd `select('clients.*')` bij een join.

> **Toelichting:** Hetzelfde gebeurt met created_at, name of elke andere kolom die in beide tabellen staat. Heb je een kolom van de andere tabel nodig, geef ze dan een alias: select('clients.*', 'invoices.due_on as invoice_due_on'). En pas op met update() op zo'n resultaat, want de id klopt misschien niet.

<!-- slide 36 -->

## 6. Grote datasets

> **Toelichting:** Een webpagina toont zelden meer dan honderd rijen, maar een export, een import of een nachtelijke command raakt de hele tabel. PHP heeft een memory_limit, vaak 128 of 256 MB, en een Eloquent-model neemt veel meer geheugen dan de rij in de database.

<!-- slide 37 -->

### Grote datasets

|   | Queries | In het geheugen | Eager loading |
| --- | --- | --- | --- |
| `get()` | 1 | Alles | Ja |
| `chunk(500, fn)` | Eén per 500 rijen | 500 modellen | Ja |
| `chunkById(500, fn)` | Eén per 500 rijen | 500 modellen | Ja |
| `lazy(500)` | Eén per 500 rijen | 500 modellen, als één stream | Ja |
| `cursor()` | 1 | Eén model, maar PDO buffert de rijen | Nee |

Een export of een nachtelijke command met `get()` op 200.000 rijen eindigt in een memory limit error. Kies `lazy()` of `lazyById()`. `cursor()` lijkt zuiniger, maar PDO houdt de ruwe rijen toch in het geheugen, en eager loading kan niet.

> **Toelichting:** lazy() gebruikt intern chunk, lazyById() intern chunkById, dus de regel over de filterkolom van de volgende slides geldt ook hier. Een LazyCollection heeft bijna alle methodes van een gewone collectie, maar ze lopen pas als je erover itereert.

<!-- slide 38 -->

### chunk() of chunkById()

*app/Console/Commands/SendInvoiceRemindersCommand.php*

```php
Invoice::query()
    ->whereNull('reminded_at')
    ->where('due_on', '<', today())
    ->chunkById(500, function (Collection $invoices) {
        $invoices->each(function (Invoice $invoice) {
            $invoice->client->notify(new InvoiceOverdue($invoice));
            $invoice->update(['reminded_at' => now()]);
        });
    });
```

De command filtert op facturen zonder herinnering.

En past in de lus net die kolom aan. Met `chunk()` gaat dat mis, met `chunkById()` niet.

> **Toelichting:** Bij chunkById moet je eigen voorwaarden met orWhere in een closure groeperen, anders botsen ze met de where id > die Laravel toevoegt. En zet with('client') voor de chunkById, dan laadt Laravel de klanten per blok van 500 in één query.

<!-- slide 39 -->

### Dezelfde command, maar met chunk(1000) in plaats van chunkById(1000). Er zijn 5000 facturen over tijd. Wat gebeurt er?

- A. Alle 5000 krijgen een herinnering
- B. Een deel wordt overgeslagen
- C. Memory limit error
- D. Een exception over de offset

**Antwoord:** B. Een deel wordt overgeslagen

chunk() werkt met limit en offset. Na de eerste 1000 hebben die een reminded_at en vallen ze uit de where. De tweede chunk vraagt offset 1000, maar de lijst is opgeschoven: de 1000 facturen die nu vooraan staan, sla je over. Het geheugen is geen probleem, dat is net waarvoor je chunkt. chunkById() werkt met where id > laatste id en schuift niet op.

> **Toelichting:** Dit soort bug valt niet op in een test met tien facturen, want dan past alles in één chunk. Test een command dat in blokken werkt daarom met meer rijen dan de chunkgrootte, of met een chunkgrootte van 2.

<!-- slide 40 -->

### Of gewoon één update

- *5000 queries*

  ```php
  Invoice::where('due_on', '<', today())
      ->lazyById()
      ->each->update([
          'status' => InvoiceStatus::Overdue,
      ]);
  ```
- *1 query*

  ```php
  Invoice::where('due_on', '<', today())
      ->update([
          'status' => InvoiceStatus::Overdue,
      ]);
  ```

Moet er per rij iets gebeuren in PHP, zoals een mail, dan chunk je. Moet er alleen een kolom veranderen, dan is één update-query veel sneller.

> [!WARNING]
> Een update op de query builder laadt geen modellen, dus model events en observers vuren niet. Meer daarover in de les over modellen.

> **Toelichting:** Een update via de query builder zet wel updated_at, maar voert geen casts of mutators uit en vuurt geen events. Geef je een enum mee, dan gebruikt Laravel zijn waarde. Ook delete() op een query werkt zo: één query, zonder events.

<!-- slide 41 -->

## 7. Indexes, transactions en upserts

> **Toelichting:** Dit hoofdstuk gaat meer over de database dan over Eloquent. Een index, een transaction of een upsert werken in elk framework hetzelfde. Ze maken het verschil tussen een app die werkt met testdata en een app die jaren meegaat bij een klant.

<!-- slide 42 -->

### Wat een index doet

Zonder index leest de database voor een `where` elke rij van de tabel, een full table scan. Met 1000 rijen merk je dat niet, met twee miljoen time entries wel.

Een index is een gesorteerde lijst van de waarden in een kolom, met bij elke waarde een verwijzing naar de rij. Zoals de index achteraan een boek: je zoekt het woord op en springt naar de pagina.

> [!NOTE]
> Een index kost ook iets: elke insert en update moet hem bijwerken. Zet er een op kolommen die vaak in `where`, `join` of `order by` staan, niet op alles.

> **Toelichting:** De meeste databases gebruiken een B-tree als index: een boom die gesorteerd blijft, zodat de database in een paar stappen bij de juiste waarde is, ook bij miljoenen rijen. Een primary key heeft altijd een index. use-the-index-luke.com legt het uitgebreid uit voor developers.

<!-- slide 43 -->

### Indexes

*database/migrations/...\_create_time_entries_table.php*

```php
Schema::create('time_entries', function (Blueprint $table) {
    $table->id();
    $table->foreignId('task_id')->constrained()->cascadeOnDelete();
    $table->foreignId('user_id')->constrained();
    $table->unsignedInteger('minutes');
    $table->date('spent_on');
    $table->index(['user_id', 'spent_on']);
    $table->timestamps();
});
```

MySQL maakt vanzelf een index voor een foreign key. PostgreSQL niet, daar zet je hem er zelf bij.

Een index op twee kolommen helpt voor een user, en voor een user in een periode. Niet voor een periode alleen: de volgorde telt.

> **Toelichting:** constrained() maakt een foreign key constraint. MySQL met InnoDB heeft daarvoor een index nodig en maakt hem zelf als hij er nog niet is. Een index op een bestaande tabel zet je met een nieuwe migration, met Schema::table en $table->index(...).

<!-- slide 44 -->

### explain()

```php
TimeEntry::where('user_id', 4)
    ->whereBetween('spent_on', ['2026-09-01', '2026-09-30'])
    ->explain()
    ->dd();
```

Voorbeeld van de output, op MySQL

|   | type | key | rows |
| --- | --- | --- | --- |
| Zonder index | `ALL` | `null` | 1.840.212 |
| Met de index | `range` | `time_entries_user_id_spent_on_index` | 96 |

`explain()` vraagt de database hoe hij de query gaat uitvoeren. `type ALL` is de hele tabel, `key` is de index die hij gebruikt, `rows` hoeveel rijen hij denkt te lezen.

> **Toelichting:** In MySQL betekent type ALL een full table scan, ref of range dat er een index gebruikt wordt. PostgreSQL toont een ander formaat, met Seq Scan en Index Scan, maar het idee is hetzelfde. In TablePlus of een andere databaseclient kan je EXPLAIN ook voor een query typen.

<!-- slide 45 -->

### Waarom een transaction

- Stel: een factuur maken is drie queries. De factuur aanmaken, de lijnen toevoegen, en de uren markeren als gefactureerd.
- Crasht de code na de tweede query, dan heb je een factuur waarvan de uren nog open staan. Volgende maand factureer je ze opnieuw.
- Een transaction maakt van de drie één geheel: alles lukt, of er verandert niets.
- Docs: [Database transactions](https://laravel.com/docs/13.x/database#database-transactions)

> **Toelichting:** Een transaction schermt half afgewerkte data ook af van andere requests: tot de commit ziet niemand anders de nieuwe factuur. Hou een transaction kort. Een API-call of een mail binnen een transaction houdt locks open en kan niet teruggedraaid worden.

<!-- slide 46 -->

### Transactions

```php
$invoice = DB::transaction(function () use ($client, $timeEntries, $total) {
    $invoice = $client->invoices()->create([
        'number' => InvoiceNumber::next(),
        'total' => $total,
    ]);

    TimeEntry::whereKey($timeEntries->modelKeys())
        ->update(['invoice_id' => $invoice->id]);

    return $invoice;
});
```

Alles in de closure hoort bij één transaction.

Een exception hierin, en ook de factuur wordt teruggedraaid.

Lukt alles, dan volgt een commit en geeft de transaction terug wat de closure teruggaf.

> **Toelichting:** Het tweede argument van DB::transaction is het aantal pogingen bij een deadlock, bijvoorbeeld attempts: 3. Een mail of een job die je binnen de closure start, vertrekt ook als alles daarna teruggedraaid wordt. Daarvoor bestaat afterCommit, zie de les over queues.

<!-- slide 47 -->

### Upserts

```php
TimeEntry::upsert(
    $rows,
    uniqueBy: ['external_id'],
    update: ['minutes', 'spent_on'],
);
```

Stel: elke nacht importeer je de uren uit de timetracker van een klant via een API. Bestaat een rij al, dan werk je ze bij, anders voeg je ze toe. Met `updateOrCreate` in een lus zijn dat twee queries per rij, `upsert` doet het in één query voor de hele lijst.

> [!WARNING]
> Werkt alleen met een `unique` index op de kolommen in `uniqueBy`. MySQL kijkt naar alle unique indexes van de tabel. Model events vuren niet.

> **Toelichting:** upsert zet created_at en updated_at voor je, maar voert geen casts of mutators uit. De waarden in $rows moeten dus al in het formaat van de database staan. Een externe id bewaren is slim bij elke import: zo werk je bij de volgende run bij in plaats van te dupliceren.

<!-- slide 48 -->

## 8. Queries zien

> **Toelichting:** Je kan geen query versnellen die je niet ziet. De tools in dit hoofdstuk horen standaard in je project, niet pas als een klant klaagt. Zet ze aan bij de start, dan zie je een N+1 op het moment dat je hem schrijft.

<!-- slide 49 -->

### toRawSql()

```php
$query = Client::whereHas(
    'invoices', fn (Builder $query) => $query->whereNull('paid_at'),
);

$query->toRawSql();
$query->dumpRawSql();
```

```sql
select * from "clients" where exists (select * from "invoices"
  where "clients"."id" = "invoices"."client_id" and "paid_at" is null)
```

`toRawSql()` geeft de SQL met de waarden ingevuld, `dumpRawSql()` toont hem meteen. Een `with()` staat er niet in: eager loads zijn aparte queries die pas bij `get()` lopen.

> **Toelichting:** Voor toRawSql bestond, gebruikte je toSql, met vraagtekens op de plaats van de waarden, en getBindings voor de waarden apart. ddRawSql toont de SQL en stopt de request. Plak de SQL in je databaseclient om hem daar uit te voeren of te explainen.

<!-- slide 50 -->

### Elke query in de log

*app/Providers/AppServiceProvider.php*

```php
public function boot(): void
{
    if ($this->app->isLocal()) {
        DB::listen(function (QueryExecuted $query) {
            Log::debug($query->toRawSql(), ['ms' => $query->time]);
        });
    }
}
```

`DB::listen` krijgt elke query die je app uitvoert, met de tijd in milliseconden. Handig in een command of een job, waar geen Debugbar is. Zet het alleen lokaal aan, anders wordt je log in productie gigantisch.

> **Toelichting:** Met php artisan pail zie je je log live in de terminal, dan komen de queries voorbij terwijl je door je app klikt. Wil je alleen trage queries, filter dan in de closure op $query->time boven 100.

<!-- slide 51 -->

### Trage requests melden

*app/Providers/AppServiceProvider.php*

```php
DB::whenQueryingForLongerThan(500, function (Connection $connection, QueryExecuted $event) {
    Log::warning('Trage request', ['url' => request()->fullUrl()]);
});
```

Deze wel voor productie. Zodra één request in totaal langer dan 500 ms in de database zit, komt er een waarschuwing in de log met de url erbij. Zo vind je de pagina's waar data is bijgekomen sinds de oplevering. Docs: [Listening for query events](https://laravel.com/docs/13.x/database#listening-for-query-events).

> **Toelichting:** De closure krijgt de connection en de laatste query. In een echt project stuur je zo'n melding vaak naar een monitoringtool zoals Sentry of Flare in plaats van naar de log. Het is cumulatief: vijftig queries van 12 ms halen samen ook de 500.

<!-- slide 52 -->

### Debugbar en Telescope

- **[Laravel Debugbar](https://github.com/fruitcake/laravel-debugbar)**: Een balk onder je pagina met de queries van die request, hun tijd, en welke dubbel zijn. Alleen lokaal: op een publieke server toont hij alles aan iedereen.
- **[Telescope](https://laravel.com/docs/13.x/telescope)**: Een dashboard op `/telescope` met requests, queries, jobs en mails. Queries boven 100 ms krijgen de tag slow.

```console
$ composer require fruitcake/laravel-debugbar --dev
$ composer require laravel/telescope --dev
$ php artisan telescope:install && php artisan migrate
```

> **Toelichting:** Debugbar markeert dubbele queries apart, dat is vaak de snelste manier om een N+1 te vinden. Telescope bewaart zijn data in de database, zet dus pruning aan als je het langer laat draaien. Laravel Pulse is een alternatief voor productie, met een dashboard van trage queries en requests.

<!-- slide 53 -->

### Opdracht: Een snel projectoverzicht

In je Agency-project, of in een nieuw project met het datamodel van slide 5.

1. Seed 500 projecten met taken en time entries, en installeer Debugbar
2. Toon per project de klant, het aantal open taken en de gewerkte minuten
3. Zet preventLazyLoading aan en los elke exception op
4. Klaar als het overzicht met 500 projecten in hoogstens 3 queries laadt, zonder exception

Vast? Eager loading staat vanaf slide 15, tellen en subqueries vanaf slide 24. De minuten per project lopen via de taken: een subquery met `addSelect` (slide 28), of `hasManyThrough` uit de les over modellen.

> **Toelichting:** Begin met de seeder: een ProjectFactory met has(Task::factory()->count(10)) maakt snel veel data. Tel eerst hoeveel queries de pagina doet zonder aanpassingen, dan weet je achteraf hoeveel je gewonnen hebt. Werk daarna relatie per relatie.

## Begrippen

- **N+1**: Eén query voor de lijst, en daarna één extra query per rij om een relatie op te halen. 50 projecten met hun klant worden zo 51 queries, 500 projecten 501. (slide 10)
- **Lazy loading**: Eloquent haalt een relatie pas op op het moment dat je ze aanspreekt, zoals $project->client. Handig bij één model, een N+1 in een lus. (slide 9)
- **Eager loading**: Relaties vooraf ophalen met with(), in één extra query per relatie met where id in (...). Het aantal queries hangt dan niet meer af van het aantal rijen. (slide 15)
- **Nested eager loading**: Relaties van relaties mee laden met een punt: with('tasks.timeEntries'). Elke stap is één query. (slide 16)
- **load()**: Eager loading op modellen die je al hebt, zoals een project uit route model binding. loadMissing() laadt alleen wat er nog niet is. (slide 18)
- **preventLazyLoading**: Model::preventLazyLoading() in AppServiceProvider laat Laravel een LazyLoadingViolationException gooien bij lazy loading. Zet het aan buiten productie, dan zie je een N+1 voor de klant hem ziet. (slide 19)
- **chaperone()**: Een optie op een hasMany die de parent op elk kind zet, zodat $task->project in een lus over $project->tasks geen query meer kost. (slide 22)
- **withCount**: Telt een relatie in dezelfde query met een subquery, zonder de modellen te laden. Het resultaat staat in een attribuut zoals tasks_count. (slide 24)
- **withSum**: Zoals withCount, maar met een som over een kolom van de relatie, zoals de minuten van alle time entries. Het attribuut heet dan time_entries_sum_minutes. (slide 26)
- **Subquery select**: Een query binnen de select, via addSelect(), die per rij één waarde teruggeeft, zoals de datum van de laatste factuur van een klant. Eén query in plaats van één per klant. (slide 28)
- **whereHas**: Filtert op het bestaan van een relatie die aan een voorwaarde voldoet. Eloquent maakt er een where exists (select ...) van. (slide 32)
- **Join**: Twee tabellen in één resultaat zetten op basis van een kolom die ze delen. Nodig als je sorteert of selecteert op een kolom van de andere tabel, maar let op dubbele rijen en kolommen die elkaar overschrijven. (slide 34)
- **chunkById**: Verwerkt een grote tabel in blokken, met where id > laatste id in plaats van een offset. Veilig als je de kolom aanpast waarop je filtert. (slide 38)
- **lazy()**: Haalt resultaten in blokken op, maar geeft ze terug als één LazyCollection. Weinig geheugen, en eager loading werkt nog. (slide 36)
- **cursor()**: Eén query, en maar één model tegelijk in het geheugen. Geen eager loading, en PDO buffert de ruwe rijen toch, dus bij echt grote tabellen is lazy() de betere keuze. (slide 36)
- **Index**: Een gesorteerde structuur naast je tabel waarmee de database rijen vindt zonder de hele tabel te lezen. Zet er een op kolommen die vaak in where, join of order by staan. (slide 43)
- **EXPLAIN**: Vraagt de database hoe hij een query gaat uitvoeren: welke index, hoeveel rijen hij denkt te lezen. In Eloquent met ->explain() op een query. (slide 44)
- **Transaction**: Een groep queries die samen slaagt of samen mislukt. DB::transaction() doet een rollback als er een exception komt, zodat je nooit een factuur zonder lijnen overhoudt. (slide 46)
- **Upsert**: Insert of update in één query, op basis van een kolom met een unique index. Handig bij een import. Model events vuren niet. (slide 47)
- **toRawSql**: Geeft de SQL van een query terug met de bindings ingevuld, zodat je ze kan lezen of in TablePlus plakken. dumpRawSql() en ddRawSql() tonen hem meteen. (slide 49)
- **Laravel Debugbar**: Een package dat onderaan je pagina toont welke queries een request deed, met hun tijd en de dubbele. Alleen lokaal gebruiken. (slide 52)
- **Telescope**: Het debug-dashboard van Laravel op /telescope. Houdt requests, queries, jobs, mails en exceptions bij, en tagt queries boven 100 ms als slow. (slide 52)

## Links en bronnen

### In de presentatie

- [Eager loading](https://laravel.com/docs/13.x/eloquent-relationships#eager-loading) (slide 15)
- [Aggregating related models](https://laravel.com/docs/13.x/eloquent-relationships#aggregating-related-models) (slide 24)
- [Advanced subqueries](https://laravel.com/docs/13.x/eloquent#advanced-subqueries) (slide 29)
- [Querying relationship existence](https://laravel.com/docs/13.x/eloquent-relationships#querying-relationship-existence) (slide 33)
- [Database transactions](https://laravel.com/docs/13.x/database#database-transactions) (slide 45)
- [Listening for query events](https://laravel.com/docs/13.x/database#listening-for-query-events) (slide 51)
- [Laravel Debugbar](https://github.com/fruitcake/laravel-debugbar) (slide 52): Package heet nu fruitcake/laravel-debugbar
- [Laravel Telescope](https://laravel.com/docs/13.x/telescope) (slide 52): Query watcher en de slow tag

### Bronnen

- [Eloquent: Relationships](https://laravel.com/docs/13.x/eloquent-relationships): Eager loading, aggregaten, whereHas, preventLazyLoading, chaperone
- [Eloquent: Getting Started](https://laravel.com/docs/13.x/eloquent): chunk, lazy, cursor, subquery selects, upserts
- [Database: Getting Started](https://laravel.com/docs/13.x/database): DB::listen, whenQueryingForLongerThan, transactions
- [Database: Query Builder](https://laravel.com/docs/13.x/queries): toRawSql en dumpRawSql, pessimistic locking
- [Database: Migrations](https://laravel.com/docs/13.x/migrations): Indexes en foreign keys
- [Use The Index, Luke](https://use-the-index-luke.com): Indexes uitgelegd voor developers, per database
