mohammad-fouladgar / eloquent-builder
Package info
github.com/mohammad-fouladgar/eloquent-builder
pkg:composer/mohammad-fouladgar/eloquent-builder
Requires
- php: ^8.3
- illuminate/database: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- driftingly/rector-laravel: ^2.6
- laravel/pint: ^1.32
- mockery/mockery: ^1.4
- orchestra/testbench: ^7.0|^8.0|^10.0|^11.0
- php-coveralls/php-coveralls: ^2.1
- phpunit/phpunit: ^10.5.17|^11.5.3
- rector/rector: ^2.6
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-29 09:34:06 UTC
README
If you are upgrading from v5 to v6, see UPGRADE.md.
Build clean, reusable, request-driven Eloquent queries in Laravel.
Eloquent Builder lets you map incoming request parameters to reusable Eloquent filters without filling your controllers with conditional query logic.
Current release: v6 PHP: 8.3+
Why Eloquent Builder?
Filtering an Eloquent query can quickly become difficult to maintain when every request parameter requires its own conditional logic:
$users = User::where('is_active', true); if ($request->has('age_more_than')) { $users->where('age', '>', $request->age_more_than); } if ($request->has('gender')) { $users->where('gender', $request->gender); } if ($request->has('has_published_post')) { $users->whereHas('posts', function ($query) use ($request) { $query->where('is_published', $request->has_published_post); }); }
With Eloquent Builder, the filtering logic can live in dedicated filter classes or lightweight Quick Filters:
return EloquentBuilder::model(User::class) ->filters($request->filter) ->thenApply() ->get();
Your controllers stay focused on application flow while query-specific logic stays inside filters.
Table of Contents
Installation
Install the package via Composer:
composer require mohammad-fouladgar/eloquent-builder
Requirements
- PHP 8.3+
- Laravel Eloquent
Warning: The
Lumenframework is no longer supported.
Upgrading from v5
If you are upgrading an existing application from v5, see UPGRADE.md for breaking changes and new features introduced in v6.
Quick Start
Suppose your API accepts these filters:
GET /api/users?filter[age_more_than]=25&filter[gender]=male
Create an AgeMoreThanFilter:
<?php namespace App\EloquentFilters\User; use Fouladgar\EloquentBuilder\Support\Foundation\Contracts\Filter; use Illuminate\Database\Eloquent\Builder; class AgeMoreThanFilter extends Filter { public function apply(Builder $builder, mixed $value): Builder { return $builder->where('age', '>', $value); } }
Then apply the request filters:
use App\Models\User; return EloquentBuilder::model(User::class) ->filters($request->filter) ->thenApply() ->get();
The request key age_more_than is resolved to AgeMoreThanFilter.
For simple conditions, you can skip the filter class entirely and use a Quick Filter.
Filters
Defining a Filter
A filter is a class that extends:
Fouladgar\EloquentBuilder\Support\Foundation\Contracts\Filter
The class must implement apply().
For example:
<?php namespace App\EloquentFilters\User; use Fouladgar\EloquentBuilder\Support\Foundation\Contracts\Filter; use Illuminate\Database\Eloquent\Builder; class AgeMoreThanFilter extends Filter { public function apply(Builder $builder, mixed $value): Builder { return $builder->where('age', '>', $value); } }
Filter classes should use the Filter suffix.
With the default namespace, filters for User live under:
App\EloquentFilters\User
For example:
app/
└── EloquentFilters/
└── User/
├── AgeMoreThanFilter.php
└── GenderFilter.php
Tip: You can use Laravel local scopes inside your filter classes as well.
Generating Filters with Artisan
Create a filter with:
php artisan eloquent-builder:make user age_more_than
You can generate multiple filters at once:
php artisan eloquent-builder:make user age_more_than gender
Using Filters
Model class
$users = EloquentBuilder::model(User::class) ->filters(request()->filter) ->thenApply() ->get();
Existing query
You can start with an existing Eloquent query:
$query = User::where('is_active', true); $users = EloquentBuilder::model($query) ->filters(request()->filter) ->thenApply() ->where('city', 'london') ->get();
Programmatically adding filters
You can also push filter values directly:
$users = EloquentBuilder::model(new User()) ->filters(request()->filter) ->filter(['age_more_than' => '30']) ->filter(['gender' => 'female']) ->thenApply() ->get();
When the same filter key is pushed more than once, the latest value takes precedence in v6.
Fluent Usage
For the common filters() use case, you can use the filter() macro directly on an Eloquent model or query:
$users = User::filter(request()->filter)->get();
It also works with an existing query:
$users = User::where('is_active', true) ->filter(request()->filter) ->get();
This is the simplest way to apply standard filters without using EloquentBuilder::model().
For advanced features such as:
quickFilters()filterGroups()defaults()ignoreValues()
use the full EloquentBuilder API.
Tip: It is recommended to put filter parameters under a
filterrequest key:
/api/users?filter[age_more_than]=25&filter[gender]=male
Then access them through:
$request->filter
Quick Filters
Quick Filters are designed for simple filtering logic where creating a dedicated filter class would add unnecessary boilerplate.
Import:
use Fouladgar\EloquentBuilder\Support\Foundation\Concrete\QuickFilter;
Then define filters inline:
EloquentBuilder::model(User::class) ->quickFilters([ QuickFilter::exact('gender'), QuickFilter::partial('name'), QuickFilter::scope('online'), QuickFilter::callback( 'has_posts', fn (Builder $builder, mixed $value) => $builder->whereHas('posts') ), QuickFilter::trashed(), ]) ->filters($request->filter) ->thenApply() ->get();
Quick Filter Reference
| Quick Filter | Purpose |
|---|---|
exact() |
Exact = comparison |
partial() |
LIKE %value% comparison |
scope() |
Apply an Eloquent local scope |
callback() |
Apply custom query logic |
trashed() |
Control soft-deleted records |
includes() |
Eager-load allowed relationships |
fields() |
Select allowed root-model columns |
Exact
Use exact() for a simple equality condition.
QuickFilter::exact('status')
Request:
GET /api/users?filter[status]=active
Equivalent query:
$query->where('status', 'active');
You can map the request key to a different column:
QuickFilter::exact('user_status', 'status')
Request:
GET /api/users?filter[user_status]=active
This applies:
$query->where('status', 'active');
Partial
Use partial() for a LIKE query:
QuickFilter::partial('name')
Request:
GET /api/users?filter[name]=john
Equivalent query:
$query->where('name', 'LIKE', '%john%');
You can also map the request key to a different column:
QuickFilter::partial('search', 'name')
Scope
Use scope() when the filtering logic already exists as an Eloquent local scope.
For example:
public function scopeOnline(Builder $query, mixed $value): Builder { return $query->where('is_online', $value); }
Register the scope:
QuickFilter::scope('online')
Request:
GET /api/users?filter[online]=1
The corresponding local scope is invoked with the filter value.
If the request key and scope name are different:
QuickFilter::scope('active', 'online')
This maps:
filter[active]
↓
scopeOnline()
Callback
Use callback() when you need custom query logic but do not want to create a dedicated filter class.
QuickFilter::callback( 'has_posts', fn (Builder $builder, mixed $value) => $builder->whereHas('posts') )
Request:
GET /api/users?filter[has_posts]=1
The callback receives:
Builder $builder mixed $value
and can modify the query.
For complex or reusable filtering logic, prefer a class-based filter.
Trashed
trashed() provides filtering for models using Laravel's SoftDeletes trait.
QuickFilter::trashed()
The default request key is:
trashed
Include trashed records
GET /api/posts?filter[trashed]=with
Equivalent to:
$query->withTrashed();
Only trashed records
GET /api/posts?filter[trashed]=only
Equivalent to:
$query->onlyTrashed();
Any other value uses the default behavior and excludes trashed records.
You can customize the request key:
QuickFilter::trashed('deleted')
Warning:
QuickFilter::trashed()requires Laravel'sSoftDeletestrait. Usingwithoronlywith a model that does not support soft deletes results in aBadMethodCallException.
Includes
includes() lets clients request eager-loaded relationships while restricting them to an explicit whitelist.
QuickFilter::includes('include', [ 'posts', 'posts.comments', 'profile', ])
Request:
GET /api/users?filter[include]=posts,posts.comments
The query will eager-load the requested allowed relationships.
The request may also provide an array:
[
'posts',
'posts.comments',
]
Whitelisting
Only relations present in the $allowed list are accepted.
For example:
QuickFilter::includes('include', [ 'posts.user', ])
allows:
posts.user
but does not automatically allow:
posts
Anything not present in the whitelist is ignored.
Fields
fields() lets clients request a limited set of columns from the root model.
QuickFilter::fields('fields', [ 'id', 'name', 'email', ])
Request:
GET /api/users?filter[fields]=name,email
The selected columns will include:
id
name
email
The primary key is always selected, even if it was not explicitly requested.
If none of the requested fields are allowed, only the primary key is selected.
Root model only
fields() currently applies only to the root model.
It does not provide relation-specific fieldsets such as:
fields[posts]=title
Use a class-based filter or QuickFilter::callback() for more advanced selection logic.
Important: fields() uses select()
fields() calls Eloquent's select() method.
That means it replaces the query's existing column list.
Avoid combining it with another filter that also calls select().
If another filter needs to add columns after fields() has been applied, use addSelect() where appropriate.
Quick Filter Precedence
Quick Filters take precedence over class-based filters when they use the same request key.
For example:
QuickFilter::exact('status')
takes precedence over a class-based StatusFilter for the same status key.
Note: Quick Filters do not support the
authorize()method available to class-based filters. Use a class-based filter when filter-level authorization is required.
Filter Groups
By default, filters are combined with AND.
For example:
GET /api/users?filter[status]=online&filter[is_featured]=true
normally produces:
WHERE status = 'online' AND is_featured = true
Use FilterGroup::or() to combine a group of filter keys with OR:
use Fouladgar\EloquentBuilder\Support\Foundation\Concrete\FilterGroup; EloquentBuilder::model(User::class) ->filterGroups([ FilterGroup::or([ 'status', 'is_featured', ]), ]) ->filters($request->filter) ->thenApply() ->get();
The same request now produces:
WHERE ( status = 'online' OR is_featured = true )
Filter groups can contain:
- class-based filters
- Quick Filters
- a combination of both
Only filter keys that are present in the request are applied.
If only one member is present, it behaves like a normal filter.
If none are present, the group is skipped.
Filters inside groups
Filter groups are intended for filters that add normal where constraints.
Good candidates include:
QuickFilter::exact()
QuickFilter::partial()
QuickFilter::scope()
QuickFilter::callback()
Avoid putting filters that change the overall query structure inside an OR group, especially filters that:
withoutGlobalScope()
orderBy()
with()
or otherwise modify query state outside the nested where condition.
QuickFilter::trashed() should also be used outside filter groups.
Defaults and Ignored Values
Defaults
Use defaults() when a filter should receive a value if none is provided.
EloquentBuilder::model(User::class) ->quickFilters([ QuickFilter::exact('status'), ]) ->defaults([ 'status' => 'online', ]) ->filters($request->filter) ->thenApply() ->get();
If the request does not contain filter[status], the default value is used.
An explicitly provided value takes precedence over the default.
Ignored Values
Use ignoreValues() when a specific incoming value should be treated as if the filter was not provided.
This is useful for UI sentinel values such as:
filter[status]=all
Example:
EloquentBuilder::model(User::class) ->quickFilters([ QuickFilter::exact('status'), ]) ->ignoreValues([ 'status' => ['all'], ]) ->filters($request->filter) ->thenApply() ->get();
The value all is ignored.
You can combine ignored values with defaults:
EloquentBuilder::model(User::class) ->quickFilters([ QuickFilter::exact('status'), ]) ->ignoreValues([ 'status' => ['all'], ]) ->defaults([ 'status' => 'online', ]) ->filters($request->filter) ->thenApply() ->get();
Now:
filter[status]=all
│
▼
ignored
│
▼
default = online
Values are compared as strings, so:
ignoreValues([ 'id' => [0], ])
also matches an incoming '0'.
Note:
defaults()only applies when the filter has no value after processing. A non-empty value that happens to contain no allowed items does not automatically trigger the default.
Predefined Filters
Eloquent Builder provides reusable traits for common filtering requirements:
- Date filtering
- Number filtering
- Sorting
Date Filters
Use FiltersDatesTrait for date-based filters.
Supported conventions:
between:date1,date2
before:date
before_or_equal:date
after:date
after_or_equal:date
same:date
equals:date
Date Examples
GET /api/users?birth_date=before:2018-01-01
Date Between
These forms can be used for a range:
birth_date=between:2018-01-01,2022-01-01
birth_date=2018-01-01,2022-01-01
birth_date[]=2018-01-01&birth_date[]=2022-01-01
Date Equals
These forms represent equality:
birth_date=equals:2018-01-01
birth_date=same:2018-01-01
birth_date=2018-01-01
Defining a date filter
<?php namespace App\EloquentFilters\User; use Fouladgar\EloquentBuilder\Concerns\FiltersDatesTrait; use Fouladgar\EloquentBuilder\Support\Foundation\Contracts\Filter; use Illuminate\Database\Eloquent\Builder; class BirthDateFilter extends Filter { use FiltersDatesTrait; public function apply(Builder $builder, mixed $value): Builder { return $this->filterDate( $builder, $value, 'birth_date' ); } }
Number Filters
Use FiltersNumbersTrait for numeric filters.
Supported conventions:
between:number1,number2
gt:number
gte:number
lt:number
lte:number
equals:number
Number Examples
GET /api/users?score=gte:500
Number Between
score=between:100,1010
or:
score=100,1010
or:
score[]=100&score[]=1010
Number Equals
score=equals:2222
or:
score=2222
Defining a number filter
<?php namespace App\EloquentFilters\User; use Fouladgar\EloquentBuilder\Concerns\FiltersNumbersTrait; use Fouladgar\EloquentBuilder\Support\Foundation\Contracts\Filter; use Illuminate\Database\Eloquent\Builder; class ScoreFilter extends Filter { use FiltersNumbersTrait; public function apply(Builder $builder, mixed $value): Builder { return $this->filterNumber( $builder, $value, 'score' ); } }
Sort Filters
Use SortableTrait to expose a controlled set of sortable columns.
For example:
GET /api/users?sort_by[birth_date]=desc&sort_by[id]=asc
You can also use:
GET /api/users?sort_by[]=birth_date:desc&sort_by[]=id:asc
If no direction is specified, asc is used:
GET /api/users?sort_by[]=birth_date
Defining a sort filter
<?php namespace App\EloquentFilters\User; use Fouladgar\EloquentBuilder\Concerns\SortableTrait; use Fouladgar\EloquentBuilder\Support\Foundation\Contracts\Filter; use Illuminate\Database\Eloquent\Builder; class SortByFilter extends Filter { use SortableTrait; protected array $sortable = [ 'birth_date', 'score', ]; public function apply(Builder $builder, mixed $value): Builder { return $this->applySort($builder, $value); } }
Only columns listed in $sortable can be requested by the client.
Custom sort resolvers
A sortable key can map to a custom closure instead of a direct database column.
For example, sorting by the number of posts:
protected array $sortable = [ 'birth_date', 'score', 'posts_count' => function ( Builder $builder, string $direction ): Builder { return $builder ->withCount('posts') ->orderBy('posts_count', $direction); }, ];
Request:
GET /api/users?sort_by[posts_count]=desc
The resolver receives the builder and sort direction and should mutate the provided builder.
Note: The resolver's return value is ignored. Returning a different
Builderinstance does not replace the current query.
Default sorting
Combine SortableTrait with defaults() to apply a default sort:
EloquentBuilder::model(User::class) ->defaults([ 'sort_by' => [ 'created_at' => 'desc', ], ]) ->filters($request->filter) ->thenApply() ->get();
Authorization
Class-based filters can implement authorize() when filter-level authorization is required.
For example:
public function authorize(): bool { return auth()->user()->hasPremiumAccount(); }
If authorize() returns false, the filter is rejected through Laravel's authorization mechanism.
You do not need to implement authorize() when a filter does not require authorization.
Note: Authorization is performed for filters that are resolved and applied through the Eloquent Builder pipeline. Quick Filters do not provide an
authorize()method.
Missing Filter Behavior
By default, an incoming filter key without a matching Quick Filter or filter class throws a FilterException.
You can change this behavior in the published configuration:
'ignore_missing_filters' => true,
With this option enabled, unrecognized filter keys are silently ignored.
This can be useful when a request payload contains additional parameters that are not intended to be filters.
Note: This only affects unrecognized keys. If a matching filter class exists but is not a valid
Filterinstance, that is still treated as an error.
Ignoring Empty and Null Values
Filter parameters with empty or null values are ignored.
For example:
filter[name]
filter[gender]=null
filter[age_more_than]=
filter[published_post]=true
Only the filter with a meaningful value is applied.
Custom Filter Namespaces
The default filter namespace is:
App\EloquentFilters\
You can customize it by publishing the configuration:
php artisan vendor:publish \
--provider="Fouladgar\EloquentBuilder\ServiceProvider" \
--tag="config"
Then configure:
return [ 'namespace' => 'App\\EloquentFilters\\', ];
Per-domain namespaces
For applications using domain-based structures, you can set the namespace for a specific query:
$stores = EloquentBuilder::model(\Domains\Entities\Store::class) ->filters($request->all()) ->setFilterNamespace('Domains\\Store\\Filters') ->thenApply() ->get();
For example:
Domains/
├── Store/
│ └── src/
│ └── Filters/
│ └── StoreFilter.php
│
└── User/
└── src/
└── Filters/
└── UserFilter.php
Note: When
setFilterNamespace()is used, the default namespace and configured namespace are ignored for that builder instance.
Dependency Injection
EloquentBuilder can be injected into a controller or another service.
For example:
<?php namespace App\Controllers; use App\Models\User; use Fouladgar\EloquentBuilder\EloquentBuilder; use Illuminate\Http\Request; class UserController { public function index( Request $request, User $user, EloquentBuilder $builder ) { $users = $user->newQuery() ->where('is_active', true); $builder ->model($users) ->filters($request->filter) ->thenApply(); return $users->get(); } }
Choosing the Right Filter
Use this as a quick decision guide:
| Requirement | Recommended API |
|---|---|
Simple = comparison |
QuickFilter::exact() |
Simple LIKE search |
QuickFilter::partial() |
| Existing Eloquent local scope | QuickFilter::scope() |
| One-off custom query logic | QuickFilter::callback() |
| Soft-delete filtering | QuickFilter::trashed() |
| Client-controlled eager loading | QuickFilter::includes() |
| Client-controlled root columns | QuickFilter::fields() |
| Reusable or complex logic | Class-based Filter |
| Filter-level authorization | Class-based Filter |
| OR conditions | FilterGroup::or() |
| Date comparisons | FiltersDatesTrait |
| Numeric comparisons | FiltersNumbersTrait |
| Controlled sorting | SortableTrait |
Testing
Run the test suite with:
composer test
Contributing
Please see CONTRIBUTING.md for details.
Security
If you discover a security-related issue, please email:
Please do not use the public issue tracker for security vulnerabilities.
License
Eloquent Builder is released under the MIT License.
See the LICENSE file for details.
Built with ❤️ for you.
