The Complete Guide to Filterable: Clean, Scalable Eloquent Filtering for Laravel
If you've ever built a Laravel API with search and filtering, you've probably written something like this:
$query = Post::query();
if ($request->status) {
$query->where('status', $request->status);
}
if ($request->title) {
$query->where('title', 'like', "%{$request->title}%");
}
if ($request->created_after) {
$query->where('created_at', '>=', $request->created_after);
}
// ...10 more conditions
It works. But as the project grows, this logic spreads across controllers, gets duplicated, and becomes nearly impossible to test in isolation.
Filterable solves this by moving all filtering logic into dedicated, testable filter classes — and giving you four different engines to structure that logic however fits your use case.
Installation
composer require kettasoft/filterable
php artisan vendor:publish --provider="Kettasoft\Filterable\Providers\FilterableServiceProvider" --tag="config"
Your First Filter
Generate a filter class using the CLI:
php artisan filterable:make-filter PostFilter --filters=title,status
This creates App\Http\Filters\PostFilter. Open it and define your filter methods:
namespace App\Http\Filters;
use Kettasoft\Filterable\Filterable;
use Kettasoft\Filterable\Support\Payload;
class PostFilter extends Filterable
{
protected $filters = ['status', 'title'];
protected function title(Payload $payload)
{
return $this->builder->where('title', 'like', $payload->asLike('both'));
}
protected function status(Payload $payload)
{
return $this->builder->where('status', $payload->value);
}
}
Apply it in your controller:
public function index()
{
return Post::filter(PostFilter::class)->paginate();
}
Now GET /posts?status=active&title=laravel automatically triggers the right methods. No if-statements, no manual $request->has() checks.
Binding the Filter to Your Model
If you always use the same filter class for a model, you can bind it directly:
use Kettasoft\Filterable\Traits\HasFilterable;
class Post extends Model
{
use HasFilterable;
protected $filterable = PostFilter::class;
}
Now your controller becomes a single line:
return Post::filter()->paginate();
Choosing an Engine
This is where Filterable stands apart from other filtering packages. Instead of forcing one approach, it ships with four engines. You pick the one that matches how your frontend sends data.
| Engine | Best For | Example |
|---|---|---|
| Invokable | Custom logic per field | ?status=active&title=laravel |
| Ruleset | Operator-based API queries | ?filter[title][like]=laravel |
| Expression | Ruleset + nested relations | ?filter[author.name][like]=ahmed |
| Tree | Complex AND/OR JSON logic | { "and": [...] } |
Let's go through each one.
1. Invokable Engine
The default engine. Each request key maps to a method in your filter class. Clean, explicit, and easy to understand.
class PostFilter extends Filterable
{
protected $filters = ['status', 'title', 'created_at'];
protected function status(Payload $payload)
{
return $this->builder->where('status', $payload->value);
}
protected function title(Payload $payload)
{
return $this->builder->where('title', 'like', $payload->asLike('both'));
}
protected function created_at(Payload $payload)
{
return $this->builder->whereDate('created_at', '>=', $payload->value);
}
}
You can also use PHP 8 Annotations to add behavior per method without writing extra code:
use Kettasoft\Filterable\Annotations\Cast;
use Kettasoft\Filterable\Annotations\SkipIf;
use Kettasoft\Filterable\Annotations\Between;
class PostFilter extends Filterable
{
protected $filters = ['status', 'created_at'];
#[Cast('integer')]
#[DefaultValue(1)]
protected function status(Payload $payload)
{
return $this->builder->where('status', $payload->value);
}
#[SkipIf('auth()->guest()')]
#[Between(min: '2020-01-01', max: 'now')]
protected function created_at(Payload $payload)
{
return $this->builder->whereDate('created_at', $payload->value);
}
}
Available annotations: #[Authorize] #[SkipIf] #[Cast] #[Sanitize] #[Trim] #[DefaultValue] #[MapValue] #[Explode] #[Required] #[In] #[Between] #[Regex] #[Scope]
2. Ruleset Engine
Ideal for REST APIs where the frontend sends structured filter parameters with explicit operators.
GET /posts?filter[status]=published
GET /posts?filter[title][like]=%laravel%
GET /posts?filter[views][gte]=100
GET /posts?filter[id][in][]=1&filter[id][in][]=2
GET /posts?filter[price][between][]=10&filter[price][between][]=50
Define your filter class with allowed fields and operators:
class PostFilter extends Filterable
{
protected $allowedFields = ['status', 'title', 'views', 'created_at'];
protected $allowedOperators = ['eq', 'like', 'gte', 'lte', 'in', 'between'];
}
No methods needed — the engine handles the query building automatically.
Supported operators: eq neq gt gte lt lte like nlike in nin between null notnull
3. Expression Engine
Everything the Ruleset engine does, plus the ability to filter through nested Eloquent relationships using dot notation.
GET /posts?filter[author.profile.name][like]=ahmed
GET /posts?filter[status]=active&filter[category.name]=laravel
use Kettasoft\Filterable\Facades\Filterable;
Filterable::create()
->useEngine('expression')
->allowedFields(['status', 'title'])
->allowRelations([
'author.profile' => ['name', 'bio']
])
->filter(Post::query());
The engine automatically resolves the relation path and applies whereHas queries — no manual join logic needed.
4. Tree Engine
For advanced use cases where the frontend needs to build complex AND/OR logic — think filter builders in admin panels or reporting tools.
The request sends a nested JSON tree:
{
"filter": {
"and": [
{ "field": "status", "operator": "eq", "value": "active" },
{
"or": [
{ "field": "views", "operator": "gte", "value": 1000 },
{ "field": "featured", "operator": "eq", "value": true }
]
}
]
}
}
The engine recursively translates this into Eloquent where / orWhere groups. It also supports depth limiting to protect against overly complex queries:
// config/filterable.php
'tree' => [
'depth_limit' => 3,
'normalize_keys' => true,
]
Validation & Sanitization
Input handling lives inside the filter class, not scattered across controllers or form requests.
Validation uses Laravel's native rules format:
class PostFilter extends Filterable
{
protected $rules = [
'status' => ['required', 'string', 'in:active,pending,archived'],
'title' => ['sometimes', 'string', 'max:100'],
];
}
If validation fails, a ValidationException is thrown automatically.
Sanitization runs before validation, via dedicated sanitizer classes:
class PostFilter extends Filterable
{
protected $sanitizers = [
TrimSanitizer::class, // global — runs on all fields
'title' => [
StripTagsSanitizer::class,
CapitalizeSanitizer::class,
],
];
}
A sanitizer implements the Sanitizable interface:
class TrimSanitizer implements Sanitizable
{
public function sanitize(mixed $value): mixed
{
return is_string($value) ? trim($value) : $value;
}
}
Execution order: sanitize → validate → filter.
Authorization
Protect a filter class based on roles or permissions by defining an authorize() method:
class AdminFilter extends Filterable
{
public function authorize(): bool
{
return auth()->user()?->isAdmin() ?? false;
}
}
If authorize() returns false, a FilterAuthorizationException is thrown before any filtering runs. Per-method authorization is also available via the #[Authorize] annotation in the Invokable engine.
Sorting
Define which fields are sortable via $sortable:
class PostFilter extends Filterable
{
protected $sortable = ['created_at', 'views', 'title'];
}
GET /posts?sort=-created_at # descending
GET /posts?sort=views # ascending
Caching
Filterable has a full caching system built into the filter pipeline.
// Cache for 1 hour
Post::filter()->cache(3600)->get();
// User-scoped — each user gets their own cache entry
Post::filter()->cache(1800)->scopeByUser()->get();
// Tagged cache — easy to invalidate by group
Post::filter()->cache(3600)->cacheTags(['posts', 'content'])->get();
Post::flushCacheByTagsStatic(['posts']);
// Conditional caching
Post::filter()->cacheWhen(!auth()->user()->isAdmin(), 3600)->get();
// Reusable cache profiles defined in config
Report::filter()->cacheProfile('heavy_reports')->get();
Enable auto-invalidation in config/filterable.php and cache is cleared automatically when a model is created, updated, or deleted:
'auto_invalidate' => [
'enabled' => true,
'models' => [
\App\Models\Post::class => ['posts', 'content'],
],
],
CLI Reference
# Create a new filter class
php artisan filterable:make-filter PostFilter --filters=title,status
# Discover and register all filter classes in your app
php artisan filterable:discover
# List all registered filters
php artisan filterable:list
# Test a filter with a sample payload
php artisan filterable:test PostFilter --payload='{"status":"active"}'
# Inspect a filter class (engine, fields, rules, sanitizers...)
php artisan filterable:inspect PostFilter
Wrapping Up
Filterable gives you a structured, testable way to handle Eloquent filtering without cluttering your controllers. Whether you need simple key-value filters or complex nested AND/OR trees, there's an engine for it — and they all share the same validation, sanitization, authorization, caching, and sorting infrastructure.
Links:
- 📦 GitHub
- 📚 Documentation
- 🚀 Packagist
Built by Kettasoft. MIT Licensed.
Top comments (0)