Back to all articles
Laravel Insights Jan 9, 2026 โˆ™ 1 min read

Mastering Advanced Query Scopes in Laravel for Clean Code

Learn how to leverage local, global, and attribute-based query scopes for cleaner, reusable, and efficient Eloquent queries.

An illustration showing a database icon with several branching arrows pointing to code blocks, symbolizing how a single query can be filtered and constrained us

Mastering Advanced Query Scopes in Laravel

Learn how to leverage local, global, and attribute-based query scopes for cleaner, reusable, and efficient Eloquent queries.

In any large-scale Laravel application, database queries can quickly become complex and repetitive. Maintaining clean, readable, and reusable query logic is essential for long-term project health. Laravel's Eloquent ORM provides a powerful feature to address this challenge: query scopes. By encapsulating common query constraints, scopes allow you to build expressive, chainable, and maintainable database interactions.

While many developers are familiar with basic query scopes, mastering their advanced applications can significantly elevate your codebase. From enforcing multi-tenancy rules with global scopes to creating dynamic, attribute-based filters, these techniques are fundamental to building efficient and scalable systems. Properly implemented scopes reduce code duplication, improve readability, and centralize business logic related to your data models.

This guide provides a comprehensive walkthrough of advanced query scopes in Laravel. We will cover local and global scopes, explore the modern attribute-based syntax, and provide real-world examples for scenarios like multi-tenant filtering and dynamic API constraints. We will also discuss best practices for testing to ensure your scopes function exactly as intended.

Understanding Query Scopes: Local vs. Global

Query scopes in Laravel fall into two main categories: local and global. Each serves a distinct purpose and is suited for different use cases.

Local Scopes: Reusable, Opt-In Constraints

Local scopes allow you to define common sets of constraints that you can easily reuse throughout your application. They are "opt-in," meaning you must explicitly apply them to an Eloquent query. This makes them perfect for common filters that are not needed on every single query.

A classic example is filtering published blog posts. Instead of repeating the same where clause everywhere, you can define a local scope on your Post model.

Defining a Local Scope:
Traditionally, local scopes are defined as methods on your model, prefixed with scope.

// app/Models/Post.php
namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    /**
     * Scope a query to only include published posts.
     */
    public function scopePublished(Builder $query): void
    {
        $query->where('published_at', '<=', now());
    }
}

Now, you can use this scope to build cleaner, more readable queries:

// Instead of this:
$publishedPosts = Post::where('published_at', '<=', now())->get();

// You can write this:
$publishedPosts = Post::published()->get();

This approach is not only cleaner but also centralizes the logic for what "published" means. If the definition changes, you only need to update it in one place.

Global Scopes: Automatically Applied Constraints

Global scopes are designed to add constraints to all queries for a given model. They are "opt-out," meaning they are applied automatically unless you explicitly remove them. This makes them ideal for rules that should almost always be enforced, such as multi-tenancy.

For instance, in a multi-tenant application, you typically want to ensure that users can only see records belonging to their team or organization. A global scope is the perfect tool for this.

Defining a Global Scope:
Global scopes are implemented as classes that implement the Illuminate\Database\Eloquent\Scope interface.

php artisan make:scope TenantScope

This command creates a new file at app/Models/Scopes/TenantScope.php.

// app/Models/Scopes/TenantScope.php
namespace App\Models\Scopes;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
use Illuminate\Support\Facades\Auth;

class TenantScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        if (Auth::check()) {
            $builder->where('tenant_id', Auth::user()->tenant_id);
        }
    }
}

Applying the Global Scope:
You apply the scope to a model using the booted method.

// app/Models/Order.php
use App\Models\Scopes\TenantScope;

class Order extends Model
{
    protected static function booted(): void
    {
        static::addGlobalScope(new TenantScope);
    }
}

Now, every query for the Order model, such as Order::all() or Order::find(1), will automatically have the WHERE tenant_id = ? constraint applied. If you need to query across all tenants (for example, in an admin panel), you can bypass the scope:

// Get orders for all tenants
$allOrders = Order::withoutGlobalScope(TenantScope::class)->get();

The Modern Approach: Attribute-Based Scopes

Laravel 10.3 introduced a cleaner, more modern way to define local scopes using PHP attributes. This approach eliminates the need for the scope prefix and provides better IDE support and static analysis capabilities.

Using the #[Scope] attribute, you can define a regular method on your model, and Laravel will recognize it as a local query scope.

