Skip to content

Laravel Macro-Free Extensibility: Custom Query Builder Classes and Fluent Scopes

Skip global macros and reach for typed, testable custom query builder classes in Laravel. Learn how to bind a dedicated builder per model, compose fluent scopes, and keep your query logic discoverable.

Published · Updated · 1 min read

Laravel Macro-Free Extensibility: Custom Query Builder Classes and Fluent Scopes

The Problem With Macros and Scope Sprawl

Local scopes work fine for a handful of filters, but once a model accumulates a dozen scope* methods they become a flat, unsorted list with no grouping, no type hints on the builder return, and no IDE-friendly autocomplete. Macros are worse — they're global, invisible to static analysis, and silently overwrite each other.

The solution Laravel has always supported but rarely advertises: a dedicated Eloquent query builder class per model.

Binding a Custom Builder to a Model

Every Eloquent model exposes newEloquentBuilder(). Override it and return your own class that extends Illuminate\Database\Eloquent\Builder.

<?php

namespace App\Models\Builders;

use Illuminate\Database\Eloquent\Builder;

/**
 * @template TModel of \App\Models\Order
 * @extends Builder<TModel>
 */
class OrderBuilder extends Builder
{
    public function pending(): static
    {
        return $this->where('status', 'pending');
    }

    public function forCustomer(int $customerId): static
    {
        return $this->where('customer_id', $customerId);
    }

    public function highValue(int $thresholdCents = 10_000): static
    {
        return $this->where('total_cents', '>=', $thresholdCents);
    }

    public function withLatestShipment(): static
    {
        return $this->with(['shipments' => fn ($q) => $q->latest()->limit(1)]);
    }
}

Then in the model:

<?php

namespace App\Models;

use App\Models\Builders\OrderBuilder;
use Illuminate\Database\Eloquent\Model;

class Order extends Model
{
    /** @return OrderBuilder<static> */
    public function newEloquentBuilder($query): OrderBuilder
    {
        return new OrderBuilder($query);
    }
}

Now Order::query() returns OrderBuilder, and every chained call is fully typed.

$orders = Order::query()
    ->forCustomer($user->id)
    ->pending()
    ->highValue()
    ->withLatestShipment()
    ->cursorPaginate(25);

IDE and Static Analysis Benefits

Add the @method docblock on the model or rely on the generic @extends Builder<TModel> annotation. PHPStan and Psalm both resolve the generic correctly when you use @template on the builder class. Rector can enforce that no scope* methods exist on models that already have a dedicated builder.

Composing Complex Conditions as Named Methods

The real win is replacing multi-line where chains with intention-revealing names:

// Before — buried in a controller or service
$query->where('status', 'pending')
      ->where('scheduled_at', '<=', now())
      ->whereNull('locked_by');

// After — one readable call
public function readyToProcess(): static
{
    return $this
        ->where('status', 'pending')
        ->where('scheduled_at', '<=', now())
        ->whereNull('locked_by');
}

Testing the Builder in Isolation

Because the builder is a plain class, you can unit-test the SQL it generates without hitting the database:

it('scopes to pending high-value orders', function () {
    $sql = Order::query()
        ->pending()
        ->highValue(5_000)
        ->toRawSql();

    expect($sql)
        ->toContain("status" . " = 'pending'")
        ->toContain('total_cents >= 5000');
});

toRawSql() (available since Laravel 10.15) returns the fully interpolated SQL string — no database connection needed.

Sharing Behaviour Across Builders

Extract shared concerns into a trait:

trait HasSoftDeleteScopes
{
    public function onlyExpired(): static
    {
        return $this->whereNotNull('deleted_at')
                    ->where('deleted_at', '<', now());
    }
}

Apply it to any builder that needs it. No global macro registration, no collision risk.

Takeaways

  • Override newEloquentBuilder() to bind a typed builder class to each model.
  • Return static from every scope method to preserve the concrete builder type through chains.
  • Use @template TModel generics so PHPStan resolves the model type on collection results.
  • Test generated SQL with toRawSql() — no database required.
  • Extract shared query logic into traits on builder classes, not on models.
  • Delete scope* methods from models once the builder exists; keep the surface area small.

Frequently asked questions

Does overriding newEloquentBuilder() break Eloquent relationships or eager loading?

No. Eloquent uses newEloquentBuilder() only when constructing a fresh query for that model. Relationships build their own queries through the same method, so they also receive your custom builder automatically.

Can I still use local scopes alongside a custom builder class?

Yes, but it creates two competing patterns. The cleaner approach is to migrate all scope* methods into the builder class over time and remove them from the model entirely, keeping query logic in one place.

How do I get full PHPStan support for the custom builder return type?

Annotate the builder with @template TModel of YourModel and extend Builder<TModel>. Also add a @return OrderBuilder<static> docblock on newEloquentBuilder() in the model. PHPStan level 6+ will then infer the correct model type on ->get() and ->first().

Mohamed Said

About the author

Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

About GitHub LinkedIn WhatsApp Email Address My CV

Have a technical challenge?

Tell me what you’re building. I reply within two working days.

Discuss your project