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
staticfrom every scope method to preserve the concrete builder type through chains. - Use
@template TModelgenerics 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.