skip to content

In Laravel, how does an Eloquent global scope such as a firm filter reach every model query, and how is it registered and bypassed?

level: middleimportance: must knowfreq 55%

answer

  1. Scope interface, apply(builder, model)
  2. make:scope into app/Models/Scopes
  3. #[ScopedBy] or addGlobalScope in booted()
  4. applied when the query is built
  5. withoutGlobalScope(Class) per query

basics

~20 s

A global scope is a class implementing the Eloquent Scope interface, or a named closure, registered with #[ScopedBy] or addGlobalScope() in booted(). Eloquent adds its constraint to every query of the model; withoutGlobalScope() removes it for one query.

solid answer

~40 s

`php artisan make:scope FirmScope` creates a class in `app/Models/Scopes` implementing `Scope` with one method, `apply(Builder $builder, Model $model)`, where you add `$builder->where('firm_id', ...)`. Register it with `#[ScopedBy([FirmScope::class])]` on the model, or with `static::addGlobalScope(new FirmScope)` in `booted()`; a closure needs a name, `addGlobalScope('firm', fn (Builder $b) => ...)`. Eloquent stores scopes per model class and applies them when the query is turned into SQL, so they reach `Matter::all()`, `find()`, relation queries, `whereHas()` subqueries, route model binding and builder-level `update()` and `delete()`. `Matter::withoutGlobalScope(FirmScope::class)` (or the closure's name) removes one for a single query; `withoutGlobalScopes()` removes all, or those listed; `withoutGlobalScopesExcept([...])` keeps only the listed ones.

code

php · 16 lines
php
<?php

namespace App\Models;

use App\Models\Scopes\FirmScope;
use Illuminate\Database\Eloquent\Attributes\ScopedBy;
use Illuminate\Database\Eloquent\Model;

#[ScopedBy([FirmScope::class])]
class Matter extends Model
{
    // every Matter query now carries: where matters.firm_id = ?
}

// one-off cross-firm report for a platform admin
$total = Matter::withoutGlobalScope(FirmScope::class)->count();

go deeper

for a junior

Recall that a global scope adds a constraint to every query of a model and that withoutGlobalScope() removes it for one query.

for a middle

Explain the Scope interface, the three registration styles, when scopes are applied and which bypass methods exist.

for a senior

Design tenant scopes that read context at query time, qualify columns, and audit every bypass in code review.

for a principal

Decide whether tenancy lives in global scopes, separate databases or schemas, weighing leak risk, operability and reporting needs.

## What a global scope is A **global scope** is a constraint Eloquent adds to **every** query of a model unless the query opts out. In a multi-tenant legal-case manager, each law firm must see only its own matters. Relying on every developer to write `where('firm_id', ...)` is how data leaks; a global scope makes the filter the default. ## Writing one ```php namespace App\Models\Scopes; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Scope; class FirmScope implements Scope { public function apply(Builder $builder, Model $model): void { $builder->where($model->qualifyColumn('firm_id'), app(CurrentFirm::class)->id()); } } ``` `php artisan make:scope FirmScope` generates the class; it lands in `app/Models/Scopes` when an `app/Models` directory exists. Qualifying the column (`matters.firm_id`) avoids ambiguity once the query joins another table. ## Registering it | Way | Syntax | Scope identifier for removal | |---|---|---| | Attribute on the model | `#[ScopedBy([FirmScope::class])]` | the class name | | `booted()` with an instance | `static::addGlobalScope(new FirmScope)` | the class name | | `booted()` with a named closure | `static::addGlobalScope('firm', fn (Builder $b) => ...)` | `'firm'` | | Unnamed closure | `static::addGlobalScope(fn (Builder $b) => ...)` | an object ID you cannot easily name | Scopes are stored statically per model class, so registration happens once per process, while `apply()` runs for every query. That is why `apply()` should read the current firm at query time rather than capture it at boot. ## Where it applies Eloquent applies registered scopes when the builder is converted to SQL. That covers: - reads: `get()`, `first()`, `find()`, `paginate()`, `count()`, `exists()`; - relation queries: `$client->matters`, and the related model's scopes inside `whereHas()` and `withCount()` subqueries; - route model binding, which resolves through a model query; - builder-level writes: `Matter::where(...)->update([...])` and `->delete()`. ## Bypassing it - `Matter::withoutGlobalScope(FirmScope::class)` removes one scope for this query; for a closure, pass its name. - `Matter::withoutGlobalScopes()` removes every global scope; pass an array to remove only those. - `Matter::withoutGlobalScopesExcept([SoftDeletingScope::class])` removes all but the listed ones. - `$builder->removedScopes()` reports what a query has removed, useful in tests and audits. Bypasses are per query; they never unregister the scope for later queries. ## How the constraint is grouped When Eloquent applies a scope, it wraps the clauses the scope adds so they combine correctly with the rest of the query. A scope that adds `where('firm_id', 1)->orWhere('shared', true)` therefore does not break a caller's `where('status', 'open')`: the scope's clauses are grouped as a unit. The comment in the framework's `applyScopes()` states this intent, and it is why a scope may use `orWhere` internally without leaking the OR into the outer query. ## A closure scope for simple cases ```php protected static function booted(): void { static::addGlobalScope('firm', function (Builder $builder) { $builder->where('matters.firm_id', app(CurrentFirm::class)->id()); }); } ``` A named closure is enough for a one-model filter; a class earns its keep when several models share the rule or it needs tests of its own. ## What it does not reach A global scope is part of the **Eloquent** builder. `DB::table('matters')` queries, raw SQL and anything else that never builds a `Matter` query skip it. Soft deletes are themselves implemented as a global scope, which is why `withTrashed()` is really a scope removal. ## Local versus global in one line Local scopes are opt-in vocabulary; global scopes are opt-out policy. Use the global form when forgetting the filter would be a bug.

  • Why should apply() look up the current firm instead of the scope capturing it in its constructor?
    Global scopes are registered once per model class and kept in a static array for the life of the process. A value captured at registration would be reused by every later query in that process: across queue jobs in one worker, or across requests under a long-lived server. Resolving the firm inside `apply()` reads it fresh for each query.
  • How would you check in a test that a query really removed the firm scope?
    Call `removedScopes()` on the builder, which returns the identifiers of scopes removed with `withoutGlobalScope()` or `withoutGlobalScopes()`. For behaviour, seed matters for two firms and assert the normal query sees one firm while the bypassed query sees both.

saying these in an interview costs you the question

  • Believing a global scope filters DB::table() queries too
  • Thinking withoutGlobalScope() unregisters the scope permanently
  • Registering an unnamed closure and expecting to remove it by name
  • Capturing the current tenant when the scope is registered
  • Saying global scopes skip relation and whereHas queries