In Laravel, how does an Eloquent global scope such as a firm filter reach every model query, and how is it registered and bypassed?
answer
- Scope interface, apply(builder, model)
- make:scope into app/Models/Scopes
- #[ScopedBy] or addGlobalScope in booted()
- applied when the query is built
- withoutGlobalScope(Class) per query
basics
~20 sA 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
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
Recall that a global scope adds a constraint to every query of a model and that withoutGlobalScope() removes it for one query.
Explain the Scope interface, the three registration styles, when scopes are applied and which bypass methods exist.
Design tenant scopes that read context at query time, qualify columns, and audit every bypass in code review.
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