« back published by @mmartin_joo on January 25, 2022

Build Your Own Query Builders In Laravel

In Laravel we often struggle with models that have too much business logic in them. Fortunately you can build your own query builder classes to make your models a bit leaner.

In this example I'm working with a Book model. To get started we can make a class called BookBuilder:

namespace App\Builders;

use App\Models\User;
use Illuminate\Database\Eloquent\Builder;

class BookBuilder extends Builder
{
}

It has to extend the Eloquent\Builder. After that we have to instruct Laravel to get a new instance from this class whenever we want to build a query on the Book model. We can override the newEloquentBuilder() to achieve this:

namespace App\Models;

use App\Builders\BookBuilder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Book extends Model
{
    use HasFactory;

    public function author(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    public function ratings(): HasMany
    {
        return $this->hasMany(Rating::class);
    }

    public function newEloquentBuilder($query): BookBuilder
    {
        return new BookBuilder($query);
    }
}

Now every time you start to build a query with Book::something() you will get a BookBuilder instance. What can we do with it?

Scopes

Did you know that model scope is just syntactic sugar around query builder? Here's how you can use them without magic:

class BookBuilder extends Builder
{
    public function wherePublished(): self
    {
        return $this->where('publish_at', '<=', now());
    }

    public function whereAuthor(User $user): self
    {
        return $this->where('author_id', $user->id);
    }

    public function wherePriceBetween(float $from, float $to): self
    {
        return $this->whereBetween('price', [$from, $to]);
    }
}

I like to start every scope with where because it seems more expressive in a query. The important thing is that you have to return a BookBuilder instance from every method, since we want to chain these methods. Notice that there is no get() or all() or anything like that after the where() calls.

In this class you have no limitations, you can build any query you want. So here's one with some where groups:

public function whereContains(string $searchTerm): self
{
    return $this->where(function ($query) use ($searchTerm) {
        $query->where('title', 'LIKE', "%$searchTerm%")
            ->orWhere('description', 'LIKE', "%$searchTerm%");
    });
}

Now, let's say business needs an API endpoint that returns:

  • Every book that is published
  • You can filter by author
  • You can filter by price range
  • It returns the books ordered by ratings (ratings is a hasMany relationship to a Rating model)

We can easily build this query like this:

class BookController extends Controller
{
    public function index(Request $request)
    {
        return Book::query()
            ->wherePublished()
            ->when($request->authorId, fn ($query) => $query->whereAuthor(User::find($request->authorId)))
            ->when($request->fromPrice, fn ($query) => $query->wherePriceBetween($request->fromPrice, $request->toPrice))
            ->orderByRatings()
            ->get();
    }
}

The when() method comes from the base builder class. It only runs the callback if the first parameter is truthy. The query() is not necessary. I only use it because it makes the whole chain more readable. You can omit it. The only missing piece is the orderByRatings():

public function orderByRatings(): self
{
    return $this->withAvg('ratings as average_rating', 'rating')
        ->orderByDesc('average_rating');
}

The withAvg() method has nothing to do with our custom query builder. It comes from Eloquent. Of course we can re-use these methods in the builder. Let's business needs another API for retrieving the N most popular books:

public function mostPopular(int $count): self
{
    return $this->orderByRatings()
        ->take($count);
}

And the controller action:

public function popular()
{
    return Book::mostPopular(5)->get();
}

Basically we can move our queries from the models to a dedicated class that only contains queries.

One more thing about query builder. Since we wrote methods that returns some books. What about methods that manipulate a concrete Book instance?

You can access the model via the model poperty:

public function publish(): self
{
    $this->model->publish_at = now();
    $this->model->save();

    return $this;
}

And you can use it like this:

public function publish(Book $book)
{
    $book->publish();
}

Remember that we returned a new BookBuilder from the model class? We can use this fact to our advantage. Often we have a state in our model, like a status for the Invoice model. Paid, unpaid, overdue, draft and so on. It can be very complicated. And I often find myself writing a lot of logic specifically for one status. Maybe I have to write 200 lines of code specifically for the unpaid invoices. Now, with query builders we can separate this logic by creating a custom builder class for unpaid invoices. So we can have classes like:

  • InvoiceBuilder
  • UnpaidInvoiceBuilder
  • PaidInvoiceBuilder

Where the base InvoiceBuilder contains some common logic, but the UnpaidInvoiceBuilder only contains logic related to unpaid invoices. This is a great way to clean your code.

Back to our book example we can do something like this:

public function newEloquentBuilder($query): BookBuilder
{
    if (!$this->exists) {
        return new BookBuilder($query);
    }

    if ($this->publish_at->isFuture()) {
        return new UnpublishedBookBuilder($query);
    }

    return new PublishedBookBuilder($query);
}

The Book model returns a builder based on the book's state. Remember we can build queries where there is no model at all. In this case we return a simple BookBuilder.

Now we can move the publish() method to the new UnpublishedBookBuilder class:

class UnpublishedBookBuilder extends BookBuilder
{
    public function publish(): self
    {
        $this->model->publish_at = now();
        $this->model->save();

        return $this;
    }
}

A query builder like this only makes sense when it has a model, so in this class we won't write functions like whereAuthor(). In this class we only write functions that wants to manipulate unpublished books.

If we want to publish more than one book we can write:

public function publishAll()
{
    Book::whereNotPublished()->get()->each->publish();
}

If you don't want to fire N query, you can write:

public function publishAll()
{
    Book::query()
        ->whereNotPublished()
        ->update(['publish_at' => now()]);
}

Of course if you want to re-use the publishAll() method (and for some reason you can't put it in a service or action) you can move it to the BookBuilder:

public function publishAll(): self
{
    $this
        ->whereNotPublished()
        ->update(['publish_at' => now()]);

    return $this;
}

That's our basic options. I think with custom builders we have a lot of flexibility and opportunity to make our code clean. I like them, and I use them frequently! Basically I think that custom query builders are the Laravel way to implement the Repository "pattern".

Check out Domain-Driven Design with Laravel where I build a complex e-mail service provider using query builders and other concepts!

Domain-Driven Design with Laravel