Laravel Academy
Blog / Laravel Gevorderd

Laravel Gevorderd

Query scopes in Laravel: lokaal én globaal, zoals het hoort

Stop met dezelfde where-ketens in elke controller. Ontdek wanneer een local scope volstaat, wanneer een global scope een echte regel afdwingt, en welke valkuilen je bij beide ontwijkt.

J

Joram

Laravel-trainer & ontwikkelaar

01 okt 2026 · 10 min leestijd

Post::where('published', true)->where('user_id', $id) op vijf verschillende plekken schrijven is precies wat query scopes moeten voorkomen. Op dag één werkt het prima. Dan verandert de definitie van "gepubliceerd" — ingeplande posts, een embargodatum, een verborgen vlag — en zit je de codebase door te greppen, in de hoop dat je elke kopie hebt gevonden.

Wat je leert

  • Hoe je herhaalde where-ketens omzet naar benoemde, chainbare local scopes
  • Het #[Scope]-attribuut versus de klassieke scopeXxx-prefix
  • Scopes met parameters, en scopes binnen relaties, eager loads en whereHas
  • Wanneer een global scope het juiste gereedschap is — en wanneer het een valkuil is
  • Hoe je een global scope schrijft die veilig dichtvalt, en hoe je er bewust een uitschakelt

Voorkennis

  • Een Laravel 12- of 13-project. Het #[Scope]-attribuut is geïntroduceerd in Laravel 12; gebruik op oudere versies de scope-prefix uit stap 2.
  • Je kent Eloquent-models, relaties en de query builder, zoals behandeld in de Laravel Basis-reeks.
  • Een posts-tabel met user_id, team_id en een nullable published_at-timestamp.

Het idee erachter

Een local scope is een benoemd queryfragment dat op het model leeft. Je definieert het één keer, geeft het een naam die iets betekent, en roept het aan zoals elke andere builder-methode: Post::published()->latest()->get(). Het is opt-in: het geldt alleen als je erom vraagt.

Een global scope is een voorwaarde die Eloquent automatisch aan elke query op dat model toevoegt: get(), first(), count(), relatiequeries, route model binding, zelfs mass updates en deletes. Het is opt-out: het geldt, tenzij je het expliciet weghaalt. Je gebruikt er waarschijnlijk al een — SoftDeletes is een global scope die whereNull('deleted_at') toevoegt.

Dat verschil levert een simpele ontwerpregel op: local scopes zijn vocabulaire, global scopes zijn invarianten. "Gepubliceerd", "van deze auteur" en "populair deze week" zijn vocabulaire — die heb je in sommige queries nodig en in andere niet. "Een gebruiker ziet nooit data van een ander team" is een invariant — die moet blijven gelden, ook als een developer iets vergeet. Stel jezelf de vraag: als iemand deze voorwaarde vergeet, is dat dan een bug of gewoon een andere query? Een bug wijst naar een global scope. Een andere query wijst naar een local scope.

Stap voor stap

Stap 1 — Herken de duplicatie

Zo ziet het er meestal uit vóór scopes:

// PostController@index
$posts = Post::where('published', true)->latest()->paginate(10);

// DashboardController
$count = Post::where('published', true)->where('user_id', $user->id)->count();

// SitemapController
$posts = Post::where('published', true)->get(['slug', 'updated_at']);

// ...plus de RSS-feed, een console command en een admin-resource

Nu wil de business ingeplande posts: een post is gepubliceerd als published_at gevuld is en in het verleden ligt. Elke kopie hierboven is nu fout, en niets waarschuwt je.

Stap 2 — Je eerste local scope

namespace App\Models;

use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    #[Scope]
    protected function published(Builder $query): void
    {
        $query->whereNotNull('published_at')
              ->where('published_at', '<=', now());
    }
}

Gebruik:

$posts = Post::published()->latest()->paginate(10);

De eis voor ingeplande posts is nu één wijziging in één methode. Op Laravel 11 en ouder — of in een codebase die nog niet is overgestapt — gebruik je de prefix-conventie:

