Eloquent Query Scopes at Scale: Global, Local, and Pending Scope Internals
#laravel #eloquent #database #architecture

Eloquent Query Scopes at Scale: Global, Local, and Pending Scope Internals

2 min read Mohamed Said Mohamed Said

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 after query().
  • 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.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can a global scope cause problems with Eloquent eager loading?
Not usually — eager loading creates a fresh builder for the related model, so global scopes on that model apply correctly. The risk is when a global scope uses unqualified column names and the eager load involves a join internally, which can cause ambiguity errors.
Q02 Is it safe to call withoutGlobalScope() inside a repository method?
Yes, but document it clearly. Because scopes are applied lazily, calling withoutGlobalScope() before the query executes is always safe. The danger is accidentally bypassing tenant isolation — restrict such calls to explicitly privileged service classes and cover them with tests.
Q03 Do local scopes work with cursor() and lazy collections?
Yes. cursor() and lazyById() both go through the same Eloquent builder pipeline, so all applied local and global scopes are respected. The difference is in how rows are hydrated — one at a time via a generator — not in how the WHERE clauses are built.

Continue reading

More Articles

View all