Skip to content

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

Query scopes are one of Eloquent's most misused features. This guide shows how to write global, local, and dynamic scopes that stay testable, composable, and free of hidden side-effects.

Published · Updated · 3 min read

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

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.

Frequently asked questions

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.

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.

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.

Mohamed Said

About the author

Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

About GitHub LinkedIn WhatsApp Email Address My CV

Have a technical challenge?

Tell me what you’re building. I reply within two working days.

Discuss your project