# Advanced Eloquent: modellen

Casts, scopes, relaties met pivot-data, polymorphic relaties, soft deletes, model events en mass assignment.

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

Tags: laravel, php, eloquent, database

Slides: <https://slides.mctantwerp.be/nl/the-agency/09-eloquent-modellen>

<!-- slide 2 -->

## Agenda

1. Casts
2. Accessors en mutators
3. Scopes
4. Relaties
5. Soft deletes en pruning
6. Model events
7. Mass assignment

> **Toelichting:** Casts, accessors en scopes gaan over één model. Het hoofdstuk over relaties is het langste. De laatste drie hoofdstukken gaan over wat er gebeurt als je opslaat of verwijdert, en daar zitten de meeste valkuilen.

<!-- slide 3 -->

## Na deze les kan je

- Kolommen omzetten naar een enum, een collection of een eigen value object met casts
- Een berekende waarde of een opkuis schrijven met een accessor of een mutator
- Herbruikbare queries schrijven met local scopes, en uitleggen waarom een global scope riskant is
- Extra data op een many to many bewaren, en relaties leggen via een tussenmodel of polymorphic
- Soft deletes gebruiken zonder in de valkuilen te trappen, en oude records laten opruimen
- Voorspellen welke model events vuren, en een model beschermen tegen mass assignment

> **Toelichting:** Elk punt komt terug in de opdracht op het einde. Twijfel je bij een punt, ga dan terug naar dat hoofdstuk voor je aan de opdracht begint.

<!-- slide 4 -->

## 1. Casts

> **Toelichting:** Casts zijn de brug tussen wat de database kan bewaren en wat je in PHP wil gebruiken. Goed gekozen casts halen een hele reeks bugs weg: strings die je vergelijkt met een typfout, bedragen die afronden, datums die je zelf moet parsen.

<!-- slide 5 -->

### Van kolom naar PHP

| Kolom | In de database | Cast | Wat je leest |
| --- | --- | --- | --- |
| `status` | `'active'` | `ProjectStatus::class` | `ProjectStatus::Active` |
| `is_billable` | `1` | `'boolean'` | `true` |
| `starts_on` | `'2026-10-01'` | `'date'` | Een Carbon-datum |
| `budget_hours` | `120.5` | `'decimal:2'` | `'120.50'` |
| `settings` | `{"color":"blue"}` | `AsCollection::class` | Een Collection |

De database kent strings, getallen en JSON. Een cast zegt hoe een kolom een PHP-waarde wordt als je hem leest, en hoe hij terug wordt omgezet als je opslaat. Zonder casts schrijf je overal `if ($project->status === 'active')`, met een typfout als bug die wacht.

> **Toelichting:** Een cast werkt in twee richtingen, bij het lezen en bij het opslaan. Ook toArray() en JSON gebruiken hem: een datum wordt een ISO-string, een enum zijn waarde. Een kolom die null is in de database, wordt niet gecast en blijft null.

<!-- slide 6 -->

### casts()

*app/Models/Project.php*

```php
protected function casts(): array
{
    return [
        'status' => ProjectStatus::class,
        'is_billable' => 'boolean',
        'starts_on' => 'date',
        'budget_hours' => 'decimal:2',
        'settings' => AsCollection::class,
    ];
}
```

