How to Refactor Eloquent Logic into Dedicated Builders
As our Laravel applications grow, our Eloquent models bloat out of control. We start with a simple 50-line User model that represents a database table, and it becomes a 1500-line god class with 20+ local scopes like scopeActive(), scopeVerified(), scopePendingPayment(), and scopeFilterByDate()
Local scopes are great, but having 20+ different scopes inside your model makes it hard to read and test your code. Your IDE also can't autocomplete or recognize local scopes because they're dynamically processed via magic methods __call(). There's also no way for your IDE to know what parameters the scope requires, so you can't get proper autocompletion for the arguments.
The solution is to externalize the query scopes and move them into separate classes and use newEloquentBuilder() to use them inside your model. In this article, we'll show how to do that and get clean and pretty code with proper IDE autocompletion.
The Problem: Model Bloat from Local Scopes
Let's look at what typically happens to a heavily used model like Order in an e-commerce or SaaS application:
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class Order extends Model
{
// Scopes, scopes, and more scopes...
public function scopeCompleted(Builder $query): Builder
{
return $query->where('status', 'completed');
}
public function scopePending(Builder $query): Builder
{
return $query->where('status', 'pending');
}
public function scopePaidBetween(Builder $query, string $start, string $end): Builder
{
return $query->whereBetween('paid_at', [$start, $end]);
}
public function scopeHighValue(Builder $query, float $threshold = 1000): Builder
{
return $query->where('total_amount', '>=', $threshold);
}
public function scopeForCustomer(Builder $query, int $customerId): Builder
{
return $query->where('customer_id', $customerId);
}
}
If you add relationships, accessors, mutators, event hooks, casts, etc., your model file becomes a mess. More importantly, when you call Order::completed()->highValue()->get(), your IDE has no native understanding of the type of completed() because it's really a scopeCompleted().
By moving them out to a dedicated Query Builder, your model is slim and only contains relationships and schema / casts.
Step 1: Creating the Custom Query Builder Class
A custom query builder is simply a class that extends Illuminate\Database\Eloquent\Builder. You can store your custom builders inside the app/Builders or app/Models/Builders directory.
Let's create an OrderBuilder class in app/Builders/OrderBuilder.php:
namespace App\Builders;
use Illuminate\Database\Eloquent\Builder;
class OrderBuilder extends Builder
{
/**
* Scope query to completed orders.
*/
public function completed(): self
{
return $this->where('status', 'completed');
}
/**
* Scope query to pending orders.
*/
public function pending(): self
{
return $this->where('status', 'pending');
}
/**
* Scope query to high-value orders.
*/
public function highValue(float $threshold = 1000.00): self
{
return $this->where('total_amount', '>=', $threshold);
}
/**
* Scope query to orders placed within a date range.
*/
public function paidBetween(string $start, string $end): self
{
return $this->whereBetween('paid_at', [$start, $end]);
}
/**
* Filter orders by customer ID.
*/
public function forCustomer(int $customerId): self
{
return $this->where('customer_id', $customerId);
}
}
Notice the key improvements over traditional local scopes:
- No more repetitive
scopeprefixes on method names.
- No need to accept
Builder $queryas the first argument—you call methods directly on$this.
- Methods return
self, ensuring fluent method chaining with strict type safety.
Step 2: Connecting the Custom Builder to the Model
To inform Eloquent that it should use your custom OrderBuilder whenever a query is initiated on the Order model, override the newEloquentBuilder() method on your model:
namespace App\Models;
use App\Builders\OrderBuilder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Query\Builder as QueryBuilder;
class Order extends Model
{
protected $fillable = [
'customer_id',
'status',
'total_amount',
'paid_at',
];
/**
* Create a new Eloquent query builder for the model.
*
* @param \Illuminate\Database\Query\Builder $query
* @return \App\Builders\OrderBuilder<$this>
*/
public function newEloquentBuilder($query): OrderBuilder
{
return new OrderBuilder($query);
}
}
Step 3: Enabling Native IDE Autocompletion
To instruct your IDE (PhpStorm, VS Code with Intelephense) that calling static query methods on Order will return your custom OrderBuilder instead of the generic Laravel base builder, add a @method docblock to your model class:
namespace App\Models;
use App\Builders\OrderBuilder;
use Illuminate\Database\Eloquent\Model;
/**
* @method static OrderBuilder query()
* @method static OrderBuilder completed()
* @method static OrderBuilder pending()
* @method static OrderBuilder highValue(float $threshold = 1000.00)
* @method static OrderBuilder paidBetween(string $start, string $end)
* @method static OrderBuilder forCustomer(int $customerId)
* @mixin OrderBuilder
*/
class Order extends Model
{
// ...
}
Now, as soon as you type Order::, your IDE immediately suggests completed(), highValue(), and your parameters with full type analysis.
Using the Custom Builder in Practice
Your custom builder methods can be chained seamlessly alongside all of Laravel's standard query builder methods like where(), with(), paginate(), and latest():
namespace App\Http\Controllers;
use App\Models\Order;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class RevenueReportController extends Controller
{
public function index(Request $request): JsonResponse
{
$orders = Order::query()
->completed()
->highValue(2500)
->paidBetween('2026-01-01', '2026-06-30')
->with('customer')
->latest('paid_at')
->paginate(20);
return response()->json($orders);
}
}
The query reads like plain English, and your Order model file remains under 50 lines.
Reusing Query Logic Across Multiple Models with Shared Builders
What if multiple models share identical query constraints? For instance, what if Post, Article, and Course models all have status, published_at, and archived_at columns?
Instead of duplicating local scopes across three different models, you can create a shared trait or base builder:
namespace App\Builders\Concerns;
trait HasPublishableQueries
{
public function published(): self
{
return $this->where('status', 'published')
->whereNotNull('published_at')
->where('published_at', '<=', now());
}
public function drafts(): self
{
return $this->where('status', 'draft');
}
}
Now, simply use this trait inside PostBuilder, ArticleBuilder, or CourseBuilder:
namespace App\Builders;
use App\Builders\Concerns\HasPublishableQueries;
use Illuminate\Database\Eloquent\Builder;
class PostBuilder extends Builder
{
use HasPublishableQueries;
public function featured(): self
{
return $this->where('is_featured', true);
}
}
Local Scopes vs. Custom Query Builders
| Feature | Local Model Scopes | Custom Query Builders |
|---|---|---|
| Location | Inside the Model class | Dedicated Builder class in app/Builders |
| Model Cleanliness | Clutters models as applications grow | Keeps models lean and focused (Single Responsibility) |
| IDE Autocompletion | Requires plugins or manual docblocks | Native object-oriented autocompletion |
| Method Syntax | scopePublished(Builder $query) |
published(): self |
| Unit Testing | Coupled directly to full model tests | Can be tested as isolated builder units |
Important Things to Remember
- 📌 Relationships Automatically Inherit the Builder: If you query a relationship—for example,
$customer->orders()->completed()->get()—Laravel's relationship instance automatically uses your customOrderBuilderbehind the scenes.
- ⚡ Always Return
$this: Every method you define inside your custom builder should return$this(orself) so downstream methods remain chainable.
- ⚠️ Do Not Execute Queries Inside the Builder: Builder methods should strictly configure SQL conditions (
where,join,orderBy). Avoid calling terminal execution methods likeget(),first(), orcount()inside builder methods unless writing a specialized data retrieval helper.
Conclusion
Custom Eloquent Query Builders give a clean architecture solution to the common problem of bloated models in larger Laravel applications. By externalizing query filters this way you not only follow the single responsibility principle but also get first-class IDE autocompletion and keep your models lean and readable.
Thank you for reading this article 😊
For any query, do not hesitate to comment 💬
-compressed.jpg)