The Problem With Macro Sprawl
Macros are convenient, but registering a dozen Builder::macro() calls in a service provider creates invisible global state. There is no type hint, no IDE completion, and no obvious place to test them in isolation. For teams working on a modular monolith or a domain-rich application, typed query objects are a better trade-off.
What Is a Query Object?
A query object is a plain PHP class that accepts an Illuminate\Database\Eloquent\Builder instance and applies one cohesive set of constraints. It is not a repository — it does not fetch results. It only scopes the query, leaving the caller in control of pagination, eager loading, and execution.
<?php
namespace App\Domain\Billing\Queries;
use App\Models\Invoice;
use Illuminate\Database\Eloquent\Builder;
final class OverdueInvoicesQuery
{
public function __construct(
private readonly int $graceDays = 0,
) {}
public function apply(Builder $query): Builder
{
return $query
->where('status', Invoice::STATUS_UNPAID)
->whereDate('due_at', '<', now()->subDays($this->graceDays))
->whereNull('voided_at');
}
}
The caller decides what to do with the scoped builder:
$query = (new OverdueInvoicesQuery(graceDays: 7))
->apply(Invoice::query());
$invoices = $query
->with('customer')
->orderBy('due_at')
->cursorPaginate(50);
Composing Multiple Query Objects
Because each object returns the builder, you can chain them through a small helper:
function applyQueries(Builder $builder, array $queries): Builder
{
foreach ($queries as $q) {
$builder = $q->apply($builder);
}
return $builder;
}
$results = applyQueries(Invoice::query(), [
new OverdueInvoicesQuery(graceDays: 7),
new BelongsToTenantQuery(tenantId: $tenant->id),
new ExcludesTestAccountsQuery(),
])->get();
This is the same idea as the pipeline pattern, but without the overhead of Closure-based pipes when all you need is builder mutation.
Binding Query Objects via the Service Container
If a query object has infrastructure dependencies — say, a Clock interface for testable date logic — resolve it from the container:
// AppServiceProvider
$this->app->bind(OverdueInvoicesQuery::class, function ($app) {
return new OverdueInvoicesQuery(
graceDays: config('billing.overdue_grace_days', 0),
);
});
In a controller or action you can now type-hint it directly:
public function index(OverdueInvoicesQuery $overdueQuery): JsonResponse
{
$invoices = $overdueQuery
->apply(Invoice::query())
->with('customer')
->cursorPaginate(50);
return InvoiceResource::collection($invoices)->response();
}
Testing in Isolation With Pest
Because the query object only touches the builder, you can test it with a real SQLite in-memory database without bootstrapping the full HTTP stack:
it('excludes invoices within the grace period', function () {
Invoice::factory()->create(['due_at' => now()->subDays(3), 'status' => 'unpaid']);
Invoice::factory()->create(['due_at' => now()->subDays(10), 'status' => 'unpaid']);
$results = (new OverdueInvoicesQuery(graceDays: 7))
->apply(Invoice::query())
->get();
expect($results)->toHaveCount(1)
->and($results->first()->due_at->diffInDays(now()))->toBeGreaterThan(7);
});
No mocking, no HTTP overhead — just a focused database assertion.
When to Prefer Local Scopes Instead
Local scopes (scopeActive, scopeForTenant) are still the right tool when the constraint is a fundamental, always-available behaviour of the model. Query objects shine when the constraint is:
- Contextual — it depends on runtime values or injected services.
- Composable — it is combined with other constraints in varying combinations.
- Domain-specific — it belongs to a bounded context, not the model itself.
Takeaways
- Query objects are typed, injectable, and testable — none of which macros give you for free.
- They return the builder, keeping execution control with the caller.
- Compose them with a simple loop or a pipeline; no framework magic required.
- Bind them in the service container when they need configuration or dependencies.
- Reserve local scopes for stable, model-level constraints; use query objects for domain logic.