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:
Read also
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.


