Eloquent Query Scopes at Scale
Scopes feel simple until they silently corrupt a join, double-apply a where, or make a multi-tenant query leak rows. Understanding what happens inside Builder::applyScopes() changes how you design them.
How Eloquent Applies Scopes Internally
Every Model::query() call returns an Illuminate\Database\Eloquent\Builder wrapping a base QueryBuilder. Global scopes are stored on the model class and applied lazily — not when you call query(), but when the builder is about to compile SQL. The entry point is Builder::applyScopes():
// Simplified from Illuminate\Database\Eloquent\Builder
public function applyScopes(): static
{
foreach ($this->model->getGlobalScopes() as $identifier => $scope) {
if (! isset($this->removedScopes[$identifier])) {
$scope->apply($this, $this->model);
}
}
return $this;
}
This lazy evaluation is why you can call withoutGlobalScope(TenantScope::class) after Model::query() and it still works — the scope hasn't run yet.
Global Scope Pitfall: Ambiguous Columns on Joins
The classic trap: your TenantScope adds ->where('tenant_id', tenant()). Then a join brings in another table with a tenant_id column.
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
// BAD: ambiguous when joined
$builder->where('tenant_id', tenant()->id);
}
}
PostgreSQL and MySQL both throw Column 'tenant_id' in where clause is ambiguous. Fix it by qualifying the column:
public function apply(Builder $builder, Model $model): void
{
$table = $model->getTable();
$builder->where("{$table}.tenant_id", tenant()->id);
}
Always qualify columns in global scopes. It costs nothing and prevents production incidents.
Local Scopes: Composable by Design
Local scopes should be narrow and composable, not a grab-bag of business logic. A scope that does too much becomes impossible to reuse.
class Order extends Model
{
public function scopePending(Builder $query): Builder
{
return $query->where('status', OrderStatus::Pending);
}
public function scopeOlderThan(Builder $query, Carbon $date): Builder
{
return $query->where('created_at', '<', $date);
}
public function scopeWithCustomer(Builder $query): Builder
{
return $query->with('customer:id,name,email');
}
}
Now you compose at the call site:
$orders = Order::pending()
->olderThan(now()->subDays(7))
->withCustomer()
->cursor(); // cursor() respects all scopes
Each scope is independently testable:
it('filters pending orders', function () {
Order::factory()->create(['status' => OrderStatus::Pending]);
Order::factory()->create(['status' => OrderStatus::Shipped]);
expect(Order::pending()->count())->toBe(1);
});
The withoutGlobalScope Escape Hatch
Sometimes you genuinely need to bypass a global scope — a background job reconciling all tenants, for example. Use the named escape hatch:
// Remove one scope by class
$allOrders = Order::withoutGlobalScope(TenantScope::class)->get();
// Remove all global scopes (use sparingly)
$allOrders = Order::withoutGlobalScopes()->get();
Document every withoutGlobalScopes() call with a comment explaining why. It's a code smell that should be visible in review.
Pending Scopes and Eager Loading
When you eager-load a relationship, Eloquent creates a fresh builder for the related model. Global scopes on that model apply independently. This is correct behaviour, but it means a TenantScope on Customer will also filter the eager-loaded customers — which is usually what you want, but worth verifying in tests:
it('eager load respects tenant scope on related model', function () {
$order = Order::factory()
->for(Customer::factory()->create())
->create();
$loaded = Order::with('customer')->find($order->id);
expect($loaded->customer)->not->toBeNull();
});
Takeaways
- Global scopes are applied lazily at SQL compile time, so
withoutGlobalScope()works even afterquery(). - Always qualify column names in global scopes to prevent ambiguity on joins.
- Keep local scopes narrow and single-purpose; compose them at the call site.
- Eager-loaded relationships get their own builder — global scopes on related models apply independently.
- Every
withoutGlobalScopes()call deserves a comment; treat it as a deliberate bypass, not a convenience.