Sinds Laravel 11 is `casts` een methode. De property `$casts` werkt nog, en je komt ze tegen in oudere projecten. Docs: [Attribute casting](https://laravel.com/docs/13.x/eloquent-mutators#attribute-casting).

> **Toelichting:** Een methode heeft als voordeel dat er code in kan, zoals AsCollection::of(Option::class) of AsEnumCollection::of(Channel::class). In een property mag geen functieaanroep staan. mergeCasts() voegt tijdelijk een cast toe op één model.

<!-- slide 7 -->

### Een enum voor de status

- ```php
  enum ProjectStatus: string
  {
      case Draft = 'draft';
      case Active = 'active';
      case Done = 'done';

      public function label(): string
      {
          return match ($this) {
              self::Draft => 'Offerte',
              self::Active => 'Lopend',
              self::Done => 'Afgerond',
          };
      }
  }
  ```
- In `app/Enums/ProjectStatus.php`. Een backed enum heeft een waarde per case. Die waarde staat in de database, de case gebruik je in PHP.

  De enum kan zelf methodes hebben. `label()` geeft de tekst voor de interface, zodat die vertaling niet in elke Blade-view opnieuw staat.

> **Toelichting:** Een backed enum heeft een string of int als waarde, een pure enum heeft er geen en kan je dus niet in een kolom bewaren. Kies waarden die niet meer veranderen: een label mag je aanpassen, de waarde in de database liever niet. Enums bestaan sinds PHP 8.1.

<!-- slide 8 -->

### Enums als cast

```php
if ($project->status === ProjectStatus::Done) {
    return;
}

$project->status->label();

$project->update(['status' => ProjectStatus::Active]);
```

Met `'status' => ProjectStatus::class` in `casts()` krijg je een enum terug als je leest, en mag je een enum geven als je opslaat. Een waarde die niet in de enum staat, geeft een fout in plaats van een project met een status die niemand kent.

> **Toelichting:** Vergelijk enums met ===, want elke case bestaat maar één keer. ProjectStatus::from('active') geeft de case of gooit een fout, tryFrom() geeft null bij een onbekende waarde. Een enum kan ook een interface implementeren en constants hebben.

<!-- slide 9 -->

### Enums in queries en validatie

```php
Project::where('status', ProjectStatus::Active)->get();

$request->validate([
    'status' => ['required', Rule::enum(ProjectStatus::class)],
]);

@foreach (ProjectStatus::cases() as $status)
    <option value="{{ $status->value }}">{{ $status->label() }}</option>
@endforeach
```

In een query mag je de enum meegeven. Eloquent gebruikt zijn waarde.

`Rule::enum` weigert elke waarde die geen case is.

`cases()` geeft alle cases, handig voor een select.

> **Toelichting:** Rule::enum kan je beperken met only() of except(), bijvoorbeeld zodat een gewone gebruiker een project niet zelf op Done kan zetten. In Blade markeer je de huidige waarde met @selected($project->status === $status).

<!-- slide 10 -->

### JSON-kolommen

```php
protected function casts(): array
{
    return ['settings' => AsCollection::class];
}

$project->settings->get('color', 'blue');
$project->settings->put('color', 'red');
$project->save();
```

Met de gewone `array` cast kan je geen waarde in de array aanpassen en opslaan. `AsCollection` en `AsArrayObject` kunnen dat wel. Voor een lijst van enums is er `AsEnumCollection::of()`.

> [!WARNING]
> Filter je vaak op een waarde in die JSON? Dan hoort ze in een eigen kolom, met een index.

> **Toelichting:** MySQL en PostgreSQL kunnen zoeken in JSON met where('settings->color', 'red'), maar zonder index is dat traag op grote tabellen. AsArrayObject gedraagt zich als een array, AsCollection als een Laravel collection met map, filter en de rest. Kies wat in je code het best leest.

<!-- slide 11 -->

### Een value object voor bedragen

*app/Support/Money.php*

```php
final readonly class Money
{
    public function __construct(public int $cents) {}

    public function format(): string
    {
        return '€ '.number_format($this->cents / 100, 2, ',', '.');
    }
}
```

Bedragen bewaar je in centen, als integer. Nooit als float: `0.1 + 0.2` is in PHP geen `0.3`. In je code wil je dan geen losse int, maar een object dat weet dat het geld is en zichzelf kan tonen. Dat heet een value object, meer daarover in de les over domain-driven design.

> **Toelichting:** readonly betekent dat de properties na de constructor niet meer veranderen: wil je een ander bedrag, dan maak je een nieuw object. Echte projecten gebruiken vaak een package zoals brick/money of moneyphp/money, met munten en afrondingsregels. Voor facturen in euro volstaat dit.

<!-- slide 12 -->

### Een eigen cast

```php
class AsMoney implements CastsAttributes
{
    public function get(Model $model, string $key, mixed $value, array $attributes): Money
    {
        return new Money((int) $value);
    }

    public function set(Model $model, string $key, mixed $value, array $attributes): int
    {
        return $value->cents;
    }
}
```

In `app/Casts/AsMoney.php`, aangemaakt met `php artisan make:cast AsMoney`. `get()` maakt van de ruwe waarde een Money, `set()` maakt er weer een int van.

> **Toelichting:** Een cast kan ook meerdere kolommen vullen: geef in set() een array terug met kolomnaam en waarde, bijvoorbeeld total_cents en currency. Wil je zeker zijn dat er een Money binnenkomt, gooi dan een InvalidArgumentException bij iets anders. Is de kolom nullable, handel null dan af in get() en set().

<!-- slide 13 -->

### De cast gebruiken

```php
protected function casts(): array
{
    return [
        'total' => AsMoney::class,
    ];
}

$invoice->total->format(); // € 1.250,00

$invoice->total = new Money(125000);
```

Op `Invoice` hang je de cast aan de kolom `total`. Lezen geeft een Money, opslaan neemt een Money. Dezelfde cast kan op een offerte, een onkost of een uurtarief, en overal werkt `format()` hetzelfde. Docs: [Custom casts](https://laravel.com/docs/13.x/eloquent-mutators#custom-casts).

> **Toelichting:** Eloquent onthoudt het Money-object na het eerste lezen. Pas je een property aan op een value object dat niet readonly is, dan schrijft Eloquent die wijziging terug bij save(). Met readonly vermijd je die verrassing en vervang je altijd het hele object.

<!-- slide 14 -->

### budget_hours heeft de cast decimal:2 en in de database staat 120.5. Wat krijg je terug als je het leest?

- A. De float 120.5
- B. De string '120.50'
- C. De int 120
- D. Een Money-object

**Antwoord:** B. De string '120.50'

Een decimal cast geeft een string terug met precies twee cijfers na de komma, zodat er geen afrondingsfouten van een float insluipen. De float lijkt logisch omdat het een getal is, maar net die float wil Laravel hier vermijden. Wil je ermee rekenen, doe dat bewust, of kies een eigen cast.

> **Toelichting:** Er bestaan ook de casts float en double, maar die geven een float met alle afrondingsproblemen van dien. Voor uren is dat meestal geen ramp, voor geld wel. Daarom bewaar je geld in centen als integer, met een eigen cast.

<!-- slide 15 -->

## 2. Accessors en mutators

> **Toelichting:** Accessors en mutators zijn de oudste manier om een model slimmer te maken dan zijn tabel. Sinds Laravel 9 schrijf je beide in één methode met Attribute::make. In oudere projecten zie je getFirstNameAttribute en setFirstNameAttribute, en die werken nog.

<!-- slide 16 -->

### Een accessor

*app/Models/TimeEntry.php*

```php
protected function hours(): Attribute
{
    return Attribute::make(
        get: fn (mixed $value, array $attributes) => round($attributes['minutes'] / 60, 2),
    );
}
```

Een accessor is een attribuut dat niet in de database staat, of er anders uitkomt dan het erin staat. De methode heet `hours`, je leest ze als `$entry->hours`: `1.5` voor 90 minuten. In JSON staat ze pas als je `#[Appends(['hours'])]` op het model zet. Docs: [Accessors and mutators](https://laravel.com/docs/13.x/eloquent-mutators#accessors-and-mutators).

> **Toelichting:** De get-closure krijgt de waarde van de kolom met dezelfde naam, en als tweede argument alle attributen van het model. Voor een waarde die niet in de database staat, zoals hours, is de eerste null en gebruik je de tweede. Met shouldCache() rekent Laravel een dure accessor maar één keer uit.

<!-- slide 17 -->

### Een mutator

*app/Models/Client.php*

```php
protected function vatNumber(): Attribute
{
    return Attribute::make(
        set: fn (string $value) => strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $value)),
    );
}
```

```php
$client->vat_number = 'be 0123.456.789';
$client->vat_number; // BE0123456789
```

Klanten typen hun btw-nummer met spaties, punten of kleine letters. De mutator maakt er altijd hetzelfde van voor het in het model komt, zodat je erop kan zoeken en een unique index werkt.

> **Toelichting:** Een mutator loopt ook bij create() en fill(), niet alleen bij een directe toewijzing. Bij een update via de query builder loopt hij niet, want daar komen geen modellen aan te pas.

<!-- slide 18 -->

### Accessor of cast

- Accessor

  Voor één model: een berekende waarde of een kleine opkuis, zoals `hours` of `vat_number`.
- Cast

  Voor dezelfde omzetting op meerdere modellen of kolommen, zoals `AsMoney` op facturen, offertes en onkosten.

> [!WARNING]
> Een accessor die een query doet, is een verstopte N+1. `$this->timeEntries()->sum()` in een accessor kost in een lijst van 50 projecten 50 queries. Gebruik daar `withSum` uit de les over queries.

> **Toelichting:** Heb je een berekende waarde in een lijst nodig, kijk dan of de database hem kan berekenen: withSum, withCount of een subquery. Een accessor mag rekenen met wat al op het model staat, maar haalt beter zelf geen data op.

<!-- slide 19 -->

## 3. Scopes

> **Toelichting:** Scopes geven een naam aan een stuk query dat je vaak nodig hebt. Controllers worden er korter van, maar vooral leesbaarder: Project::active()->forClient($client) zegt wat je wil, niet hoe. Global scopes doen hetzelfde automatisch, en daar zit het risico.

<!-- slide 20 -->

### Local scopes

*app/Models/Project.php*

```php
#[Scope]
protected function active(Builder $query): void
{
    $query->where('status', ProjectStatus::Active);
}

#[Scope]
protected function forClient(Builder $query, Client $client): void
{
    $query->whereBelongsTo($client);
}
```

Een scope is een stuk query met een naam. Zet `#[Scope]` boven een protected methode die de query aanpast. In oudere code heet dezelfde methode `scopeActive()`.

> **Toelichting:** Een scope met #[Scope] is protected, en je roept hem aan via de query builder. Binnen het model zelf schrijf je static::query()->active(). whereBelongsTo zoekt de juiste foreign key zelf op via de relatie client().

<!-- slide 21 -->

### Scopes combineren

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

Je roept een scope aan zonder `#[Scope]` en zonder de parameter `$query`. Een tweede parameter, zoals de klant, geef je gewoon mee. Scopes combineren met alles wat een query kan, en de naam zegt wat je vraagt: actieve projecten van deze klant. Docs: [Local scopes](https://laravel.com/docs/13.x/eloquent#local-scopes).

> **Toelichting:** Scopes en orWhere samen hebben haakjes nodig. Project::active()->orWhere->forClient($client) gebruikt de higher order orWhere van Laravel en zet de haakjes goed. Twijfel je, controleer dan de SQL met toRawSql().

<!-- slide 22 -->

### Global scopes

*app/Models/Scopes/TeamScope.php*

```php
class TeamScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        $builder->where('team_id', auth()->user()->team_id);
    }
}
```

Stel: de tool wordt een SaaS, en elk agency is een team. Elke tabel krijgt een `team_id`, en een agency mag nooit de projecten van een ander zien. Een global scope zet die voorwaarde op elke query van het model, zonder dat je ze telkens schrijft.

> **Toelichting:** Een global scope kan ook een closure zijn, met static::addGlobalScope('team', ...) in booted(). Een class heeft als voordeel dat je hem uitzet met withoutGlobalScope(TeamScope::class), zonder een string te moeten onthouden. Maak er een aan met php artisan make:scope TeamScope.

<!-- slide 23 -->

### Een global scope koppelen

```php
#[ScopedBy([TeamScope::class])]
class Project extends Model

Project::withoutGlobalScope(TeamScope::class)->get();
```

`#[ScopedBy]` koppelt de scope aan het model. Vanaf dan krijgt elke query een `where team_id = ...`. Wil je hem een keer niet, dan zeg je dat expliciet met `withoutGlobalScope`. De les over autorisatie gaat verder in op teams en multi tenancy. Docs: [Global scopes](https://laravel.com/docs/13.x/eloquent#global-scopes).

> **Toelichting:** withoutGlobalScopes() zonder argument zet ze allemaal uit, ook die van SoftDeletes. Dat is zelden wat je wil, noem dus liever de scope die je uitzet. Packages zoals spatie/laravel-multitenancy lossen teams anders op, bijvoorbeeld met een database per klant.

<!-- slide 24 -->

### Global scopes zijn onzichtbaar

- Je leest `Project::all()` en krijgt niet alle projecten. De regel staat in een andere file dan de query.
- Een command, een queued job of een test heeft geen ingelogde user, en dan breekt een scope die op auth() leunt.
- Een admin-scherm, een export of een rapport moet er telkens omheen met `withoutGlobalScope`.
- `SoftDeletes` is de global scope die je elke dag gebruikt, en die is goed gekozen. Voor je eigen regels is een local scope of `$team->projects()` meestal duidelijker.

> **Toelichting:** Een global scope voelt veilig omdat je hem niet kan vergeten. Maar je vergeet wel dat hij er is, en dat geeft bugs die moeilijk te vinden zijn: een rapport met te weinig rijen, een job die niets doet. Een policy of een query via het team is explicieter, zie de les over autorisatie.

<!-- slide 25 -->

### Project heeft de TeamScope van net. Een queued job doet Project::all(). Wat gebeurt er?

- A. Je krijgt alle projecten
- B. Een fout: er is geen ingelogde user
- C. Je krijgt de projecten van de laatste user
- D. Laravel slaat global scopes over in jobs

**Antwoord:** B. Een fout: er is geen ingelogde user

Een job draait in een worker, zonder request en zonder sessie. `auth()->user()` is null, en `->team_id` op null geeft een fout. Laravel slaat global scopes nergens vanzelf over, ook niet in jobs. Met `?->` verdwijnt de fout, maar dan krijg je `where team_id is null`, en dus niets.

> **Toelichting:** Wil je toch een team-scope, maak hem dan robuust: sla de where over als er geen user is, of geef het team expliciet mee aan de job. Een job die voor één team werkt, krijgt dat team in zijn constructor en filtert zelf.

<!-- slide 26 -->

## 4. Relaties

> **Toelichting:** belongsTo en hasMany ken je al. De relaties hier heb je nodig zodra het model groeit: een tussentabel met eigen data, een relatie over twee stappen, en één tabel die bij verschillende modellen hoort. Ze komen in bijna elk klantproject terug.

<!-- slide 27 -->

### Wie werkt aan welk project

*database/migrations/...\_create_project_user_table.php*

```php
Schema::create('project_user', function (Blueprint $table) {
    $table->foreignId('project_id')->constrained()->cascadeOnDelete();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->string('role');
    $table->unsignedInteger('hourly_rate');
    $table->timestamps();

    $table->primary(['project_id', 'user_id']);
});
```

Stel: Jan is developer op het ene project aan 85 euro, en lead op het andere aan 95. Die rol en dat tarief horen niet bij Jan en niet bij het project, maar bij de combinatie. Dus komen ze in de pivot table, naast de twee ids.

> **Toelichting:** De naam van de pivot table is de twee modelnamen in het enkelvoud, alfabetisch, met een underscore: project_user. De samengestelde primary key voorkomt dat dezelfde user twee keer aan hetzelfde project hangt. Het uurtarief staat in centen, zodat het later bij de Money-cast past.

<!-- slide 28 -->

### Pivot-data ophalen

*app/Models/Project.php*

```php
public function members(): BelongsToMany
{
    return $this->belongsToMany(User::class)
        ->withPivot('role', 'hourly_rate')
        ->withTimestamps();
}

foreach ($project->members as $member) {
    echo $member->pivot->hourly_rate;
}
```

Zonder `withPivot` staan alleen de twee ids op `pivot`. Met `withTimestamps` vult Laravel `created_at` en `updated_at` van de pivot zelf in. Docs: [Many to many](https://laravel.com/docs/13.x/eloquent-relationships#many-to-many).

> **Toelichting:** Zet ook de inverse relatie op User: projects() met dezelfde withPivot. Met wherePivot('role', 'lead') filter je op een kolom van de pivot, handig voor een relatie leads() naast members().

<!-- slide 29 -->

### attach, sync en updateExistingPivot

```php
$project->members()->attach($user, ['role' => 'developer', 'hourly_rate' => 8500]);

$project->members()->sync([
    4 => ['role' => 'lead', 'hourly_rate' => 9500],
    7 => ['role' => 'designer', 'hourly_rate' => 8000],
]);

$project->members()->updateExistingPivot($user, ['hourly_rate' => 9000]);
```

`attach` voegt één lid toe, met de pivot-data erbij. Het tarief staat in centen.

`sync` maakt de lijst gelijk aan de array: wie er niet in staat, wordt losgekoppeld. Ideaal voor checkboxes.

`updateExistingPivot` past alleen de pivot-data aan van iemand die al lid is.

> **Toelichting:** sync geeft een array terug met wat er attached, detached en updated is, handig om te loggen. syncWithoutDetaching voegt toe zonder iemand los te koppelen. De OrFail-varianten zoals syncOrFail doen het in een transaction.

<!-- slide 30 -->

### Een eigen pivot model

*app/Models/ProjectMember.php*

```php
class ProjectMember extends Pivot
{
    protected function casts(): array
    {
        return [
            'role' => ProjectRole::class,
            'hourly_rate' => AsMoney::class,
        ];
    }
}
```

Een pivot is standaard een naamloos object met ruwe waarden. Een class die `Pivot` extendt, krijgt casts en methodes zoals elk model: de rol als enum, het tarief als Money.

> **Toelichting:** Een pivot model maak je met php artisan make:model ProjectMember --pivot. Heeft je pivot table een eigen auto-increment id, zet dan #[Table(incrementing: true)] op de class. Een pivot model kan geen SoftDeletes gebruiken; heb je dat nodig, maak er dan een gewoon model van met twee belongsTo-relaties.

<!-- slide 31 -->

### Het pivot model gebruiken

*app/Models/Project.php*

```php
return $this->belongsToMany(User::class)
    ->using(ProjectMember::class)
    ->as('membership')
    ->withPivot('role', 'hourly_rate')
    ->withTimestamps();
```

```php
$member->membership->hourly_rate->format(); // € 95,00
```

`using()` zegt welke class de pivot is. `as()` geeft het attribuut een betere naam dan `pivot`, zodat je code leest als wat het is: het lidmaatschap van iemand op dit project.

> **Toelichting:** Test de cast in php artisan tinker: $project->members->first()->membership->hourly_rate moet een Money zijn, geen int. Zet je chaperone() op de relatie, dan vult Laravel ook de relaties van het pivot model zonder extra query.

<!-- slide 32 -->

### hasManyThrough

```php
public function timeEntries(): HasManyThrough
{
    return $this->through('tasks')->has('timeEntries');
}
```

```sql
select time_entries.*, tasks.project_id as laravel_through_key
from time_entries
inner join tasks on tasks.id = time_entries.task_id
where tasks.project_id = 12
```

Uren hangen aan taken, niet aan projecten. `hasManyThrough` op `Project` springt over de taken heen, met een join (de SQL eronder). `through()` hergebruikt de relaties die er al zijn.

> **Toelichting:** Er bestaat ook hasOneThrough, voor één model via een tussenmodel. De laravel_through_key in de SQL is hoe Eloquent weet bij welk project elke time entry hoort als je eager loadt. Over een many to many springen kan hasManyThrough niet; daarvoor bestaat het package staudenmeir/eloquent-has-many-deep.

<!-- slide 33 -->

### Uren per project

```php
$projects = Project::withSum('timeEntries', 'minutes')->get();

$projects->first()->time_entries_sum_minutes;
```

Omdat `timeEntries` nu een relatie van het project is, werken `withSum` en `withCount` erop, net zoals in de les over queries. De lange schrijfwijze is `hasManyThrough(TimeEntry::class, Task::class)`. Docs: [Has many through](https://laravel.com/docs/13.x/eloquent-relationships#has-many-through).

> **Toelichting:** withSum via een through-relatie maakt een subquery met een join. Met een index op tasks.project_id en time_entries.task_id blijft dat snel. Hiermee kan je de opdracht uit de les over queries in één regel schrijven.

<!-- slide 34 -->

### Polymorphic relaties

```php
Schema::create('comments', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained();
    $table->morphs('commentable');
    $table->text('body');
    $table->timestamps();
});
```

Commentaar op een project, een taak en een factuur. Drie tabellen is drie keer dezelfde code. `morphs` maakt `commentable_type` en `commentable_id`: één tabel, met een kolom die zegt bij welk soort model de rij hoort.

> [!WARNING]
> Geen foreign key op `commentable_id`: geen cascade, en niets houdt een wees tegen. Goed voor comments en bijlagen, niet voor de kern van je domein.

> **Toelichting:** morphs maakt een string-kolom voor het type en een unsignedBigInteger voor het id, met een index op de twee samen. Gebruik je uuids of ulids als primary key, dan heb je uuidMorphs of ulidMorphs nodig. nullableMorphs laat beide kolommen leeg toe.

<!-- slide 35 -->

### morphTo en morphMany

```php
public function commentable(): MorphTo
{
    return $this->morphTo();
}
```

```php
public function comments(): MorphMany
{
    return $this->morphMany(Comment::class, 'commentable');
}
```

Bovenaan `Comment`, onderaan `Project`, `Task` en `Invoice`. `$comment->commentable` geeft een van die drie terug, afhankelijk van de type-kolom. Docs: [Polymorphic relationships](https://laravel.com/docs/13.x/eloquent-relationships#polymorphic-relationships).

> **Toelichting:** Eager loaden werkt ook: Comment::with('commentable') doet één query per type, dus drie queries voor projecten, taken en facturen samen. Filteren op het type kan met whereHasMorph. Er bestaan ook morphOne, en morphToMany voor een polymorphic many to many zoals tags.

<!-- slide 36 -->

### Een morph map

*app/Providers/AppServiceProvider.php*

```php
public function boot(): void
{
    Relation::enforceMorphMap([
        'project' => Project::class,
        'task' => Task::class,
        'invoice' => Invoice::class,
    ]);
}
```

Zonder morph map staat `App\Models\Project` in `commentable_type`. Hernoem of verplaats je die class, dan vindt geen enkele comment zijn project nog. Met de map staat er `project`, en `enforce` gooit een fout als je een model vergeet. Zet hem er op dag één in: achteraf moet je bestaande rijen omzetten.

> **Toelichting:** enforceMorphMap gooit een exception voor een model dat niet in de map staat, morphMap zonder enforce valt terug op de class-naam. Kies de strengste. Voeg je de map toe aan een bestaand project, schrijf dan een migration die de oude class-namen in elke _type kolom vervangt.

<!-- slide 37 -->

### Zonder morph map verplaats je Invoice naar App\\Domain\\Invoices\\Models\\Invoice. Wat gebeurt er met de comments op facturen?

- A. Niets, Laravel volgt de class
- B. Ze vinden hun factuur niet meer
- C. Ze worden verwijderd
- D. De migration faalt

**Antwoord:** B. Ze vinden hun factuur niet meer

In commentable_type staat nog App\\Models\\Invoice, en die class bestaat niet meer. Laravel volgt geen verhuisde classes: de naam in de database is gewoon tekst. De rijen staan er nog, maar niets verwijst er correct naar. Met een morph map staat er invoice, en maakt de plaats van de class niet uit.

> **Toelichting:** Dit soort bug ontstaat vaak bij een refactor naar domain folders, zie de les over domain-driven design. Zonder morph map schrijf je bij elke verhuizing van een class een datamigration. Met de map verhuis je vrij.

<!-- slide 38 -->

## 5. Soft deletes en pruning

> **Toelichting:** Klanten drukken op verwijderen en merken een dag later dat het een vergissing was. Soft deletes geven je een prullenbak. Maar data die blijft staan, moet je ook ooit opruimen, en daarvoor is pruning.

<!-- slide 39 -->

### Soft deletes

- *model en migration*

  ```php
  class Project extends Model
  {
      use SoftDeletes;
  }

  $table->softDeletes();
  ```
- *gebruik*

  ```php
  $project->delete();
  $project->trashed(); // true

  Project::withTrashed()->get();
  Project::onlyTrashed()->get();

  $project->restore();
  $project->forceDelete();
  ```

`delete()` vult `deleted_at` in, en vanaf dan laat elke query het project weg: dat is een global scope. `restore()` haalt het terug, `forceDelete()` wist het echt. Docs: [Soft deleting](https://laravel.com/docs/13.x/eloquent#soft-deleting).

> **Toelichting:** Soft deletes toevoegen aan een bestaande tabel doe je met een migration die softDeletes() toevoegt, plus de trait op het model. restore() en forceDelete() werken ook op een query, zoals Project::onlyTrashed()->where(...)->restore(), maar dan zonder model events.

<!-- slide 40 -->

### Valkuilen van soft deletes

- Een `unique` index telt de verwijderde rijen mee. Een klant met hetzelfde btw-nummer opnieuw toevoegen, geeft een fout.
- `cascadeOnDelete` werkt alleen bij een echte delete. De projecten van een soft deleted klant blijven gewoon staan.
- Route model binding geeft een 404 op een soft deleted model, tenzij de route `withTrashed()` heeft.
- Soft delete is geen verwijderen voor de GDPR. Vraagt iemand om gewist te worden, dan moet het echt weg.

> **Toelichting:** Een unique index op vat_number en deleted_at samen lijkt een oplossing, maar null telt in de meeste databases niet als gelijk, dus dan mogen twee actieve klanten hetzelfde nummer hebben. Vaak laat je de unique index vallen en valideer je met Rule::unique('clients')->withoutTrashed(). Voor de cascade kan een deleting-event de projecten mee soft deleten.

<!-- slide 41 -->

### Pruning

*app/Models/Project.php*

```php
use Prunable;
use SoftDeletes;

public function prunable(): Builder
{
    return static::onlyTrashed()->where('deleted_at', '<=', now()->minus(months: 6));
}
```

Soft deleted projecten blijven anders voor altijd in je tabel. `prunable()` geeft de query terug van wat weg mag: alles wat meer dan zes maanden in de prullenbak zit. Een command ruimt die records dan op, met `forceDelete()`.

> **Toelichting:** Pruning werkt ook zonder soft deletes: voor logs, notificaties of exports die na een tijd weg mogen. Voor een model met SoftDeletes zet Laravel zelf withTrashed() op je prunable-query. onlyTrashed() maakt hier vooral duidelijk dat je alleen de prullenbak leegmaakt.

<!-- slide 42 -->

### Prunable of MassPrunable

```php
protected function pruning(): void
{
    Storage::deleteDirectory("projects/{$this->id}");
}
```

`Prunable` haalt elk model op, roept eerst `pruning()` aan en verwijdert het dan. Zo kan je ook de bestanden van het project opruimen. `MassPrunable` doet één delete-query: veel sneller, maar zonder `pruning()` en zonder model events.

> **Toelichting:** Een Prunable model wordt in blokken van 1000 verwerkt. Een exception bij één model wordt gerapporteerd en de rest gaat verder. MassPrunable past bij tabellen met veel rijen en geen bijhorende bestanden, zoals een activity log.

<!-- slide 43 -->

### model:prune in de scheduler

*routes/console.php*

```php
Schedule::command('model:prune')->daily();
```

```console
$ php artisan model:prune --pretend
12 [App\Models\Project] records will be pruned.
```

`model:prune` zoekt zelf alle prunable modellen in `app/Models`. Met `--pretend` telt hij alleen wat weg zou gaan. De scheduler zelf komt in de les over scheduling. Docs: [Pruning models](https://laravel.com/docs/13.x/eloquent#pruning-models).

> **Toelichting:** Staan je modellen buiten app/Models, geef ze dan mee met --model. Met --except sla je er een over. Draai --pretend eerst op een kopie van productie, zodat je zeker weet dat de query niet te veel meeneemt.

<!-- slide 44 -->

## 6. Model events

> **Toelichting:** Model events laten code lopen op het moment dat een model verandert, zonder dat de controller het weet. Dat is handig en gevaarlijk tegelijk. Dit hoofdstuk laat zien welke events wanneer vuren, zodat je bewust kiest.

<!-- slide 45 -->

### Model events

*app/Models/Project.php*

```php
protected static function booted(): void
{
    static::creating(function (Project $project) {
        $project->slug ??= Str::slug($project->name);
    });
}
```

Een model vuurt events op vaste momenten. Een event op `-ing` vuurt voor de query, en stopt de actie als je `false` teruggeeft. Een event op `-ed` vuurt erna. Hier krijgt elk nieuw project een slug, als er nog geen is. Docs: [Events](https://laravel.com/docs/13.x/eloquent#events).

> **Toelichting:** ??= vult de slug alleen in als hij nog null is, zodat een slug die je zelf meegeeft blijft staan. In booted() registreer je op dezelfde manier saving, deleted en de andere events. Met de property $dispatchesEvents koppel je een model event aan een eigen event-class.

<!-- slide 46 -->

### Welke events vuren

Kies een call links en vergelijk. Let op **delete()**, **save()** zonder wijziging en de update via **Project::where()**.

Welke model events vuren bij welke call, op een Project met SoftDeletes:

- Project::create([...]): saving → creating → created → saved
- $project->update(['name' => ...]): saving → updating → updated → saved
- $project->save() zonder wijziging: saving → saved. saving en saved vuren ook als er niets veranderd is.
- $project->delete(): deleting → trashed → deleted. Een soft delete is een update, maar updating en updated vuren niet.
- $project->restore(): restoring → saving → updating → updated → saved → restored
- $project->forceDelete(): forceDeleting → deleting → deleted → forceDeleted
- Project::where(...)->update([...]): geen events. Geen modellen opgehaald, dus ook geen events. Je observer ziet hier niets van.
- $project->saveQuietly(): geen events. Bewust stil, bijvoorbeeld in een observer die zelf het model opslaat.

> **Toelichting:** Het verschil tussen saving en creating: saving vuurt bij elke save, creating alleen de eerste keer. Wil je code bij zowel nieuw als aangepast, gebruik dan saving. trashed bestaat alleen op modellen met SoftDeletes.

<!-- slide 47 -->

### Observers

```php
class InvoiceObserver
{
    public function updated(Invoice $invoice): void
    {
        if (! $invoice->wasChanged('status')) {
            return;
        }

        if ($invoice->status === InvoiceStatus::Paid) {
            $invoice->client->notify(new InvoicePaid($invoice));
        }
    }
}
```

Een observer in `app/Observers` bundelt de events van één model. Je koppelt hem met het attribuut `ObservedBy` boven `Invoice`, met de observer-class als argument.

> **Toelichting:** wasChanged() kijkt naar wat de laatste save echt veranderd heeft, isDirty() naar wat nog niet opgeslagen is. In updated gebruik je wasChanged, want de save is dan al gebeurd. Maak een observer met php artisan make:observer InvoiceObserver --model=Invoice.

<!-- slide 48 -->

### Na de commit

```php
class InvoiceObserver implements ShouldHandleEventsAfterCommit
```

Stel: de factuur wordt op betaald gezet in een transaction, en een query verderop faalt. Alles wordt teruggedraaid, maar de observer had de mail al verstuurd. De klant krijgt een bevestiging voor een betaling die niet bestaat.

Met `ShouldHandleEventsAfterCommit` wacht de observer tot de transaction gelukt is. Zonder transaction loopt hij meteen. Docs: [Observers](https://laravel.com/docs/13.x/eloquent#observers).

> **Toelichting:** Voor events bestaat ShouldDispatchAfterCommit, en een job laat je met afterCommit() pas na de commit vertrekken. De les over queues gaat daar verder op in. Buiten een transaction merk je geen verschil, en daarom valt deze bug zelden op in een test.

<!-- slide 49 -->

### De boekhouder zet 40 facturen tegelijk op betaald, met één update via Invoice::whereIn(). Hoeveel mails stuurt de observer?

- A. 40
- B. 1
- C. 0
- D. Het hangt af van de queue

**Antwoord:** C. 0

Een update op de query builder haalt geen modellen op, dus er vuurt geen enkel model event. 40 lijkt logisch omdat er 40 facturen veranderen, maar de observer ziet alleen modellen die via save() of update() op een model gaan. Geen enkele klant krijgt een mail.

> **Toelichting:** Moet er toch per factuur iets gebeuren, loop dan over de modellen: Invoice::whereIn('id', $ids)->lazyById()->each->update(...). Dat is trager, maar elk event vuurt. Beter nog: roep expliciet een action aan die de mails verstuurt.

<!-- slide 50 -->

### Voorzichtig met observers

- Wat in een observer staat, zie je niet in de controller die opslaat. Wie de code leest, weet niet dat er een mail vertrekt.
- Stel: een observer stuurt een welkomstmail in created. Iemand importeert 3000 bestaande klanten met een seeder, en 3000 klanten krijgen een welkomstmail.
- Mass updates en upserts slaan observers stil over, dus een observer is geen garantie dat iets altijd gebeurt.
- Gebruik ze voor techniek: een slug, een uuid, een cache legen. Mails en betalingen horen in een action die je expliciet aanroept, zie de les over architectuur.

> **Toelichting:** Moet een seeder of import opslaan zonder events, gebruik dan Model::withoutEvents(fn () => ...) of saveQuietly(). In je tests zie je met Mail::fake() welke mails zouden vertrekken, zie de les over fakes.

<!-- slide 51 -->

## 7. Mass assignment

> **Toelichting:** Mass assignment is een van de oudste lekken in webframeworks: in 2012 kon iemand via zo'n lek in GitHub, zelf een Rails-app, zijn SSH-sleutel toevoegen aan de repository van Rails. Laravel beschermt je standaard, maar alleen als je begrijpt wat Fillable en Guarded doen.

<!-- slide 52 -->

### Mass assignment

- *fout*

  ```php
  $user->update($request->all());

  // POST name=Jan&is_admin=1
  ```
- *goed*

  ```php
  $user->update($request->validated());

  // alleen wat in rules() staat
  ```

Mass assignment is een model vullen met een array. `$request->all()` neemt elk veld uit het request, ook velden die niet in je formulier staan. Iemand voegt in de devtools `is_admin=1` toe, en is admin. Met `validated()` komt alleen door wat in je rules staat.

> **Toelichting:** Ook $request->only([...]) is veilig, maar dan herhaal je de velden die al in je rules staan. validated() haalt ze uit de validatie, zodat er maar één lijst is. Met een Form Request werkt het hetzelfde.

<!-- slide 53 -->

### Fillable, guarded of unguarded

- *Fillable*

  ```php
  #[Fillable([
      'name',
      'vat_number',
      'country',
  ])]
  class Client extends Model
  ```
- *Guarded*

  ```php
  #[Guarded([
      'id',
      'team_id',
  ])]
  class Client extends Model
  ```
- *Unguarded*

  ```php
  #[Unguarded]
  class Client extends Model
  ```

Fillable is een lijst van wat mag, Guarded van wat niet mag, Unguarded laat alles door. De properties `$fillable` en `$guarded` werken nog.

> [!NOTE]
> Kies Fillable in een team met juniors. Unguarded kan, als iedereen altijd `validated()` gebruikt. Guarded is de zwakste keuze: een nieuwe gevoelige kolom staat standaard open.

> **Toelichting:** Zet id, team_id, is_admin en andere kolommen die een gebruiker nooit zelf mag kiezen, nooit in Fillable. Die vul je expliciet in de code, zoals $client->team()->associate($team). Zo zie je bij het lezen waar die waarde vandaan komt.

<!-- slide 54 -->

### Je voegt de kolom phone toe aan clients, maar vergeet ze in Fillable. Wat gebeurt er als je een klant aanmaakt met de gevalideerde data?

- A. Een MassAssignmentException
- B. De klant wordt opgeslagen, zonder phone
- C. Een SQL-fout
- D. De klant wordt niet opgeslagen

**Antwoord:** B. De klant wordt opgeslagen, zonder phone

Een kolom die niet fillable is, wordt stil genegeerd. Een exception lijkt logisch, maar die krijg je alleen als je preventSilentlyDiscardingAttributes aanzet. Doe dat buiten productie: dan zie je de vergeten kolom meteen.

> **Toelichting:** Een MassAssignmentException krijg je wel als je create() doet op een model zonder Fillable of Guarded, want dan is alles geblokkeerd. Voeg bij elke nieuwe kolom meteen de migration, de Fillable en de validatieregel toe, dan vergeet je er geen.

<!-- slide 55 -->

### Strict mode

*app/Providers/AppServiceProvider.php*

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

| Check | Vangt |
| --- | --- |
| `preventLazyLoading` | Een N+1 in een lus |
| `preventSilentlyDiscardingAttributes` | Een kolom die niet fillable is |
| `preventAccessingMissingAttributes` | Een kolom die niet in je `select` stond |

Drie checks in één regel, lokaal en in je tests. Zet het aan op dag één van een project.

> **Toelichting:** preventAccessingMissingAttributes vangt bugs met select: haal je alleen id en name op en lees je daarna email, dan krijg je een exception in plaats van null. shouldBeStrict hoort in AppServiceProvider::boot, naast wat je daar al uit de les over queries zette.

<!-- slide 56 -->

### Opdracht: Bouw het model uit

Verder op het project van de les over queries: clients, projects, tasks, time_entries en invoices.

1. Een ProjectStatus-enum als cast, met label(), en een local scope active
2. AsMoney op invoices.total en op het uurtarief in een ProjectMember-pivot
3. Project::withSum via een hasManyThrough, en soft deletes met pruning na zes maanden
4. Klaar als een Pest-test een lid met tarief 9500 aan een project hangt en € 95,00 terugleest

Vast? Casts staan vanaf slide 5, de pivot vanaf slide 27, hasManyThrough op slide 32 en pruning op slide 41. De docs over casts: [Mutators and casting](https://laravel.com/docs/13.x/eloquent-mutators).

> **Toelichting:** Begin met de enum en de cast op status: klein, en je ziet meteen of je casts werken. Voor de pivot maak je eerst de migration en de relatie zonder eigen pivot model, en voeg je ProjectMember toe als de basis werkt. Hoe je een Pest-test met factories schrijft, zie je in de les over testen.

## Begrippen

- **Cast**: Een regel in casts() die zegt hoe een kolom een PHP-waarde wordt en terug. Een tinyint wordt een bool, een string een enum, een JSON-kolom een collection. (slide 5)
- **Enum cast**: Een kolom die als backed enum uit het model komt, zoals ProjectStatus::Active. Geen magic strings meer, en je enum kan zelf een label of een kleur geven. (slide 8)
- **AsCollection**: Een cast die een JSON-kolom als Laravel Collection teruggeeft. Anders dan met array kan je er een waarde in aanpassen en het model opslaan. (slide 10)
- **Custom cast**: Een class die CastsAttributes implementeert, met get() van de database naar PHP en set() terug. Handig als dezelfde omzetting op meerdere modellen terugkomt, zoals bedragen in centen naar een Money-object. (slide 12)
- **Value object**: Een klein object dat een waarde voorstelt, zoals Money, zonder eigen id. Het weet zelf hoe het zich formatteert of vergelijkt. (slide 11)
- **Accessor**: Een methode die een Attribute teruggeeft met get, zodat $entry->hours een berekende waarde geeft. Staat niet in toArray() tenzij je hem toevoegt met #\[Appends\]. (slide 16)
- **Mutator**: Het set-deel van een Attribute: past een waarde aan voor ze in het model komt, zoals een btw-nummer zonder spaties en punten. (slide 17)
- **Local scope**: Een herbruikbaar stuk query op het model, met #\[Scope\] boven een protected methode. Je roept het aan als Project::active(). (slide 20)
- **Global scope**: Een voorwaarde die Eloquent op elke query van een model zet, met #\[ScopedBy\] of addGlobalScope(). SoftDeletes is er een. Onzichtbaar in de code waar je de query leest. (slide 22)
- **Pivot table**: De tussentabel van een many to many, zoals project_user. Kan eigen kolommen hebben, zoals de rol en het uurtarief van iemand op een project. (slide 27)
- **Custom pivot model**: Een class die Pivot extendt en die je met using() aan een belongsToMany hangt. Zo krijgt de tussentabel casts en methodes, net als een gewoon model. (slide 30)
- **hasManyThrough**: Een relatie via een tussenliggend model: een project heeft time entries via zijn taken. Eloquent maakt er een join van, en withSum werkt er gewoon op. (slide 32)
- **Polymorphic relatie**: Een relatie waarbij één tabel bij verschillende modellen kan horen, met een \_type en een \_id kolom. Comments op projecten, taken en facturen in één tabel. (slide 34)
- **Morph map**: Een lijst die een korte naam zoals project koppelt aan een model-class. Met Relation::enforceMorphMap() staat die naam in de \_type kolom in plaats van App\\Models\\Project, en breekt je data niet als je een class verplaatst. (slide 36)
- **Soft delete**: Verwijderen door deleted_at in te vullen in plaats van de rij te wissen. Eloquent laat die rijen weg uit elke query, tot je withTrashed() of onlyTrashed() vraagt. (slide 39)
- **Pruning**: Oude records automatisch wissen met de trait Prunable of MassPrunable, een prunable() query en model:prune in de scheduler. Soft deleted records worden dan echt verwijderd. (slide 41)
- **Model events**: Momenten in het leven van een model waarop je code kan laten lopen: creating, updated, deleted en de rest. Vuren niet bij een update of delete op de query builder. (slide 44)
- **Observer**: Een class met een methode per model event, gekoppeld met #\[ObservedBy\]. Goed voor technische zaken zoals een slug of een cache, riskant voor mails en betalingen. (slide 47)
- **saveQuietly**: Slaat een model op zonder model events. Ook deleteQuietly, restoreQuietly en Model::withoutEvents(). (slide 46)
- **Mass assignment**: Een model vullen met een array, zoals create($data) of update($data). Zonder bescherming kan iemand met een extra veld in een request een kolom als is_admin aanpassen. (slide 51)
- **Fillable**: De kolommen die je via mass assignment mag vullen, met #\[Fillable\] of $fillable. Een kolom die er niet in staat, wordt stil genegeerd, tenzij je preventSilentlyDiscardingAttributes aanzet. (slide 53)
- **shouldBeStrict**: Model::shouldBeStrict() zet drie checks aan: geen lazy loading, geen stil genegeerde attributen, en een exception bij een attribuut dat niet opgehaald werd. (slide 55)

## Links en bronnen

### In de presentatie

- [Attribute casting](https://laravel.com/docs/13.x/eloquent-mutators#attribute-casting) (slide 6)
- [Custom casts](https://laravel.com/docs/13.x/eloquent-mutators#custom-casts) (slide 13)
- [Accessors and mutators](https://laravel.com/docs/13.x/eloquent-mutators#accessors-and-mutators) (slide 16)
- [Local scopes](https://laravel.com/docs/13.x/eloquent#local-scopes) (slide 21)
- [Global scopes](https://laravel.com/docs/13.x/eloquent#global-scopes) (slide 23)
- [Many to many](https://laravel.com/docs/13.x/eloquent-relationships#many-to-many) (slide 28)
- [Has many through](https://laravel.com/docs/13.x/eloquent-relationships#has-many-through) (slide 33)
- [Polymorphic relationships](https://laravel.com/docs/13.x/eloquent-relationships#polymorphic-relationships) (slide 35)
- [Soft deleting](https://laravel.com/docs/13.x/eloquent#soft-deleting) (slide 39)
- [Pruning models](https://laravel.com/docs/13.x/eloquent#pruning-models) (slide 43)
- [Events](https://laravel.com/docs/13.x/eloquent#events) (slide 45)
- [Observers](https://laravel.com/docs/13.x/eloquent#observers) (slide 48)
- [Eloquent: Mutators & Casting](https://laravel.com/docs/13.x/eloquent-mutators) (slide 56): Attribute::make, enum casts, AsCollection, custom casts

### Bronnen

- [Eloquent: Getting Started](https://laravel.com/docs/13.x/eloquent): Scopes, soft deletes, pruning, events, observers, Fillable en Unguarded
- [Eloquent: Relationships](https://laravel.com/docs/13.x/eloquent-relationships): Pivot-data, using(), hasManyThrough, polymorphic relaties, enforceMorphMap
- [Eloquent: Serialization](https://laravel.com/docs/13.x/eloquent-serialization): Hidden en Appends als attribute
- [Model.php in Laravel 13](https://github.com/laravel/framework/blob/13.x/src/Illuminate/Database/Eloquent/Model.php): Volgorde van de events in save() en delete()
- [SoftDeletes.php in Laravel 13](https://github.com/laravel/framework/blob/13.x/src/Illuminate/Database/Eloquent/SoftDeletes.php): trashed, restore en forceDelete
