Laravel Eloquent Global vs Local Scopes: Pitfalls, Ordering, and Scope Composition
#laravel #eloquent #database #testing #architecture

Laravel Eloquent Global vs Local Scopes: Pitfalls, Ordering, and Scope Composition

4 min read Mohamed Said Mohamed Said

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 SoftDeletes before adding your own deleted_at conditions.
  • Use withoutGlobalScope(ClassName::class) precisely — never withoutGlobalScopes() in production code unless you mean it.
  • Return Builder explicitly from local scopes; void breaks chaining.
  • Extract repeated constraints to a Builder macro rather than duplicating scope traits.
  • Assert raw SQL in scope tests, not just result counts.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can two global scopes on the same model conflict with each other?
Yes. If both scopes add conditions on the same column, or if one adds a JOIN that makes a column in the other scope ambiguous, you will get SQL errors or silently wrong results. Always qualify column names with the table name and test scopes together, not just in isolation.
Q02 When should I use a global scope versus a local scope?
Use a global scope only when a constraint must apply to every query on a model without exception — tenant isolation and soft deletes are the canonical cases. Use a local scope for opt-in constraints that callers compose explicitly. Overusing global scopes makes queries unpredictable and harder to debug.
Q03 Does withoutGlobalScope affect eager-loaded relationships?
No. Calling withoutGlobalScope on the parent model does not propagate to eager-loaded relationships. Each relationship query boots its own model instance and re-applies all registered global scopes. You must call withoutGlobalScope inside the relationship closure if you need to bypass it there too.

Continue reading

More Articles

View all