Laravel Magazine
Eloquent Global Scopes: Enforcing Query Rules Application-Wide

Eloquent Global Scopes: Enforcing Query Rules Application-Wide

Eric Van Johnson ·

If you've ever added ->where('team_id', auth()->user()->team_id) to a dozen different queries scattered across controllers, jobs, and Livewire components, you already have a candidate for a global scope. Global scopes attach query constraints directly to the model, so every query — including ones you write six months from now — automatically includes them.

Writing a scope class

A global scope is any class implementing Scope, with an apply() method that receives the query builder and the model instance:

namespace App\Models\Scopes;

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

class TeamScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        if ($teamId = auth()->user()?->current_team_id) {
            $builder->where('team_id', $teamId);
        }
    }
}

Attach it in the model's booted() method:

namespace App\Models;

use App\Models\Scopes\TeamScope;
use Illuminate\Database\Eloquent\Model;

class Project extends Model
{
    protected static function booted(): void
    {
        static::addGlobalScope(new TeamScope);
    }
}

From here, Project::all(), Project::find(1), and Project::where('status', 'active')->get() all silently include the team constraint. A PhpArchitect support engineer can no longer accidentally query another team's projects just because they forgot a where() clause.

The closure shortcut

For simple, one-off constraints that don't warrant their own class, addGlobalScope() also accepts a closure with a name:

protected static function booted(): void
{
    static::addGlobalScope('published', function (Builder $builder) {
        $builder->where('published_at', '<=', now());
    });
}

Escaping the scope when you need to

Global scopes are enforced everywhere by default, which means you need an explicit way out for admin tooling or background jobs that legitimately need to see everything:

// Ignore one named or class-based scope
Project::withoutGlobalScope(TeamScope::class)->get();
Project::withoutGlobalScope('published')->get();

// Ignore every global scope on the model
Project::withoutGlobalScopes()->get();

// Ignore a specific list
Project::withoutGlobalScopes([TeamScope::class, 'published'])->get();

This is also where global scopes bite people: a scheduled command that reports "total projects across all teams" will silently under-count if it forgets withoutGlobalScopes(). Because the constraint is invisible at the call site, it's easy to forget it's there at all — document it well, and consider naming scope classes obviously (TeamScope, not Scope1) so a grep through the codebase turns up every model that has one.

Scopes vs. local query scopes

Don't confuse global scopes with local scopes (the scopeActive() style methods you call as Project::active()). Local scopes are opt-in per query; global scopes are opt-out. Reach for a global scope when a rule should be nearly impossible to bypass by accident — tenant isolation, soft-delete-style visibility rules, publication status. Reach for a local scope when the constraint is just a convenience and callers should be free to include or skip it depending on context.

Stay Updated

Subscribe to our newsletter

Get latest news, tutorials, community articles and podcast episodes delivered to your inbox.

Weekly articles
We send a new issue of the newsletter every week on Friday.
No spam
We'll never share your email address and you can opt out at any time.