Onlangs stuitte ik op een controller met acht vrijwel identieke where-chains verspreid over verschillende methoden. Telkens dezelfde filtercondities: actieve gebruikers, bepaalde rollen, soms nog een datumcheck erbij. Het was precies het soort herhaling dat je als developer wilt vermijden, maar toch blijf je het soms doen omdat het sneller lijkt dan het juist aanpakken. Query scopes in Laravel hadden dit probleem al jaren geleden kunnen oplossen, maar ik had ze veel te lang genegeerd.
Wat zijn query scopes eigenlijk
Eloquent kent twee varianten van scopes die elk hun eigen toepassing hebben. Lokale scopes activeer je handmatig wanneer je ze nodig hebt, terwijl globale scopes automatisch bij elke query worden toegevoegd. In mijn dagelijkse werk gebruik ik lokale scopes veruit het meest, omdat ze de flexibiliteit bieden die ik zoek zonder onverwachte bijwerkingen.
Een lokale scope is eigenlijk gewoon een methode op je model die begint met het woord scope. De rest van de methodenaam bepaalt hoe je hem later aanroept, maar dan zonder dat prefix. Laravel zorgt voor de vertaling tussen de twee. Het resultaat leest verrassend natuurlijk:
public function scopeActive($query)
{
return $query->where('active', true);
}
Deze scope roep je later aan als onderdeel van je query-keten:
$users = User::active()->get();
De elegantie zit hem in de leesbaarheid. Je hoeft niet meer naar de databasestructuur te kijken om te begrijpen wat er gebeurt. De intentie staat direct in de code, wat vooral waardevol wordt wanneer collega's of je toekomstige zelf de code moeten onderhouden.
Scopes combineren tot krachtige filters
Meerdere scopes combineren werkt precies zoals je zou verwachten. Elke scope voegt zijn eigen conditie toe aan de query builder, wat betekent dat je ze kunt stapelen:
public function scopeAdmin($query)
{
return $query->where('role', 'admin');
}
Beide scopes samen gebruiken:
$admins = User::active()->admin()->get();
Dit patroon maakt complexe queries opbouwbaar uit kleinere, herbruikbare onderdelen. In plaats van één grote where-clausule die moeilijk te begrijpen is, krijg je een reeks betekenisvolle stappen. Elk onderdeel kun je apart testen, wat de betrouwbaarheid van je code verhoogt.
Parameters maken scopes nog flexibeler. Een scope hoeft niet vastgepind te zijn op één specifieke waarde:
public function scopeWithRole($query, string $role)
{
return $query->where('role', $role);
}
Nu kun je dezelfde logica hergebruiken voor verschillende rollen:
$editors = User::active()->withRole('editor')->get();
$moderators = User::active()->withRole('moderator')->get();
Deze aanpak voorkomt dat je dezelfde filterlogica op verschillende plekken moet schrijven. Je definieert het een keer in het model, test het daar, en gebruikt het overal waar nodig. Bugs in filterlogica worden hierdoor veel zeldzamer.
Complexe scenarios met relaties
Query scopes worden echt krachtig wanneer je ze combineert met Eloquent-relaties en eager loading. Stel je wilt alle actieve administrators ophalen inclusief hun recente bestellingen:
$users = User::active()->admin()->with('orders')->get();
Ik gebruik scopes vaak als basis voor queries die later in de controller verder worden aangepast op basis van gebruikersinput. Een zoekfunctie begint bijvoorbeeld met een basis-scope voor zichtbare records, waarna filters voor categorie, prijs of datum worden toegevoegd. De scope zorgt voor de fundamentele regels, de controller handelt de variabele onderdelen af.
Daarnaast zijn scopes handig voor queries die afhankelijk zijn van de context waarin ze worden uitgevoerd. Een scopeOwnedBy scope kan bijvoorbeeld het huidige gebruikers-ID gebruiken om automatisch te filteren op eigendom, zonder dat elke controller dit expliciet hoeft te regelen.
public function scopeOwnedBy($query, User $user)
{
return $query->where('user_id', $user->id);
}
Globale scopes als automatische filters
Globale scopes voegen zichzelf automatisch toe aan elke query voor een bepaald model. Laravel's soft delete functionaliteit is hiervan het bekendste voorbeeld: de SoftDeletes trait voegt automatisch WHERE deleted_at IS NULL toe aan al je queries, tenzij je expliciet aangeeft dat je ook verwijderde records wilt zien.
Zelf maak ik soms gebruik van globale scopes voor multi-tenant applicaties. Een OrganizationScope kan er bijvoorbeeld voor zorgen dat gebruikers automatisch alleen data van hun eigen organisatie zien, ongeacht welke query ze uitvoeren. Dit type scope werkt als een veiligheidsnet: ook als een developer vergeet te filteren op organisatie, gebeurt dit automatisch.
Let wel op dat globale scopes ook actief zijn bij relaties. Dat kan soms tot verrassend gedrag leiden wanneer je verwacht dat gerelateerde records zichtbaar zijn, maar een globale scope ze wegfiltert. Laravel biedt withoutGlobalScope() als escape hatch:
User::withoutGlobalScope(ActiveScope::class)->get();
Praktische tips voor dagelijks gebruik
Naamgeving van scopes verdient extra aandacht. Een methode die scopeRecent heet zegt weinig over wat "recent" precies betekent. Betekent dat de laatste week, maand, of de meest recente vijf records? Ik voeg altijd een PHPDoc-commentaar toe die het exacte gedrag beschrijft. Toekomstige ontwikkelaars zullen je dankbaar zijn.
Houd scopes focused op één verantwoordelijkheid. Een scope die tegelijk filtert, sorteert en een join uitvoert is moeilijk te combineren met andere scopes. Splits zulke scopes op in kleinere onderdelen die elk hun eigen taak hebben. Dit maakt je code voorspelbaarder en gemakkelijker te debuggen.
Testing wordt een stuk eenvoudiger met goed geschreven scopes. Je kunt elke scope in isolatie testen door te controleren of de juiste SQL-query wordt gegenereerd. Dit is betrouwbaarder dan integratie-tests die de hele applicatie doorlopen, omdat je precies weet wat er getest wordt.
Query scopes beschouw ik tegenwoordig als onderdeel van de publieke interface van een model. Net zoals relaties en accessors definiëren ze hoe andere delen van de applicatie met dat model kunnen communiceren. Deze mindset leidt ertoe dat ik een scope schrijf voor elke operatie die ik meer dan één keer uitvoer. Het resultaat is een consistentere codebase waarin domeinkennis op de juiste plek wordt vastgelegd: in het model zelf.