DEV Community

Cover image for Writing Your Own Eloquent Builder in Laravel
Asfia Aiman
Asfia Aiman

Posted on

1

Writing Your Own Eloquent Builder in Laravel

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:

  1. Your model has too many query scopes

    If your model file is dominated by scope methods (scopeActive(), scopeVerified(), etc.), it's time to refactor.

  2. You repeat the same query patterns

    When you find yourself writing identical query logic in multiple controllers or services.

  3. Your queries reflect complex domain concepts

    When queries represent sophisticated business rules or domain-specific filtering.

  4. Query logic is becoming difficult to test

    When testing query logic in controllers becomes cumbersome.

  5. 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);
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

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...
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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');
            });
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Parameterized Methods

Make your builder methods flexible with parameters:

public function olderThan($days)
{
    return $this->where('created_at', '<', now()->subDays($days));
}
Enter fullscreen mode Exit fullscreen mode

Working with Relationships

Your builder methods can incorporate relationships:

public function withActiveReviews()
{
    return $this->whereHas('reviews', function ($query) {
        $query->where('status', 'approved');
    });
}
Enter fullscreen mode Exit fullscreen mode

Handling Complex Logic

For very complex filtering, you can accept closures:

public function withAdvancedFilters($filterCallback)
{
    return $filterCallback($this);
}
Enter fullscreen mode Exit fullscreen mode

Best Practices

  1. Keep methods focused - Each method should handle a single aspect of filtering.
  2. Use descriptive names - Method names should clearly describe their purpose.
  3. Document with DocBlocks - Add PHPDoc comments to explain parameters and return values.
  4. Return $this - Always return the builder instance for method chaining.
  5. 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.

Hostinger image

Get n8n VPS hosting 3x cheaper than a cloud solution

Get fast, easy, secure n8n VPS hosting from $4.99/mo at Hostinger. Automate any workflow using a pre-installed n8n application and no-code customization.

Start now

Top comments (1)

Collapse
 
xwero profile image
david duymelinck

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.

$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);
// can be
$products = ProductRepository->getFilteredPage(ProductFiltersDTO::fromRequest($request));
Enter fullscreen mode Exit fullscreen mode

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.

namespace App\Models\Scopes;

use Illuminate\Database\Eloquent\Builder;

trait ActiveScope
{
    public function scopeActive(Builder $query): void
    {
        $query->where('active', 1);
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

Image of Datadog

The Essential Toolkit for Front-end Developers

Take a user-centric approach to front-end monitoring that evolves alongside increasingly complex frameworks and single-page applications.

Get The Kit

👋 Kindness is contagious

If this post resonated with you, feel free to hit ❤️ or leave a quick comment to share your thoughts!

Okay