Why Global Scopes Deserve More Respect
Global scopes are one of Eloquent's most powerful — and most quietly dangerous — features. Drop one on a model and every SELECT, UPDATE, and DELETE that touches that model is silently filtered. That's exactly what you want for soft deletes or tenant isolation, but it's a footgun when you forget the scope is there.
This article goes beyond the docs: bootable trait patterns, named vs anonymous scopes, safe removal, and the Pest testing patterns that catch regressions.
Named vs Anonymous Scopes
The framework supports two registration styles:
// Anonymous — hard to remove by reference
protected static function booting(): void
{
static::addGlobalScope(new ActiveScope);
}
// Named string key — removable anywhere
protected static function booting(): void
{
static::addGlobalScope('active', function (Builder $builder) {
$builder->where('is_active', true);
});
}
Always prefer a dedicated Scope class or a string key. Anonymous closures registered without a key can only be removed by class reference, which is impossible for closures.
Bootable Trait Pattern
Encapsulate scope registration inside a trait so any model can opt in without touching booting():
trait HasActiveScope
{
public static function bootHasActiveScope(): void
{
static::addGlobalScope(new ActiveScope);
}
}
class ActiveScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$builder->where($model->getTable() . '.is_active', true);
}
}
Laravel automatically calls boot{TraitName}() during model boot. The trait is self-contained, testable in isolation, and composable.
Qualify the column name ($model->getTable() . '.is_active') to avoid ambiguous column errors in joins — a common production bug.
Removing Scopes Without Breaking Encapsulation
// Remove by class — works for class-based scopes
User::withoutGlobalScope(ActiveScope::class)->get();
// Remove by string key — works for closure scopes
User::withoutGlobalScope('active')->get();
// Remove all global scopes for an admin report
User::withoutGlobalScopes()->get();
A common mistake is calling withoutGlobalScopes() in a repository method and forgetting it also strips soft-delete scopes. Be explicit:
User::withoutGlobalScope(ActiveScope::class)
->withTrashed() // re-apply soft delete awareness explicitly
->get();
Scope Interaction With Updates and Deletes
Global scopes apply to UPDATE and DELETE statements too:
// Only updates active users — silent data integrity guarantee
User::where('plan', 'free')->update(['trial_ends_at' => now()]);
This is intentional for tenant isolation but can mask bugs. If an admin action needs to touch all records, remove the scope explicitly rather than working around it with raw queries.
Testing Global Scopes With Pest
The most common CI lie: a test passes because the factory creates active records by default, so the scope never actually gets exercised.
it('excludes inactive users from default queries', function () {
User::factory()->create(['is_active' => true]);
User::factory()->create(['is_active' => false]);
expect(User::count())->toBe(1);
});
it('returns inactive users when scope is removed', function () {
User::factory()->create(['is_active' => false]);
expect(
User::withoutGlobalScope(ActiveScope::class)->count()
)->toBe(1);
});
it('scope qualifies column in joins', function () {
// Trigger a join that would cause ambiguous column without qualification
$sql = User::join('roles', 'roles.user_id', '=', 'users.id')
->toRawSql();
expect($sql)->toContain('users.is_active');
});
Always create at least one record that should be excluded to prove the scope is doing work.
Takeaways
- Use class-based scopes or string keys — never anonymous closures you can't remove.
- The
bootHasActiveScope()trait pattern keeps scope registration encapsulated and composable. - Qualify column names with the table prefix to survive joins.
withoutGlobalScope()also strips soft-delete scopes unless you re-apply them.- Write tests that assert excluded records are excluded, not just that included records appear.