Laravel Eloquent Global vs Local Scopes: Pitfalls, Ordering, and Scope Composition
Eloquent scopes are one of those features that feel obvious until a production bug teaches you otherwise. Global scopes silently modify every query on a model. Local scopes are chainable named constraints. Both are powerful — and both have sharp edges when composed at scale.
How Global Scopes Are Applied
Global scopes are registered in booted() and injected into every query builder instance for that model. The order of registration matters because each scope appends its own WHERE clauses, and some scopes wrap the query in a subquery or add JOINs that interact with later scopes.
class ActiveScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$builder->where($model->getTable() . '.active', true);
}
}
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$builder->where(
$model->getTable() . '.tenant_id',
app(TenantContext::class)->id()
);
}
}
Always qualify column names with the table alias. Without it, a JOIN added by another scope will cause an ambiguous column error that only surfaces in specific query paths.
The Soft-Delete Conflict
SoftDeletes registers its own global scope (SoftDeletingScope). If you add a custom global scope that also touches deleted_at, you risk double-wrapping conditions or accidentally shadowing the soft-delete filter when using withTrashed().
// Dangerous: your scope re-adds deleted_at logic
public function apply(Builder $builder, Model $model): void
{
$builder->whereNull('deleted_at')->where('active', true);
}
The fix is to check whether the soft-delete scope is already applied:
public function apply(Builder $builder, Model $model): void
{
if (in_array(SoftDeletes::class, class_uses_recursive($model), true)) {
// Let SoftDeletingScope handle deleted_at
$builder->where('active', true);
} else {
$builder->whereNull('deleted_at')->where('active', true);
}
}
Removing Global Scopes Selectively
withoutGlobalScope() accepts either the class name or the string key used during registration. Forgetting this causes subtle bugs when you need admin queries to bypass tenant isolation.
// Remove a single scope
Post::withoutGlobalScope(TenantScope::class)->get();
// Remove all global scopes
Post::withoutGlobalScopes()->get();
// Remove multiple specific scopes
Post::withoutGlobalScopes([TenantScope::class, ActiveScope::class])->get();
Document every global scope on the model with a @uses docblock so future engineers know what implicit filters exist.
Local Scopes and Composition
Local scopes are clean for composable, named constraints. The pitfall is returning void instead of Builder — doing so breaks chaining silently in older PHP versions (PHP 8+ will surface the type error).
public function scopePublished(Builder $query): Builder
{
return $query->where('status', Status::Published);
}
public function scopeForCategory(Builder $query, int $categoryId): Builder
{
return $query->where('category_id', $categoryId);
}
// Composing cleanly
$posts = Post::published()->forCategory(3)->latest()->get();
Scope Macros for Cross-Model Reuse
When the same constraint appears on multiple models, resist copy-pasting. Register a macro on the query builder:
// In a ServiceProvider
Builder::macro('activeOnly', function (): Builder {
/** @var Builder $this */
return $this->where($this->getModel()->getTable() . '.active', true);
});
// Usage on any model
User::activeOnly()->get();
Product::activeOnly()->get();
This avoids a trait-per-model approach while keeping the constraint in one place.
Testing Scopes in Isolation
Test global scopes by asserting the raw SQL, not just the result set:
it('applies tenant scope to all queries', function () {
$tenantId = 42;
app()->instance(TenantContext::class, new TenantContext($tenantId));
$sql = Post::toBase()->toSql();
expect($sql)->toContain('"tenant_id" = ?');
});
For local scopes, test the composed query and the returned collection separately to isolate scope logic from data fixtures.
Takeaways
- Always qualify column names in global scopes to survive
JOINs from other scopes. - Check for
SoftDeletesbefore adding your owndeleted_atconditions. - Use
withoutGlobalScope(ClassName::class)precisely — neverwithoutGlobalScopes()in production code unless you mean it. - Return
Builderexplicitly from local scopes;voidbreaks chaining. - Extract repeated constraints to a
Buildermacro rather than duplicating scope traits. - Assert raw SQL in scope tests, not just result counts.