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.