public function scopePublished(Builder $query): void
{
    $query->whereNotNull('published_at')
          ->where('published_at', '<=', now());
}

Je roept hem nog steeds aan als published(). Op actuele versies werken beide; kies één stijl per codebase en houd je eraan.

Stap 3 — Scopes met parameters

Elk argument na $query wordt een parameter van de scope:

use App\Models\User;

#[Scope]
protected function byAuthor(Builder $query, User $author): void
{
    $query->where('user_id', $author->getKey());
}

Scopes chain je zoals elke andere builder-methode:

$count = Post::published()->byAuthor($user)->count();

Gebruik een type-hint voor de parameter. Geef je per ongeluk een ID mee waar een User verwacht wordt, dan faalt het luid in plaats van dat je stilletjes de verkeerde rijen terugkrijgt.

Stap 4 — Scopes reizen mee met de builder

Scopes werken op elke Eloquent-builder voor het model, ook die achter relaties:

// Via een relatie
$user->posts()->published()->latest()->get();

// Een eager load beperken
$authors = User::with(['posts' => fn ($query) => $query->published()])->get();

// Parents filteren op hun children
$activeAuthors = User::whereHas('posts', fn ($query) => $query->published())->get();

Hier verdienen scopes zich echt terug: de betekenis van "gepubliceerd" reist mee met het model, niet met degene die toevallig de controller schreef.

Een geruststellend detail: als een scope orWhere-clauses toevoegt, zet Eloquent die tussen eigen haakjes. Een scope als deze kan dus niet per ongeluk de omringende voorwaarden opslokken:

#[Scope]
protected function highlighted(Builder $query): void
{
    $query->where('featured', true)->orWhere('pinned', true);
}

// where user_id = ? and (featured = 1 or pinned = 1)
Post::byAuthor($user)->highlighted()->get();

Stap 5 — Een global scope die een invariant afdwingt

Nu de invariant: elke post hoort bij een team, en een gebruiker ziet alleen posts van zijn eigen team. Genereer een scope-class:

php artisan make:scope TeamScope

Dat maakt app/Models/Scopes/TeamScope.php aan:

namespace App\Models\Scopes;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
use Illuminate\Support\Facades\Context;

class TeamScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        $teamId = Context::get('team_id');

        if ($teamId === null) {
            // Fail closed: geen teamcontext betekent geen rijen, nooit "alle rijen".
            $builder->whereRaw('1 = 0');

            return;
        }

        $builder->where($model->qualifyColumn('team_id'), $teamId);
    }
}

Twee details doen ertoe. qualifyColumn() maakt er posts.team_id van, zodat de scope geen "ambiguous column"-fouten veroorzaakt zodra iemand een join toevoegt. En de null-check zorgt dat de scope dichtvalt in plaats van openvalt (meer daarover bij de veelgemaakte fouten).

Het team-ID komt uit Laravels Context, gevuld door een kleine middleware:

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Context;
use Symfony\Component\HttpFoundation\Response;

class SetTeamContext
{
    public function handle(Request $request, Closure $next): Response
    {
        if ($user = $request->user()) {
            Context::add('team_id', $user->current_team_id);
        }

        return $next($request);
    }
}

Registreer hem in bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) {
    $middleware->web(append: [
        \App\Http\Middleware\SetTeamContext::class,
    ]);
})

Een prettige bijkomstigheid van Context: de data gaat mee naar queued jobs die tijdens het request worden gedispatcht, dus de scope blijft ook in die jobs werken.

Koppel de scope tot slot aan het model:

use App\Models\Scopes\TeamScope;
use Illuminate\Database\Eloquent\Attributes\ScopedBy;

#[ScopedBy([TeamScope::class])]
class Post extends Model
{
    // ...
}

Vanaf nu draait Post::all() als select * from posts where posts.team_id = ?. Net als Post::find($id), en net als Post::where(...)->delete(). Let op: een global scope filtert alleen queries — hij vult team_id niet in als je een post aanmaakt. Gebruik daarvoor een creating-model event of zet het veld expliciet.

Stap 6 — Anonieme global scopes