Defining an Attribute-Based Scope:

// app/Models/Post.php
namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Attributes\Scope;

class Post extends Model
{
    #[Scope]
    public function published(Builder $query): void
    {
        $query->where('published_at', '<=', now());
    }

    #[Scope]
    public function ofType(Builder $query, string $type): void
    {
        $query->where('type', $type);
    }
}

The usage remains identical to traditional local scopes, but the definition is more explicit and aligns with modern PHP practices.

// Find all published articles
$articles = Post::published()->ofType('article')->get();

This syntax is the recommended way to define local scopes in modern Laravel applications.

Real-World Examples of Advanced Scopes

Dynamic Filters for APIs

Query scopes are incredibly powerful for building flexible APIs where clients can apply dynamic filters. Imagine you want to allow users to filter orders by status.

// app/Models/Order.php
use Illuminate\Database\Eloquent\Attributes\Scope;

class Order extends Model
{
    #[Scope]
    public function status(Builder $query, string $status): void
    {
        $query->where('status', $status);
    }
}

In your controller, you can conditionally apply this scope based on the request input.

// app/Http/Controllers/OrderController.php
public function index(Request $request)
{
    $query = Order::query();

    if ($request->has('status')) {
        $query->status($request->input('status'));
    }

    return $query->paginate(15);
}

A user can now make a request to /api/orders?status=shipped, and the status scope will be applied dynamically. This creates clean, readable controller logic that is easy to extend with more filters.

Composing Scopes for Complex Queries

The true power of scopes becomes apparent when you chain them together to build complex queries from simple, reusable blocks.

Consider an e-commerce platform where an admin wants to find all high-value, pending orders from a specific region that were placed in the last month.

Model with Multiple Scopes:

// app/Models/Order.php
class Order extends Model
{
    #[Scope]
    public function pending(Builder $query): void
    {
        $query->where('status', 'pending');
    }

    #[Scope]
    public function highValue(Builder $query, float $amount = 1000): void
    {
        $query->where('total', '>=', $amount);
    }

    #[Scope]
    public function fromRegion(Builder $query, string $region): void
    {
        $query->whereHas('customer', fn($q) => $q->where('region', $region));
    }

    #[Scope]
    public function recent(Builder $query, int $days = 30): void
    {
        $query->where('created_at', '>=', now()->subDays($days));
    }
}

Composing the Query:

$urgentOrders = Order::pending()
    ->highValue(500)
    ->fromRegion('North America')
    ->recent()
    ->get();

This query is highly expressive and easy to understand at a glance. Each component is a self-contained, testable unit, making the overall system more robust and maintainable.

Best Practices for Testing Query Scopes

Testing is crucial to ensure your scopes apply the correct constraints. An effective test should create records that both match and do not match the scope's criteria, then assert that only the matching records are returned.

Example Test for a published Scope:

// tests/Feature/Models/PostScopeTest.php
namespace Tests\Feature\Models;

use App\Models\Post;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class PostScopeTest extends TestCase
{
    use RefreshDatabase;

    /** @test */
    public function published_scope_returns_only_published_posts(): void
    {
        // Arrange: Create posts with different publication dates
        $publishedPost = Post::factory()->create(['published_at' => now()->subDay()]);
        $unpublishedPost = Post::factory()->create(['published_at' => null]);
        $scheduledPost = Post::factory()->create(['published_at' => now()->addDay()]);

        // Act: Apply the scope
        $result = Post::published()->get();

        // Assert: Verify the results
        $this->assertCount(1, $result);
        $this->assertTrue($result->contains($publishedPost));
        $this->assertFalse($result->contains($unpublishedPost));
        $this->assertFalse($result->contains($scheduledPost));
    }
}

This test confirms that the published scope correctly filters out posts that are not yet published, providing confidence that the logic is sound.

Conclusion

Mastering advanced query scopes is a key step toward writing cleaner, more efficient, and more maintainable Laravel applications. By leveraging local scopes for reusable filters, global scopes for application-wide rules, and the modern attribute-based syntax for improved clarity, you can build a robust data layer that is both powerful and easy to reason about.

When combined with a solid testing strategy, query scopes become an indispensable tool in your development workflow. They encourage you to think of your query logic in small, composable units, leading to a more organized and scalable codebase.


Related articles

Continue exploring Laravel insights and practical delivery strategies.

Laravel consulting

Need senior Laravel help for this topic?

Let's adapt these practices to your product and deliver the next milestone.