laravel-eloquent-filter maintained by marifyahya
Laravel Eloquent Filter
Elegant search and filter utilities for Laravel Eloquent models.
Requirements
- PHP: ^8.2
- Laravel components: ^11.0, ^12.0, or ^13.0
- Database: MySQL, PostgreSQL, SQLite, or SQL Server
Installation
composer require marifyahya/laravel-eloquent-filter
Optionally publish the config and request stub:
php artisan vendor:publish --provider="Marifyahya\EloquentFilter\EloquentFilterServiceProvider" --tag=config
php artisan vendor:publish --provider="Marifyahya\EloquentFilter\EloquentFilterServiceProvider" --tag=request
Model Setup
Add the HasEloquentFilter trait to your model and whitelist the fields that can be filtered, searched, sorted, or used as date ranges.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Marifyahya\EloquentFilter\Traits\HasEloquentFilter;
class Post extends Model
{
use HasEloquentFilter;
protected $filterableFields = ['id', 'status', 'category_id', 'views', 'is_featured'];
protected $sortableFields = ['id', 'title', 'status', 'views', 'created_at'];
protected $searchableFields = ['title', 'content'];
protected $dateRangeFields = ['created_at', 'published_at'];
protected $normalizeFilterKeys = true;
protected $filterableMap = [
'post_id' => 'id',
'author_name' => ['author_firstname', 'author_lastname'],
];
}
If sortableFields is not set, the package falls back to filterableFields.
Usage
Use filter() in your controller before pagination or get().
<?php
namespace App\Http\Controllers;
use App\Models\Post;
use Illuminate\Http\Request;
class PostController extends Controller
{
public function index(Request $request)
{
return Post::filter($request->all())->paginate(15);
}
}
use App\Models\Post;
// Auto from request()->all()
$posts = Post::filter()->get();
// With custom config
$posts = Post::filter($request->all(), [
'searchable' => ['title', 'content'],
'filterable' => ['status', 'category_id'],
'sortable' => ['title', 'views', 'created_at'],
'date_ranges' => ['created_at'],
'normalize_keys' => true,
])->paginate(15);
Query Examples
GET /posts?search=laravel
GET /posts?status=published
GET /posts?status=published,draft
GET /posts?views=>100
GET /posts?created_at_from=2024-01-01&created_at_to=2024-12-31
GET /posts?sort=-created_at
GET /posts?sort_by=views&sort_dir=desc
Combined example:
GET /posts?search=laravel&status=published&views=>100&created_at_from=2024-01-01&sort=-created_at
Model Properties
| Property | Purpose |
|---|---|
$filterableFields |
Columns allowed for exact, operator, comma-separated, array, and null filters. |
$sortableFields |
Columns allowed for sort and sort_by. Falls back to $filterableFields when not set. |
$searchableFields |
Columns searched by the search query parameter. |
$dateRangeFields |
Date columns allowed for {field}_from and {field}_to filters. |
$filterableMap |
Public aliases mapped to real columns or multiple columns. |
$customFilters |
Custom filter classes or callbacks keyed by request parameter. |
$normalizeFilterKeys |
Converts camelCase request keys to snake_case before filtering. |
Filter Capabilities
Search
Search applies a partial LIKE query across fields listed in searchableFields or the searchable config key.
GET /posts?search=laravel
Exact Match
GET /posts?status=published
Multiple Values
Comma-separated values use WHERE IN. Prefix the value with ! to use WHERE NOT IN.
GET /posts?status=active,pending,draft
GET /posts?status=!draft,archived
GET /posts?status[]=active&status[]=pending
NULL / NOT NULL
GET /posts?deleted_at=null
GET /posts?deleted_at=!null
Operators
GET /posts?views=>100
GET /posts?views=<50
GET /posts?views=>=10
GET /posts?views=<=100
GET /posts?views=!=0
GET /posts?status=!=draft,archived
GET /posts?title=likeLaravel
Between
GET /posts?views=<>10,100
Date Range
Date ranges are enabled only for fields listed in dateRangeFields or the date_ranges config key.
GET /posts?created_at_from=2024-01-01&created_at_to=2024-12-31
Camel Case Request Keys
Enable key normalization when your API receives camelCase request keys from a frontend client. Request keys are normalized to snake_case before filtering.
You can enable it on the model:
class Post extends Model
{
use HasEloquentFilter;
protected $normalizeFilterKeys = true;
}
Or enable it for a single query:
Post::filter($request->all(), [
'normalize_keys' => true,
'filterable' => ['category_id'],
'sortable' => ['created_at'],
'date_ranges' => ['created_at'],
]);
GET /posts?categoryId=2&createdAtFrom=2024-01-01&createdAtTo=2024-12-31&sortBy=created_at&sortDir=desc
Sorting
Sorting is ignored unless the requested field is listed in sortableFields or the sortable config key.
There are two supported sorting styles.
sort_by + sort_dir
Use this style when your frontend has separate sort field and direction values.
GET /posts?sort_by=views&sort_dir=desc
GET /posts?sort_by=views&sort_dir=DESC
GET /posts?sort_by=title&sort_dir=asc
Rules:
| Parameter | Description |
|---|---|
sort_by |
Column to sort by. Must be listed in sortableFields or sortable. |
sort_dir |
Sort direction. Supports asc, desc, ASC, and DESC. Defaults to asc when invalid or missing. |
sort
Use this style when you want compact API query parameters.
GET /posts?sort=title
GET /posts?sort=-created_at
Rules:
| Example | Result |
|---|---|
sort=title |
Sort by title ascending. |
sort=created_at |
Sort by created_at ascending. |
sort=-created_at |
Sort by created_at descending. |
sort=-views |
Sort by views descending. |
The minus prefix means descending order. Without the minus prefix, sorting is ascending.
If both sort_by and sort are present, sort_by takes priority.
Unknown or non-whitelisted sort fields are ignored:
GET /posts?sort=password
GET /posts?sort_by=non_existing_column&sort_dir=desc
Soft Deletes
These filters only apply to models that use Laravel's SoftDeletes trait.
GET /posts?trashed=only
GET /posts?trashed=with
Relation Existence
Post::filter($request->all(), [
'relation_exists' => ['comments', 'likes'],
]);
GET /posts?has_comments=true
GET /posts?has_comments=false
Relation Fields
Post::filter($request->all(), [
'relations' => [
'author' => ['status'],
],
]);
GET /posts?author.status=active
Relation filtering is supported, but sorting by relation columns is not supported yet.
Filterable Map
Use a map to expose public aliases without exposing internal column names. A multi-column alias searches each mapped column with LIKE and groups them with OR.
class User extends Model
{
protected $filterableMap = [
'name' => ['firstname', 'lastname'],
'user' => 'user_id',
];
}
GET /users?name=john
GET /users?user=10
Custom Filter Method
Model methods named filter{Field} take priority over the default filtering behavior.
class Post extends Model
{
public function filterStatus($query, $value): void
{
if ($value === 'published,reviewed') {
$query->whereIn('status', ['published', 'reviewed'])
->where('approved', true);
return;
}
$query->where('status', $value);
}
}
GET /posts?status=published,reviewed
Custom Filter Class
class PopularFilter
{
public function apply($query, $value): void
{
$query->where('views', '>', 1000);
}
}
Post::filter($request->all(), [
'custom_filters' => [
'popular' => \App\Filters\PopularFilter::class,
],
]);
Custom Filter Callback
Post::filter($request->all(), [
'custom_filters' => [
'title' => fn($query, $value) => $query->where('title', 'LIKE', "%{$value}%"),
],
]);
Security Notes
- Only whitelist columns that are safe to expose to users.
- Sorting is whitelisted separately from filtering.
- Unknown filters and non-sortable sort fields are ignored.
- Use custom filters for complex authorization-aware conditions.
Testing
./vendor/bin/phpunit
License
MIT