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
Builderexplicitly 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)overwithoutGlobalScopes()to be precise about what you're removing.