In Laravel, models are often the backbone of an application, responsible for handling data retrieval, manipulation, and persistence. However, as an application grows, models can become bloated with complex and too much business logic. This can make maintenance and scalability challenging. Fortunately, we can build our own query builder classes to make our models a bit leaner.
An example of a subscriber model
Let's consider an example where we have a subscribers list in our application.
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Subscriber extends Model
{
protected $table = 'subscribers';
protected $fillable = [
'email',
'name',
'status',
'subscribed_at',
];
protected $dates = [
'subscribed_at',
'created_at',
'updated_at',
];
}
We want to provide functionalities like filtering and sorting for the subscribers. Initially, our Subscriber
model might contain the logic to retrieve the subscriber's list and perform various operations.
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Subscriber extends Model
{
protected $table = 'subscribers';
protected $fillable = [
'email',
'name',
'status',
'subscribed_at',
];
protected $dates = [
'subscribed_at',
'created_at',
'updated_at',
];
public function scopeFilterByStatus($query, $status)
{
if ($status) {
$query->where('status', $status);
}
}
public function scopeWhereContains($query, $search)
{
if ($search) {
$query->where('email', 'LIKE', "%$search%")
->orWhere('name', 'LIKE', "%$search%");
}
}
public function scopeWhereSubscribedAtBetween($query, $start, $end)
{
if ($start && $end) {
$query->whereBetween('subscribed_at', [$start, $end]);
}
}
public function scopeSortBy($query, $column, $direction = 'asc')
{
$query->orderBy($column, $direction);
}
}
As we continue to add more business logic to the above model, it will keep growing and get bloated with code that is hard to understand.
Custom query builder
However, we can refactor this code by extracting the query building logic into a custom query builder in 4 simple steps.
Relocate the scope in a query builder class
Remove the
$query
parameter and replace with$this
on query statementRemove the
scope
prefix for model scopes and fix the casingreturn
$this
namespace App\Models\Builders;
use Illuminate\Database\Eloquent\Builder;
class SubscriberQueryBuilder extends Builder
{
public function filterByStatus($status)
{
if ($status) {
return $this->where('status', $status);
}
return $this;
}
public function whereContains($search)
{
if ($search) {
return $this->where(function ($query) use ($search) {
$query->where('email', 'LIKE', "%$search%")
->orWhere('name', 'LIKE', "%$search%");
});
}
return $this;
}
public function whereSubscribedAtBetween($start, $end)
{
if ($start && $end) {
return $this->whereBetween('subscribed_at', [$start, $end]);
}
return $this;
}
public function sortBy($column, $direction = 'asc')
{
return $this->orderBy($column, $direction);
}
}
And then we need to add a new function newEloquentBuilder()
in our model which will return the new SubscriberQueryBuilder
class we just made.
<?php
namespace App\Models;
use App\Models\Builders\SubscriberQueryBuilder;
use Illuminate\Database\Eloquent\Model;
class Subscriber extends Model
{
protected $table = 'subscribers';
protected $fillable = [
'email',
'name',
'subscribed_at',
'status',
];
protected $dates = [
'subscribed_at',
'created_at',
'updated_at',
];
public function newEloquentBuilder($query): SubscriberQueryBuilder
{
return new SubscriberQueryBuilder($query);
}
}
Then, We can use our new custom query builder filters as in the example below.
public function index(Request $request): JsonResponse {
$searchTerm = $request->input('search');
$start = $request->input('start');
$end = $request->input('end');
$filteredSubscribers = Subscriber::query()
->whereContains($searchTerm)
->whereSubscribedAtBetween($start, $end)
->filterByStatus('active')
->sortBy('name', 'asc')
->paginateResults(25);
return response()->json($filteredSubscribers);
}
Handling multiple where scopes
Eloquent under the hood detects how many where we applied in a single scope and automatically group them if we add more than one. If we are chaining multiple where
in a single scope then we need to group them manually when using this new approach.
// on model scopes
public function scopeWhereContains($query, $search)
{
if ($search) {
$query->where('email', 'LIKE', "%$search%")
->orWhere('name', 'LIKE', "%$search%");
}
}
// on the dedicated builder class
public function whereContains($search)
{
if ($search) {
return $this->where(function ($query) use ($search) {
$query->where('email', 'LIKE', "%$search%")
->orWhere('name', 'LIKE', "%$search%");
});
}
return $this;
}
Sharing scopes across models
If we want to share a handful of scopes with a particular set of models, then traits are still a great way to go about that. We can use the trait on the model as we have been doing, or we could use the dedicated query builder approach and use the trait on the query builders.
Note**: we need to ensure that the way we are defining the scope methods matches where we put them.*
However, if we are looking to share scopes across all our models, we can also utilize inheritance. To do this we can create a base query builder and all our model-specific query builders will inherit from this.
namespace App\Models\Builders;
use Illuminate\Database\Eloquent\Builder as BaseBuilder;
class Builder extends BaseBuilder
{
public function filterByStatus($status)
{
if ($status) {
return $this->where('status', $status);
}
return $this;
}
// other shared filters...
}
Conclusion
By separating query construction from the models themselves, we can keep the models focused on their core responsibilities and make the code more maintainable, testable and easier to read.