marifyahya / laravel-eloquent-filter
Elegant search and filter library for Laravel Eloquent
Package info
github.com/marifyahya/laravel-eloquent-filter
pkg:composer/marifyahya/laravel-eloquent-filter
Requires
- php: ^8.2
- illuminate/database: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0|^10.0
- phpunit/phpunit: ^10.0|^11.0
README
Elegant, whitelisted search and filter utilities for Laravel Eloquent models.
Requirements
- PHP
^8.2 - Laravel components
^11.0,^12.0, or^13.0 - MySQL, PostgreSQL, SQLite, or SQL Server
Installation
composer require marifyahya/laravel-eloquent-filter
Optionally publish the request stub:
php artisan vendor:publish --provider="Marifyahya\EloquentFilter\EloquentFilterServiceProvider" --tag=request
The package is configured from each model or from the second argument passed to filter(). No global config options are required.
Quick Start
Add the HasEloquentFilter trait and define the allowed fields on your model:
<?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', 'q' => ['title', 'content'], ]; }
Use filter() before paginate() 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); } }
Example requests:
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 request:
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. |
You can also pass configuration per query:
$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);
Filtering Basics
filterableFields is an allowlist. It controls which columns may be filtered with exact values, comparison operators, comma-separated values, arrays, null checks, and between checks.
The request value decides the filter behavior:
| Request value | Behavior |
|---|---|
published |
Exact match: where('field', 'published') |
>100 |
Greater than: where('field', '>', 100) |
>=100 |
Greater than or equal: where('field', '>=', 100) |
<50 |
Less than: where('field', '<', 50) |
<=50 |
Less than or equal: where('field', '<=', 50) |
!=draft |
Not equal: where('field', '!=', 'draft') |
active,pending |
In list: whereIn('field', ['active', 'pending']) |
!draft,archived |
Not in list: whereNotIn('field', ['draft', 'archived']) |
null |
Is null: whereNull('field') |
!null |
Is not null: whereNotNull('field') |
<>10,100 |
Between: whereBetween('field', [10, 100]) |
Field-level LIKE filters are intentionally not part of the core syntax. Use search for global text search, or custom filters when a specific field should use LIKE.
Example model:
protected $filterableFields = [ 'name', 'category', 'status', 'views', ];
Example requests:
GET /posts?status=published GET /posts?category=tech GET /posts?views=>100
Search
Search applies one keyword across all fields listed in searchableFields or the searchable config key.
protected $searchableFields = [ 'name', 'category', ];
GET /posts?search=laravel
This searches both name and category using OR:
WHERE ( name LIKE '%laravel%' OR category LIKE '%laravel%' )
Use search when one keyword should be searched across multiple fields.
Exact Match
Exact match filters compare a request value directly with a whitelisted column.
GET /posts?status=published GET /posts?category=tech
These apply:
WHERE status = 'published' WHERE category = 'tech'
Multiple exact filters are combined using AND:
GET /posts?status=published&category=tech
WHERE status = 'published' AND category = 'tech'
Field-Level LIKE
The package keeps field-level LIKE matching as custom behavior. This keeps the core syntax simple and lets each project decide which fields should use partial matching.
Define a custom filter method on your model:
class Post extends Model { use HasEloquentFilter; protected $filterableFields = ['name', 'category']; public function filterName($query, $value): void { $query->where('name', 'LIKE', "%{$value}%"); } public function filterCategory($query, $value): void { $query->where('category', 'LIKE', "%{$value}%"); } }
Then use clean request values:
GET /posts?name=contoh&category=tech
This applies:
WHERE name LIKE '%contoh%' AND category LIKE '%tech%'
You can also use custom_filters callbacks when you do not want to place filter methods on the model.
Multiple Values
Comma-separated values use WHERE IN:
GET /posts?status=active,pending,draft GET /posts?status[]=active&status[]=pending
Prefix the value with ! to use WHERE NOT IN:
GET /posts?status=!draft,archived
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
NULL / NOT NULL
GET /posts?deleted_at=null GET /posts?deleted_at=!null
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.
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
The request above is normalized to:
categoryId -> category_id
createdAtFrom -> created_at_from
createdAtTo -> created_at_to
sortBy -> sort_by
sortDir -> sort_dir
Relation existence keys are normalized too:
GET /posts?hasBlogComments=true
hasBlogComments -> has_blog_comments
Sorting
Sorting is ignored unless the requested field is listed in sortableFields or the sortable config key.
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
| 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
| 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 Filtering
Relation Existence
Post::filter($request->all(), [ 'relation_exists' => ['comments', 'likes'], ]);
GET /posts?has_comments=true GET /posts?has_comments=false
If key normalization is enabled, camelCase relation existence keys also work:
Post::filter($request->all(), [ 'normalize_keys' => true, 'relation_exists' => ['blogComments'], ]);
GET /posts?hasBlogComments=true
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.
Filter Aliases
Use filterableMap to expose public aliases without exposing internal column names.
class User extends Model { protected $filterableMap = [ 'name' => ['firstname', 'lastname'], 'user' => 'user_id', ]; }
GET /users?name=john GET /users?user=10
A multi-column alias searches each mapped column with LIKE and groups them with OR:
WHERE ( firstname LIKE '%john%' OR lastname LIKE '%john%' )
Custom Filters
Model 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
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, ], ]);
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.
- Avoid raw SQL in custom filters unless values are safely bound.
- Do not build filter or sort allowlists from request input.
Testing
./vendor/bin/phpunit
License
MIT