Voor een kleine voorwaarde die geen eigen class verdient, registreer je een closure in de booted()-methode van het model:

protected static function booted(): void
{
    static::addGlobalScope('not_archived', function (Builder $builder) {
        $builder->whereNull('archived_at');
    });
}

Onthoud de naam — die heb je nodig om de scope later weer weg te halen.

Stap 7 — Global scopes bewust uitschakelen

// Eén class-based scope
Post::withoutGlobalScope(TeamScope::class)->count();

// Eén closure-based scope, op naam
Post::withoutGlobalScope('not_archived')->get();

// Meerdere, of allemaal
Post::withoutGlobalScopes([TeamScope::class, 'not_archived'])->get();
Post::withoutGlobalScopes()->get();

Elke withoutGlobalScope-aanroep zet een invariant uit, dus behandel het als een regel die in code review een tweede blik verdient. Verpak het in een goed benoemde methode, dan is de intentie expliciet en makkelijk terug te vinden:

// In het Post-model
public static function acrossAllTeams(): Builder
{
    return static::withoutGlobalScope(TeamScope::class);
}

// In een admin-rapportage
$total = Post::acrossAllTeams()->count();

Veelgemaakte fouten

1. Een #[Scope]-methode public maken. Post::published() werkt juist omdat de methode protected is: PHP kan hem niet direct aanroepen, dus de statische aanroep loopt via de magic methods van Eloquent, die er een builder aan meegeven. Maak je de methode public, dan roept PHP hem direct — en statisch — aan, en krijg je Error: Non-static method App\Models\Post::published() cannot be called statically. Houd scopes met het attribuut dus protected. (De scopePublished-stijl is volgens conventie public; de prefix voorkomt daar de naamsbotsing.)

2. Een global scope die auth() leest. $builder->where('team_id', auth()->user()?->current_team_id) werkt in de browser. Dan draait er een scheduled command, een queued job of een Tinker-sessie zonder ingelogde gebruiker. Laravel maakt van where('team_id', null) een whereNull('team_id'), dus in plaats van een foutmelding krijg je stilletjes alle posts zonder team terug. Daarom valt de scope in stap 5 dicht en leest hij uit Context in plaats van uit de sessie.

3. Aannemen dat de global scope alles afdekt. Een global scope geldt voor Eloquent-queries: Post::..., relaties en route model binding. Hij geldt niet voor DB::table('posts'), raw SQL of validatieregels zoals Rule::exists('posts', 'id') — die gaan via de kale query builder, dus daar voeg je de teamvoorwaarde zelf toe. De omgekeerde verrassing komt ook voor: route model binding respecteert de scope wel, waardoor een admin-route als /admin/posts/{post} een 404 geeft voor een post van een ander team. Maak die uitzondering expliciet met een eigen binding:

// routes/web.php
Route::bind('anyPost', fn (string $value) => Post::acrossAllTeams()->findOrFail($value));

Route::get('/admin/posts/{anyPost}', [AdminPostController::class, 'show']);

Samengevat

  • Local scopes zijn herbruikbare, chainbare vocabulaire op het model. Ze zijn opt-in.
  • Global scopes dwingen invarianten af op elke Eloquent-query. Ze zijn opt-out.
  • Gebruik op Laravel 12+ #[Scope] op een protected methode; de scopeXxx-prefix werkt nog steeds.
  • Qualify in global scopes je kolommen en laat ze dichtvallen als de context ontbreekt.
  • withoutGlobalScope() zet een vangnet uit — houd het zeldzaam, benoemd en zichtbaar.

Hoe verder

Volgende stap: basic routing — waar route:list echt nuttig wordt. De volledige command reference en de workflow-gewoontes erachter zitten verwerkt in het trainingscurriculum.

En als je codebase op het punt is beland waar dezelfde where op tien plekken staat, of waar tenant-isolatie afhangt van één regel die elke developer moet onthouden: dat is precies het soort ding dat een codebase audit snel boven water haalt. Een audit of advisory retainer is een rustige manier om die plekken te vinden voordat zij jou vinden.

01 okt 2026 Laravel Gevorderd