Laravel's Eloquent ORM provides a powerful and intuitive interface for interacting with your database. While the built-in query builder offers extensive functionality, you may find yourself needing more specialized query logic that aligns with your application's domain. This is where custom Eloquent Builders come in.
What is a Custom Eloquent Builder?
A custom Eloquent Builder is an extension of Laravel's base Eloquent Builder that allows you to define reusable query logic specific to a particular model. It provides a way to encapsulate complex queries and create a fluent, readable API for querying your data.
Why Create Custom Eloquent Builders?
Code Organization and Maintainability
As your application grows, models can become bloated with numerous query scopes. Moving these to a dedicated builder class keeps your models lean and focused on their primary responsibility: representing data.
Reusability
Custom builders allow you to define query logic once and reuse it throughout your application, eliminating code duplication and ensuring consistency.
Readability
Well-named builder methods create self-documenting code. A method chain like User::active()->verified()->recentlyActive()->get()
clearly communicates the query's intent.
Domain-Specific Queries
Custom builders let you create a query vocabulary that matches your domain, making your code more expressive and aligned with your business logic.
When to Implement Custom Builders
Consider creating a custom Eloquent Builder when:
Your model has too many query scopes
If your model file is dominated by scope methods (scopeActive()
,scopeVerified()
, etc.), it's time to refactor.You repeat the same query patterns
When you find yourself writing identical query logic in multiple controllers or services.Your queries reflect complex domain concepts
When queries represent sophisticated business rules or domain-specific filtering.Query logic is becoming difficult to test
When testing query logic in controllers becomes cumbersome.You need to standardize query patterns across a team
To ensure consistent data access patterns across different parts of your application.
Signs You Need a Custom Builder
You'll know it's time to implement a custom builder when:
- Your model class exceeds 200-300 lines with many of those being query-related methods
- You find yourself copying and pasting query conditions across controllers
- You struggle to understand what a complex query is trying to accomplish
- Different team members write inconsistent queries for the same data needs
- You frequently need to update the same query logic in multiple places
How to Create a Custom Eloquent Builder
1. Create Your Builder Class
First, create a dedicated directory for your builders. A common convention is to place them in App\Models\Builders
:
<?php
namespace App\Models\Builders;
use Illuminate\Database\Eloquent\Builder;
class PostBuilder extends Builder
{
/**
* Get only published posts.
*
* @return \App\Models\Builders\PostBuilder
*/
public function published()
{
return $this->where('status', 'published');
}
/**
* Get posts with high view counts.
*
* @param int $threshold
* @return \App\Models\Builders\PostBuilder
*/
public function popular(int $threshold = 1000)
{
return $this->where('views', '>', $threshold);
}
/**
* Order posts with newest first.
*
* @return \App\Models\Builders\PostBuilder
*/
public function recentFirst()
{
return $this->orderBy('created_at', 'desc');
}
/**
* Filter posts by specific tag.
*
* @param string $tag
* @return \App\Models\Builders\PostBuilder
*/
public function withTag(string $tag)
{
return $this->whereHas('tags', function ($query) use ($tag) {
$query->where('name', $tag);
});
}
}
2. Connect the Builder to Your Model
Override the newEloquentBuilder
method in your model to use your custom builder:
<?php
namespace App\Models;
use App\Models\Builders\PostBuilder;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
/**
* Create a new Eloquent query builder for the model.
*
* @param \Illuminate\Database\Query\Builder $query
* @return \App\Models\Builders\PostBuilder
*/
public function newEloquentBuilder($query)
{
return new PostBuilder($query);
}
// Rest of your model...
}
3. Use Your Custom Builder
Now you can use your custom query methods directly on your model:
// Find popular, recently published posts about Laravel
$posts = Post::published()
->popular()
->withTag('laravel')
->recentFirst()
->get();
Real-World Example
Let's look at a practical example to illustrate when and how to implement a custom builder.
Before Custom Builder
Consider an e-commerce application where product filtering is complex:
// In a controller
$products = Product::where('active', true)
->where('stock', '>', 0)
->when($request->has('category'), function ($query) use ($request) {
return $query->where('category_id', $request->category);
})
->when($request->has('price_min'), function ($query) use ($request) {
return $query->where('price', '>=', $request->price_min);
})
->when($request->has('price_max'), function ($query) use ($request) {
return $query->where('price', '<=', $request->price_max);
})
->when($request->has('sort'), function ($query) use ($request) {
if ($request->sort === 'price_asc') {
return $query->orderBy('price', 'asc');
} elseif ($request->sort === 'price_desc') {
return $query->orderBy('price', 'desc');
} elseif ($request->sort === 'newest') {
return $query->orderBy('created_at', 'desc');
}
return $query;
})
->paginate(20);
This code is hard to read, difficult to test, and would need to be duplicated in other controllers that need similar filtering.
After Custom Builder
With a custom builder:
// ProductBuilder.php
namespace App\Models\Builders;
use Illuminate\Database\Eloquent\Builder;
class ProductBuilder extends Builder
{
public function active()
{
return $this->where('active', true);
}
public function inStock()
{
return $this->where('stock', '>', 0);
}
public function inCategory($categoryId)
{
return $this->where('category_id', $categoryId);
}
public function priceRange($min = null, $max = null)
{
return $this->when($min, function ($query) use ($min) {
return $query->where('price', '>=', $min);
})
->when($max, function ($query) use ($max) {
return $query->where('price', '<=', $max);
});
}
public function sortBy($sort)
{
return $this->when($sort === 'price_asc', function ($query) {
return $query->orderBy('price', 'asc');
})
->when($sort === 'price_desc', function ($query) {
return $query->orderBy('price', 'desc');
})
->when($sort === 'newest', function ($query) {
return $query->orderBy('created_at', 'desc');
});
}
}
Now the controller becomes much cleaner:
// In a controller
$products = Product::active()
->inStock()
->when($request->has('category'), function ($query) use ($request) {
return $query->inCategory($request->category);
})
->priceRange($request->price_min, $request->price_max)
->sortBy($request->sort)
->paginate(20);
This approach offers several advantages:
- The code is more readable and self-documenting
- Query logic is reusable across controllers
- Testing becomes easier since query methods can be tested in isolation
- New filtering options can be added by extending the builder
Advanced Techniques
Combining Multiple Conditions
You can create methods that combine multiple conditions:
public function featuredAndDiscounted()
{
return $this->where('is_featured', true)
->where('discount_percentage', '>', 0);
}
Parameterized Methods
Make your builder methods flexible with parameters:
public function olderThan($days)
{
return $this->where('created_at', '<', now()->subDays($days));
}
Working with Relationships
Your builder methods can incorporate relationships:
public function withActiveReviews()
{
return $this->whereHas('reviews', function ($query) {
$query->where('status', 'approved');
});
}
Handling Complex Logic
For very complex filtering, you can accept closures:
public function withAdvancedFilters($filterCallback)
{
return $filterCallback($this);
}
Best Practices
- Keep methods focused - Each method should handle a single aspect of filtering.
- Use descriptive names - Method names should clearly describe their purpose.
- Document with DocBlocks - Add PHPDoc comments to explain parameters and return values.
- Return $this - Always return the builder instance for method chaining.
- Write tests - Create unit tests for your builder methods to ensure they work as expected.
Conclusion
Custom Eloquent Builders are a powerful tool for organizing your query logic, making your code more maintainable, and creating a domain-specific query language for your application.
By identifying the right time to implement them—when models become bloated, queries are complex, or you need reusable query patterns—you can significantly improve your codebase's structure and readability.
Start small by identifying the most common query patterns in your application, move them to a custom builder, and gradually expand as you see the benefits of this approach. Your future self (and your team) will thank you for the clean, maintainable code that results from this architectural decision.
Top comments (1)
If domain driven design is the architecture of the application, the controller should use a repository with a method that is the best fit for the controller.
Where statements like active, in stock and pages should be controlled by business logic in the domain.
If the goal is to have reusable local scopes, traits are a better option.
This way it is possible to connect the local scope to multiple models like a global scope.
While the
Builder
class is well intentioned it doesn't solve any problems, it only adds more abstraction.