Laravel Macro-Free Extensibility: Extending Eloquent Builder with Custom Query Objects
#laravel #eloquent #clean-architecture #testing #pest

Laravel Macro-Free Extensibility: Extending Eloquent Builder with Custom Query Objects

1 min read Mohamed Said Mohamed Said

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.

Found this useful?

Frequently Asked Questions

3 questions
Q01 How are query objects different from Eloquent local scopes?
Local scopes are methods on the model class, always available globally. Query objects are standalone classes you instantiate and inject, making them easier to test in isolation and compose across bounded contexts without coupling logic to the model.
Q02 Can I use query objects with Filament table queries?
Yes. Filament's `table()` method accepts a `modifyQueryUsing` callback where you can call `$query->apply()` on your query object, keeping table-level filtering logic out of the resource class.
Q03 Do query objects work with union queries or raw expressions?
They work with any method available on the Eloquent Builder, including `unionAll`, `selectRaw`, and `fromRaw`. The pattern is agnostic to query complexity — it simply encapsulates whatever builder calls you need.

Continue reading

More Articles

View all