If you've been writing Laravel for any amount of time, you've probably caught yourself copy-pasting the same where() clauses across controllers, services, and commands. That's exactly the problem query scopes solve — and in Laravel 12, they've become even cleaner and more expressive than ever.
In this tutorial, we're going to walk through everything you need to know about query scopes: what they are, why you should use them, and how to implement them properly in a Laravel 12 application.
What Are Query Scopes?
Query scopes are a way to package reusable query logic directly inside your Eloquent models. Instead of scattering where() conditions all over your codebase, you define them once in the model and call them by name anywhere you need them.
Laravel offers two types of scopes:
Local Scopes — applied manually when you need them, chainable, and can accept parameters.
Global Scopes — applied automatically to every query on a model, invisible to the caller.
Both types keep your controllers slim, your models expressive, and your business rules in one place.
The Problem Without Scopes
Let's say you have a Post model and you frequently need to query only published posts. Without scopes, you end up writing something like this in multiple places:
// In your HomeController
$posts = Post::where('status', 'published')->where('published_at', '<=', now())->get();
// In your SitemapController
$posts = Post::where('status', 'published')->where('published_at', '<=', now())->orderBy('published_at')->get();
// In a scheduled command
$posts = Post::where('status', 'published')->where('published_at', '<=', now())->where('featured', true)->get();If your business logic changes — say, "published" now also requires an approved_at column to be set — you have to hunt down every single one of these calls and update them. That's brittle, tedious, and error-prone.
Query scopes solve this entirely.
Local Scopes
Local scopes are defined inside your model and called explicitly when querying. Laravel 12 introduced a new, cleaner syntax using PHP attributes — though the classic prefix approach still works too.
The Classic Way (still valid)
Prefix a method with scope followed by the scope name in PascalCase:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
public function scopePublished(Builder $query): void
{
$query->where('status', 'published')
->where('published_at', '<=', now());
}
public function scopeFeatured(Builder $query): void
{
$query->where('featured', true);
}
}The Laravel 12 Way — PHP Attributes ✨
Laravel 12 introduces the #[Scope] attribute, which is the new recommended approach. Instead of the scope prefix, you decorate a protected method with #[Scope]:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
#[Scope]
protected function published(Builder $query): void
{
$query->where('status', 'published')
->where('published_at', '<=', now());
}
#[Scope]
protected function featured(Builder $query): void
{
$query->where('featured', true);
}
}Both approaches produce identical behaviour. The attribute-based syntax is cleaner because the method name reads exactly like how you call it, and there's no cognitive overhead from the scope prefix convention.
Using Local Scopes
When calling a local scope, just use the method name (without the scope prefix — or the #[Scope] attribute makes it match exactly):
// Get all published posts
$posts = Post::published()->get();
// Get all featured posts
$posts = Post::featured()->get();
// Chain multiple scopes together
$posts = Post::published()->featured()->orderBy('published_at', 'desc')->get();
// Mix scopes with regular query builder methods
$posts = Post::published()->where('category_id', 3)->paginate(10);Scopes integrate seamlessly with the query builder, so chaining is completely natural.
Dynamic Local Scopes (Scopes with Parameters)
Local scopes can also accept parameters, making them flexible and reusable across different scenarios. This is called a dynamic scope.<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
#[Scope]
protected function status(Builder $query, string $status): void
{
$query->where('status', $status);
}
#[Scope]
protected function publishedAfter(Builder $query, string $date): void
{
$query->where('published_at', '>=', $date);
}
#[Scope]
protected function byCategory(Builder $query, int $categoryId): void
{
$query->where('category_id', $categoryId);
}
}Usage:
php
// Filter by a dynamic status
$drafts = Post::status('draft')->get();
$archived = Post::status('archived')->get();
// Posts published after a specific date
$recent = Post::publishedAfter('2025-01-01')->get();
// Combine dynamic and static scopes
$posts = Post::published()
->byCategory(5)
->publishedAfter('2024-06-01')
->orderBy('views', 'desc')
->paginate(15);Chaining Scopes with orWhere
When you chain scopes together, they use AND logic by default. If you want OR logic between two scopes, you need to wrap them in a closure to group conditions correctly:
php
// Wrong — this produces unexpected SQL grouping
$posts = Post::published()->orWhere->featured()->get();
// Correct — wrap in a closure for proper grouping
$posts = Post::published()->orWhere(function (Builder $query) {
$query->featured();
})->get();Laravel 12 also supports a higher-order orWhere that chains cleanly for simple cases:
php
// This works for simple scope chaining with OR
$posts = Post::published()->orWhere->featured()->get();Note: For complex conditions involving multiple
ORclauses, always use the closure approach to ensure your SQL grouping is correct.
Global Scopes
Global scopes are applied automatically to every single query on a model. You never have to call them manually — they're always there. Laravel's own SoftDeletes feature is the most famous example of a global scope.
Use global scopes when a constraint should always be enforced on a model — for example, a multi-tenant application where every query should filter by the current user's organisation.
Creating a Global Scope Class
Laravel 12 provides an Artisan command to generate a scope class:
bash
php artisan make:scope ActiveScopeThis creates the file at app/Models/Scopes/ActiveScope.php. The class must implement the Scope interface and define an apply method:
php
<?php
namespace App\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class ActiveScope implements Scope
{
/**
* Apply the scope to a given Eloquent query builder.
*/
public function apply(Builder $builder, Model $model): void
{
$builder->where('is_active', true);
}
}Applying a Global Scope to a Model — The Laravel 12 Way
Laravel 12 introduced the #[ScopedBy] attribute on the model itself, which is the cleanest way to register global scopes:
php
<?php
namespace App\Models;
use App\Models\Scopes\ActiveScope;
use Illuminate\Database\Eloquent\Attributes\ScopedBy;
use Illuminate\Database\Eloquent\Model;
#[ScopedBy([ActiveScope::class])]
class User extends Model
{
// No booted() method needed
}You can apply multiple global scopes at once:
php
#[ScopedBy([ActiveScope::class, TenantScope::class])]
class User extends Model
{
//
}The Classic Way — booted() Method
If you prefer (or need to support older conventions), register the scope inside the booted() method:
php
<?php
namespace App\Models;
use App\Models\Scopes\ActiveScope;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
protected static function booted(): void
{
static::addGlobalScope(new ActiveScope());
}
}Anonymous Global Scopes (Closures)
For simple constraints that don't need a dedicated class, you can use a closure directly:
php
protected static function booted(): void
{
static::addGlobalScope('verified', function (Builder $builder) {
$builder->whereNotNull('email_verified_at');
});
}Removing Global Scopes
Sometimes you legitimately need to query without the global scope — for example, an admin panel showing all users including inactive ones.
Remove a single class-based scope:
php
User::withoutGlobalScope(ActiveScope::class)->get();Remove a single closure-based scope by name:
php
User::withoutGlobalScope('verified')->get();Remove multiple scopes at once:
php
User::withoutGlobalScopes([ActiveScope::class, TenantScope::class])->get();Remove all global scopes:
php
User::withoutGlobalScopes()->get();Remove all scopes except specific ones:
php
User::withoutGlobalScopesExcept([TenantScope::class])->get();Pending Attributes (New in Laravel 12)
Laravel 12 introduced pending attributes — a powerful feature that lets a local scope set default attribute values when creating a model through that scope.
This is best explained with a real example. Imagine you have a scope for drafts and you want any post created through that scope to automatically have status = 'draft':
php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
#[Scope]
protected function draft(Builder $query): void
{
$query->where('status', 'draft');
}
}With pending attributes, you can now do this:
php
// Creates a new post with status = 'draft' automatically
$post = Post::draft()->create([
'title' => 'My Draft Post',
'body' => 'Work in progress...',
]);Laravel infers the attribute values from the scope's where constraints and applies them to the new model. This keeps model creation consistent with your filtering logic — no need to set status manually.
Real-World Example — A Blog Application
Let's put it all together with a realistic Post model for a blog:
php
<?php
namespace App\Models;
use App\Models\Scopes\TenantScope;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Attributes\ScopedBy;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
#[ScopedBy([TenantScope::class])]
class Post extends Model
{
use SoftDeletes;
protected $fillable = [
'title', 'slug', 'body', 'status',
'featured', 'category_id', 'published_at',
];
protected function casts(): array
{
return [
'published_at' => 'datetime',
'featured' => 'boolean',
];
}
// --- Local Scopes ---
#[Scope]
protected function published(Builder $query): void
{
$query->where('status', 'published')
->where('published_at', '<=', now());
}
#[Scope]
protected function draft(Builder $query): void
{
$query->where('status', 'draft');
}
#[Scope]
protected function featured(Builder $query): void
{
$query->where('featured', true);
}
#[Scope]
protected function byCategory(Builder $query, int $categoryId): void
{
$query->where('category_id', $categoryId);
}
#[Scope]
protected function popular(Builder $query, int $minViews = 1000): void
{
$query->where('views', '>=', $minViews);
}
#[Scope]
protected function recent(Builder $query, int $days = 30): void
{
$query->where('published_at', '>=', now()->subDays($days));
}
}Now your controllers become clean and expressive:
php
// HomeController — show featured + recent published posts
$posts = Post::published()->featured()->recent()->orderBy('published_at', 'desc')->take(6)->get();
// CategoryController — posts in a category
$posts = Post::published()->byCategory($category->id)->paginate(12);
// AdminController — all drafts (admin bypasses tenant scope)
$drafts = Post::withoutGlobalScope(TenantScope::class)->draft()->latest()->get();
// Create a draft directly
$post = Post::draft()->create([
'title' => 'Upcoming Tutorial',
'body' => '...',
]);When to Use Scopes vs. Other Approaches
Scenario | Recommendation |
|---|---|
Same | Local scope |
Constraint must always apply to a model | Global scope |
Filtering based on user input / request | Local scope with parameters |
Multi-tenancy / row-level security | Global scope |
One-off complex query | Query builder inline |
Very complex cross-model logic | Repository / Service class |
Best Practices
Keep scopes single-purpose. A scope named publishedAndFeatured() is harder to reuse than two separate published() and featured() scopes that you chain together.
Name scopes after business concepts, not SQL. Prefer active() over whereActiveIsOne(). The goal is readable, self-documenting code.
Be cautious with global scopes. They're invisible to the caller, which can cause confusing results if overused. Always document global scopes clearly in your model.
Use the #[Scope] attribute in new code. The attribute syntax is the Laravel 12 standard. Reserve the scopeXxx prefix convention for maintaining older codebases.
Test your scopes in isolation. Since scopes live in the model, you can write focused unit tests that exercise just the scope's SQL output without going through a full controller flow.
php
// Example scope test
public function test_published_scope_filters_correctly(): void
{
Post::factory()->create(['status' => 'published', 'published_at' => now()->subDay()]);
Post::factory()->create(['status' => 'draft', 'published_at' => now()->subDay()]);
$this->assertCount(1, Post::published()->get());
}Summary
Query scopes are one of the most practical tools in Laravel's Eloquent ORM. They eliminate repetitive query code, centralise business logic in your models, and make your application much easier to maintain and test.
In Laravel 12, the introduction of the #[Scope] and #[ScopedBy] PHP attributes makes the syntax even cleaner than before — no more hunting for methods prefixed with scope to understand what's available.
To recap what we covered:
Local scopes are defined with
#[Scope]and called explicitly — they're chainable and support parameters.Global scopes are applied automatically to every query and registered with
#[ScopedBy]or inbooted().Pending attributes (new in Laravel 12) let scopes set default values when creating models.
withoutGlobalScope()gives you an escape hatch when you need to bypass a global constraint.
Next time you find yourself writing the same where() clause twice, reach for a scope instead. Your future self — and your teammates — will thank you.