Laravel Eloquent Global Scopes: Pitfalls, Testing, and Composing Them Safely
#laravel #eloquent #testing #pest #database

Laravel Eloquent Global Scopes: Pitfalls, Testing, and Composing Them Safely

1 min read Mohamed Said Mohamed Said

Eloquent Global Scopes: Pitfalls, Testing, and Composing Them Safely

Global scopes are one of Eloquent's most useful — and most quietly dangerous — features. Applied correctly they eliminate repetitive where clauses across an entire codebase. Applied carelessly they produce queries that silently exclude data, break eager loads, or conflict with one another in ways that only surface under production load.

This article focuses on the practical problems that appear once you have more than one global scope on a model, and how to handle them deliberately.

How Global Scopes Actually Attach to Queries

Every time Eloquent builds a new Builder instance for a model it calls registerGlobalScopes, which iterates the scopes registered via static::addGlobalScope() in booted() and calls $builder->withGlobalScope($identifier, $scope). The identifier is the key used later by withoutGlobalScope().

class PublishedScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        $builder->where('published_at', '<=', now())
                ->whereNull('deleted_at');
    }
}

Registering it:

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

The scope identifier defaults to the class name. That matters the moment you want to remove it selectively.

The Multi-Scope Conflict Problem

Suppose Article also has a TenantScope that adds where('team_id', auth()->id()). Both scopes independently append where clauses. This works fine until you write a relationship that needs to bypass one but not the other:

// Removes ALL global scopes — too blunt
$articles = Article::withoutGlobalScopes()->where('team_id', $teamId)->get();

// Removes only the tenant scope — surgical
$articles = Article::withoutGlobalScope(TenantScope::class)
    ->where('team_id', $teamId)
    ->get();

Using string class names as identifiers is the safest approach. Avoid anonymous closures as scopes in production code — they cannot be removed by name.

Scopes That Touch Joins

A scope that adds a join is the most fragile kind. If two scopes join the same table, or if user code also joins it, you get ambiguous column errors at runtime.

public function apply(Builder $builder, Model $model): void
{
    // Guard against duplicate joins
    $joins = collect($builder->getQuery()->joins ?? []);
    if ($joins->pluck('table')->contains('categories')) {
        return;
    }

    $builder->join('categories', 'categories.id', '=', 'articles.category_id')
            ->addSelect('categories.name as category_name');
}

This is defensive but necessary when scopes are composed.

Testing Global Scopes with Pest

Test scopes in isolation first, then test their interaction on the model.

use App\Models\Article;
use App\Scopes\PublishedScope;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

it('excludes unpublished articles by default', function () {
    Article::factory()->create(['published_at' => now()->addDay()]);
    Article::factory()->create(['published_at' => now()->subDay()]);

    expect(Article::count())->toBe(1);
});

it('includes unpublished articles when scope is removed', function () {
    Article::factory()->count(2)->create(['published_at' => now()->addDay()]);

    expect(Article::withoutGlobalScope(PublishedScope::class)->count())->toBe(2);
});

For scopes that depend on auth() or the request cycle, bind a fake in the test rather than mocking the scope itself:

it('scopes articles to the authenticated team', function () {
    $team = Team::factory()->create();
    actingAs(User::factory()->for($team)->create());

    Article::factory()->for($team)->count(3)->create();
    Article::factory()->count(2)->create(); // different team

    expect(Article::count())->toBe(3);
});

Composing Scopes Without Surprises

A few rules that prevent the most common production incidents:

  • Name every scope with its class string — never register two scopes with the same identifier.
  • Avoid now() inside apply() when the scope is used in queued jobs; pass the timestamp as a constructor argument so it is fixed at dispatch time.
  • Never add select * inside a scope — use addSelect() to avoid clobbering explicit selects in calling code.
  • Document which scopes a model carries in a docblock or a dedicated Scopes/ directory — they are invisible to readers of the model class.

Takeaways

  • Global scopes attach by class-name identifier; always remove them by that identifier, never blindly with withoutGlobalScopes().
  • Scopes that join tables must guard against duplicate joins.
  • Test scopes in isolation and in combination; use RefreshDatabase and real factories rather than mocking the scope.
  • Avoid now() and auth() inside apply() for scopes used outside the HTTP cycle — inject dependencies via the constructor.
  • Keep scopes single-purpose; composing two narrow scopes is safer than one wide scope that does too much.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can I apply a global scope conditionally, for example only during HTTP requests?
Yes. Inside `apply()` you can check `app()->runningInConsole()` or inspect the request and return early without adding any constraints. However, a cleaner approach is to not register the scope at all in certain contexts by wrapping `static::addGlobalScope()` in the same condition inside `booted()`.
Q02 Why does withoutGlobalScopes() sometimes break eager loading?
Eager load queries are separate Builder instances. If you call `withoutGlobalScopes()` on the parent query, it does not propagate to the eager load sub-queries. You must also call `withoutGlobalScopes()` on the relationship definition, or override the relationship to use `withoutGlobalScope(SpecificScope::class)` on the related model's query.
Q03 Is there a performance cost to having many global scopes?
The registration loop is negligible. The real cost is query complexity: each scope that adds a join or a subquery increases the work the database planner must do. Profile with EXPLAIN ANALYZE when stacking more than two or three non-trivial scopes on a heavily queried model.

Continue reading

More Articles

View all