Web Development 5-8 minutes

Laravel 13 Query Scopes: Reusable Eloquent Queries

Diego Cortés
Diego Cortés
Full Stack Developer & SEO Specialist
Share:
Laravel 13 Query Scopes: Reusable Eloquent Queries

The same where('status', 'published') copy-pasted across ten controllers is the most common code smell in Laravel apps. Query scopes move those filters into the model so you can write Post::published()->recent(), and Laravel 13 adds the #[Scope] syntax next to the classic scopeName.

The Problem: Duplicated Query Logic Across Your App

The Repeated where() Code Smell

It starts with an innocent filter: "only published posts." You copy it into the listing controller, then the show controller, then a job that generates the sitemap, and later a test. When the rule changes, say to "published and not archived," you have to find and update them all. Repetition is not the only risk: one copy can lag behind and query data it should no longer expose.

What a Query Scope Is and What It Solves (DRY and Expressiveness)

A query scope is a method on the Eloquent model that encapsulates a reusable set of constraints. Post::published() replaces Post::where('status', 'published'), and the rule lives in one place: the model. It is the DRY principle applied to queries, and it also makes queries read like sentences.

Local Scopes: The Classic scope Prefix Syntax

scopePublished: The Simplest Scope

A local scope is a method with the scope prefix that receives the query builder and returns it:

public function scopePublished(Builder $query): Builder
{
    return $query->where('status', 'published');
}

When you call it, Eloquent strips the prefix: Post::published() runs exactly that where.

Chaining Scopes with Other Query Builder Methods

Because the scope returns the query builder, it chains with anything else: with() for eager loading, orderBy(), paginate()... Post::published()->with('author')->recent()->paginate(10) is a complete, readable query. Scopes and eager loading combine without friction.

Dynamic Scopes: Scopes with Parameters

scopeByCategory($slug) and Passing Arguments

Dynamic scopes accept parameters after $query. The first argument is always the query; the rest are up to you:

public function scopeByCategory(Builder $query, string $slug): Builder
{
    return $query->whereHas('category', fn ($q) => $q->where('slug', $slug));
}

Call it as Post::byCategory('laravel'), and use it for any parameterized filter: status, author, or date range.

Default Values and Validation Inside the Scope

Give the parameter a default value or validate it inside the scope, returning $query unchanged when it does not apply. That way a call without arguments never breaks the chain, and the scope decides whether the filter makes sense.

The #[Scope] Syntax in Laravel 12/13

Attribute-Based Scopes on Model Methods

Since Laravel 12 there is an alternative built on PHP attributes, still current in Laravel 13, released on March 17, 2026, as we covered in our Laravel 13 features guide. The #[Scope] attribute marks a static model method as a scope:

use Illuminate\Database\Eloquent\Attributes\Scope;

#[Scope]
public static function published(Builder $query): Builder
{
    return $query->where('status', 'published');
}

The invocation is identical: Post::published(). The difference is in the definition, which moves from a prefixed instance method to a static attribute-based one.

Reusable Scope Classes with the Attribute

The attribute also accepts a dedicated class implementing __invoke. That lets you share the same logic across models:

#[Scope]
public static function active(): ActiveScope
{
    return new ActiveScope;
}

The ActiveScope class encapsulates the active filter and can be applied to User, Post, or any model that needs it.

Both Syntaxes Coexisting in Laravel 13

In Laravel 13 both syntaxes work side by side: migrate models gradually and keep classic scopes where they already work. There is no rush to rewrite, and mixing them inside the same project causes no conflicts.

Global Scopes: Constraints on Every Query

Registering with booted() and addGlobalScope

A global scope applies automatically to every query on the model. Register it in booted() with addGlobalScope, passing a class or a closure:

protected static function booted(): void
{
    static::addGlobalScope('locale', fn (Builder $query) =>
        $query->where('locale', app()->getLocale()));
}

From then on, every query on the model includes the filter, with nobody having to remember to add it.

Soft Deletes as a Built-In Global Scope Example

The SoftDeletes trait uses global scopes internally: it adds a where deleted_at is null to every query and removes it when you query withTrashed(). It is the perfect example of why they exist: a cross-cutting rule no developer should write by hand.

withoutGlobalScope and withoutGlobalScopes for One-Off Exceptions

To skip the rule in a specific case: withoutGlobalScope(ClassName::class) removes one, withoutGlobalScopes() removes them all. An admin panel, for example, may need to see records the public app hides.

Best Practices and Common Pitfalls

The orWhere Trap Inside a Scope

The most dangerous mistake: combining orWhere inside a scope. The orWhere is evaluated without the context of the previous where and can break the filter, even exposing data the scope was supposed to hide:

// DANGEROUS: orWhere can nullify the previous filter
return $query->where('status', 'published')
             ->orWhere('featured', true);

The fix is to group conditions with closures so the OR stays inside the correct group.

Naming, Returning the Query Builder, and Keeping Scopes Short

Three simple rules: use present-tense descriptive names, published instead of recentPosts; always return the query builder, because forgetting it breaks the chain; and keep each scope short, with a single responsibility. If a scope needs more than a few lines, you are probably hiding logic that deserves its own method or class.

Real Example: Scopes on the Post Model of a Laravel 13 Blog

published, recent, featured, and byCategory Chained Together

On a blog like this one, the Post model can combine everything above:

Post::published()->featured()->byCategory($slug)->recent()->paginate(10)

Each scope contributes a filter, the chain reads itself, and the controller never sees a where.

A Locale/Tenant Global Scope and Its Admin Exception

For a multilingual blog, a locale global scope prevents any query from forgetting the language. The admin, which manages all languages at once, disables it case by case: Post::withoutGlobalScope(LocaleScope::class).

The Controller Before and After

Before, the controller repeated filters by hand:

$posts = Post::where('status', 'published')
    ->where('featured', true)
    ->whereHas('category', fn ($q) => $q->where('slug', $request->category))
    ->orderByDesc('published_at')
    ->paginate(10);

After, with scopes:

$posts = Post::published()->featured()
    ->byCategory($request->category)
    ->recent()->paginate(10);

Fewer lines, less duplication, and the business rule lives in the model. The clean data arriving from Form Requests feeds these filters directly.

Testing Scopes

Testing a Scope with a Factory Model and Assertions

Scopes are tested like any query: RefreshDatabase, a factory, and an assertion on the result:

Post::factory()->create(['status' => 'published']);
Post::factory()->create(['status' => 'draft']);

$this->assertSame(1, Post::published()->count());

If you prefer checking the generated SQL, toSql() shows the exact query and catches regressions in filters before they reach production.

Conclusion

Query scopes turn repeated queries into expressive model methods: local scopes for reusable filters, dynamic scopes for parameters, global scopes for cross-cutting rules, and the #[Scope] attribute as the modern syntax. Start with a published scope on your Post model and let the rest of the app consume it. Keep reading the blog for more Laravel 13 guides.

Categories