Eloquent Query Scopes: Global, Local, and Dynamic Scopes Without the Magic Tax
#laravel #eloquent #database #php

Eloquent Query Scopes: Global, Local, and Dynamic Scopes Without the Magic Tax

3 min read Mohamed Said Mohamed Said

Eloquent Query Scopes: Global, Local, and Dynamic Scopes Without the Magic Tax

Query scopes are deceptively simple. You add scopeActive to a model, call ->active() on a query, and everything works. Then six months later a colleague spends two hours debugging why a count query returns zero — because a global scope silently filtered it out.

This article is about writing scopes that are powerful and honest: easy to discover, easy to test, and easy to remove when they're wrong.


Global Scopes: Powerful but Dangerous

A global scope applies to every query on a model. Laravel's own SoftDeletes trait is the canonical example. The problem is that global scopes are invisible at the call site.

// app/Models/Scopes/PublishedScope.php
use Illuminate\Database\Eloquent\{Builder, Model, Scope};

final class PublishedScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        $builder->whereNotNull('published_at')
                ->where('published_at', '<=', now());
    }
}
// app/Models/Article.php
protected static function booted(): void
{
    static::addGlobalScope(new PublishedScope());
}

Rule of thumb: only use a global scope when the filtered-out rows are semantically invalid for every consumer — like soft-deleted records. For business rules that vary by context (admin vs. public), prefer a local scope and call it explicitly.

When you need to escape a global scope, be explicit:

// Readable — the intent is clear
Article::withoutGlobalScope(PublishedScope::class)->count();

Local Scopes: The Workhorse

Local scopes are the right default. They're opt-in, chainable, and immediately visible in the query chain.

// app/Models/Article.php
public function scopePublished(Builder $query): Builder
{
    return $query->whereNotNull('published_at')
                 ->where('published_at', '<=', now());
}

public function scopeByAuthor(Builder $query, int $authorId): Builder
{
    return $query->where('author_id', $authorId);
}

public function scopeRecent(Builder $query, int $days = 30): Builder
{
    return $query->where('published_at', '>=', now()->subDays($days));
}

Chaining reads naturally:

$articles = Article::published()
    ->byAuthor($user->id)
    ->recent(7)
    ->orderByDesc('published_at')
    ->cursorPaginate(20);

Return Builder explicitly — it enables static analysis tools like PHPStan and Larastan to follow the chain.


Dynamic Scopes via Dedicated Classes

When scope logic grows — conditional filters, multiple parameters, reuse across models — extract it into a dedicated invokable class:

// app/Queries/ArticleFilters.php
final class ArticleFilters
{
    public function __construct(
        private readonly ?string $search,
        private readonly ?string $status,
        private readonly ?int $authorId,
    ) {}

    public function __invoke(Builder $query): Builder
    {
        return $query
            ->when($this->search, fn ($q, $s) =>
                $q->whereFullText(['title', 'body'], $s)
            )
            ->when($this->status === 'published', fn ($q) =>
                $q->published()
            )
            ->when($this->authorId, fn ($q, $id) =>
                $q->byAuthor($id)
            );
    }
}
$filters = new ArticleFilters(
    search: $request->search,
    status: $request->status,
    authorId: $request->integer('author_id') ?: null,
);

$articles = Article::tap($filters)->cursorPaginate(20);

tap() passes the builder to any callable — no trait, no magic method needed.


Testing Scopes in Isolation with Pest

it('published scope excludes future articles', function () {
    Article::factory()->create(['published_at' => now()->addDay()]);
    Article::factory()->create(['published_at' => now()->subHour()]);

    expect(Article::published()->count())->toBe(1);
});

it('byAuthor scope filters correctly', function () {
    $author = User::factory()->create();
    Article::factory(3)->for($author, 'author')->published()->create();
    Article::factory(2)->published()->create(); // different author

    expect(Article::published()->byAuthor($author->id)->count())->toBe(3);
});

Test each scope independently before testing combinations. This isolates failures and keeps tests fast.


Key Takeaways

  • Global scopes are for invariants (soft deletes, tenant isolation), not business rules.
  • Local scopes should return Builder explicitly for static analysis compatibility.
  • Invokable filter classes with tap() replace bloated scope lists on large models.
  • Always test scopes in isolation — one scope per test, then compose.
  • Use withoutGlobalScope(ClassName::class) over withoutGlobalScopes() to be precise about what you're removing.

Found this useful?

Frequently Asked Questions

3 questions
Q01 When should I use a global scope instead of a local scope?
Use a global scope only when every query on the model must respect the constraint without exception — soft deletes and tenant isolation are the classic cases. For anything that varies by context (admin vs. public, draft vs. published), a local scope called explicitly is safer and more discoverable.
Q02 How do I apply multiple optional filters without a long chain of `when()` calls on the model?
Extract the filters into an invokable class and pass it to the query builder via `tap()`. This keeps the model clean, makes the filter logic independently testable, and lets you type-hint constructor arguments for clarity.
Q03 Does returning Builder from a local scope break IDE autocompletion?
No — returning the concrete `Illuminate\Database\Eloquent\Builder` type (or the generic `Builder<static>` with a PHPDoc) is exactly what Larastan and modern IDEs expect. It enables full chain completion and catches type errors at analysis time rather than runtime.

Continue reading

More Articles

View all