Eloquent Query Scopes as First-Class Objects: Reusable, Testable, and Composable
#laravel #eloquent #clean-code #testing #php

Eloquent Query Scopes as First-Class Objects: Reusable, Testable, and Composable

1 min read Mohamed Said Mohamed Said

The Problem With Inline Query Logic

Most Laravel codebases start with local scopes on models — scopeActive, scopeForTenant, scopePublished. They work fine until the same logic needs to appear on three different models, or until a scope grows complex enough that you want to unit-test it in isolation. At that point, the model becomes a dumping ground.

The fix is treating query scopes as first-class objects: plain PHP classes that receive a builder, apply constraints, and can be composed, reused, and tested without booting a model.

Anatomy of a Scope Class

Start with a simple contract:

namespace App\Scopes;

use Illuminate\Database\Eloquent\Builder;

interface Scope
{
    public function apply(Builder $builder): void;
}

A concrete implementation stays focused:

namespace App\Scopes;

use Illuminate\Database\Eloquent\Builder;

final class PublishedScope implements Scope
{
    public function __construct(
        private readonly \DateTimeInterface $before,
    ) {}

    public function apply(Builder $builder): void
    {
        $builder
            ->whereNotNull('published_at')
            ->where('published_at', '<=', $this->before);
    }
}

Note the injected $before — no now() hidden inside the class, which makes testing trivial.

Applying Scopes Without Polluting the Model

Add a single applyScope macro on Builder in a service provider:

use Illuminate\Database\Eloquent\Builder;
use App\Scopes\Scope;

Builder::macro('applyScope', function (Scope $scope): Builder {
    $scope->apply($this);
    return $this;
});

Now any model query can consume scope objects fluently:

$posts = Post::query()
    ->applyScope(new PublishedScope(now()))
    ->applyScope(new ForCategoryScope($category))
    ->latest('published_at')
    ->paginate();

The model itself carries zero scope methods. The controller reads like a specification.

Composing Scopes With a Pipeline

When a list endpoint accepts multiple optional filters, compose scopes dynamically:

final class PostIndexQuery
{
    /** @param Scope[] $scopes */
    public function __construct(private readonly array $scopes) {}

    public function get(): \Illuminate\Contracts\Pagination\CursorPaginator
    {
        $query = Post::query();

        foreach ($this->scopes as $scope) {
            $scope->apply($query);
        }

        return $query->cursorPaginate(20);
    }
}

The calling code builds the scope list from validated request data:

$scopes = [
    new PublishedScope(now()),
];

if ($request->filled('category_id')) {
    $scopes[] = new ForCategoryScope(
        Category::findOrFail($request->integer('category_id'))
    );
}

return (new PostIndexQuery($scopes))->get();

Each scope is independently swappable. Adding a FeaturedScope or ByAuthorScope requires no changes to existing classes.

Testing Scopes in Isolation

Because a scope only touches a Builder, you can test it with a real in-memory SQLite database and a single model — no HTTP layer, no service container:

use App\Scopes\PublishedScope;
use App\Models\Post;
use Carbon\Carbon;

it('excludes posts published in the future', function () {
    Post::factory()->create(['published_at' => now()->addDay()]);
    Post::factory()->create(['published_at' => now()->subDay()]);

    $scope = new PublishedScope(Carbon::now());
    $query = Post::query();
    $scope->apply($query);

    expect($query->count())->toBe(1);
});

The injected timestamp means you control the clock without mocking facades.

Global Scopes as Scope Classes

The same pattern works for Eloquent's global scopes. Implement Illuminate\Database\Eloquent\Scope (the framework interface, not your custom one) and register it in Model::booted:

protected static function booted(): void
{
    static::addGlobalScope(new TenantScope(tenant()));
}

Keeping tenant isolation in a dedicated class — rather than a closure in booted — means it can be tested, documented, and reused across every tenant-aware model.

Takeaways

  • Scope classes are plain PHP objects; they carry no framework magic and are trivially testable.
  • Injecting dependencies (dates, IDs, value objects) into scopes eliminates hidden global state.
  • A Builder::macro('applyScope') keeps call sites fluent without modifying models.
  • Composing scopes from request data replaces complex when() chains with an explicit, ordered list.
  • Global scopes benefit from the same pattern: a named class beats an anonymous closure every time.

Found this useful?

Frequently Asked Questions

3 questions
Q01 How is this different from Eloquent's built-in local scopes?
Local scopes live on the model class, making them hard to share across models and impossible to test without the model. Scope objects are standalone classes with explicit dependencies, so they compose freely and test without any model ceremony.
Q02 Can scope classes work with Eloquent relationships, not just top-level queries?
Yes. Any `Illuminate\Database\Eloquent\Builder` instance works, including those returned by `$post->comments()`. Pass the relationship builder to `$scope->apply($builder)` exactly as you would a top-level query.
Q03 Should I replace all local scopes immediately?
No. Simple, single-model scopes like `scopeActive` are fine as local scopes. Extract to scope classes when the logic is shared across models, when it has injectable parameters, or when you want to test it in isolation.

Continue reading

More Articles

View all