Tone down your Laravel models using custom query builders

Tone down your Laravel models using custom query builders

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.

  1. Relocate the scope in a query builder class

  2. Remove the $query parameter and replace with $this on query statement

  3. Remove the scope prefix for model scopes and fix the casing

  4. return $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.