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()insideapply()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 — useaddSelect()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
RefreshDatabaseand real factories rather than mocking the scope. - Avoid
now()andauth()insideapply()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.