kyslik/laravel-filterable
Laravel Filterable lets you filter Eloquent models directly from query string parameters. Define allowed filters, apply them with a simple trait, and support relation filters, custom filter classes, and safe, composable queries.
Install and pin first. The package's last release is from early 2019 and its support matrix is Laravel 5/6/7. Before anything else, run composer require kyslik/laravel-filterable and confirm the resolved version actually installs against your framework — on Laravel 8+ expect dependency conflicts. If it installs, read vendor/kyslik/laravel-filterable/src before writing code; the surface area is small enough to absorb in ten minutes, and that is faster than trusting a stale README.
Where to look first, in order:
Kyslik\LaravelFilterable\Filterable) — this is the whole package. Read its scope resolution and how it reads the query string.Wire it into one model. Add the trait and declare the whitelist of filterable attributes, ideally adjacent to $fillable so the two lists are reviewed together:
use Kyslik\LaravelFilterable\Filterable;
class Product extends Model
{
use Filterable;
protected $filterable = ['name', 'status', 'created_at'];
}
First use case — an index endpoint that currently ignores the query string:
// GET /products?name=desk&status=active
public function index()
{
return Product::filter()->paginate(25);
}
That single call replaces the when($request->filled(...)) ladder most Laravel codebases grow in a controller. Verify it in a feature test with a raw query string, not by clicking around: $this->get('/products?name=desk')->assertSee('desk'). If the trait's scope name or signature differs from what you expect, the test failing is your fastest signal.
Whitelist as the contract, aliases as the API. Never let the request define which columns are touchable. Keep $filterable explicit per model, and where the package supports key mapping, alias public query params (?q=) onto internal columns (name) so you can rename a column without breaking consumers. Treat $filterable as public API and review it in pull requests the same way you review routes.
Escalate from declarative to imperative deliberately. Simple equality filters belong in the array. Ranges, LIKE, relation filters and date windows do not fit a flat whitelist — express those as named filter methods on the model (or a small filter class if the package supports one) so each one is a readable unit:
// ?price_min=100&price_max=500
public function filterPriceMin($query, $value) { /* where >= */ }
public function filterPriceMax($query, $value) { /* where <= */ }
The win here is that business rules stay next to the model instead of accumulating as controller ifs. The risk is that a custom method bypasses validation — validate its input explicitly.
Validate allowed keys at the edge. Wrap the endpoint in a FormRequest that validates the query string, including rejecting unknown keys in strict mode. This gives you two benefits: 400s instead of silently-different result sets, and a single place to document the filter surface for API consumers.
Compose, don't replace. The filter scope should return a query builder you then chain on — with() for eager loads, orderBy for sorting, paginate for paging. Keep the pipeline explicit:
Product::query()
->with('category')
->filter()
->when($request->sort, fn ($q, $s) => $q->orderBy($allowedSorts[$s]))
->paginate(25)
->withQueryString();
Centralise with a base model or trait. Most teams end up with an abstract FilterableModel or a shared concern that holds the trait plus pagination defaults and a $defaultSort. That keeps five index endpoints consistent instead of five dialects of filtering.
Test it as a matrix. One feature test per filterable key, one for an unknown key, one for an empty value, one asserting filters survive pagination links. This is cheap and catches almost every regression the package can produce.
Maintenance is the headline risk. Last release 2019-02-28. On modern Laravel you will likely hit composer conflicts, deprecations or PHP 8 incompatibilities. Budget an afternoon to evaluate spatie/laravel-query-builder (actively maintained, explicit allowedFilters, first-class sorting/includes) before adopting this on a new project. Choose kyslik only for an existing Laravel 5/6/7 app where the cost of migration exceeds the cost of the trait — it is small, readable and MIT, so you can fork and patch.
Silent no-ops are the main debugging trap. A typo'd query param or a key missing from $filterable typically yields unfiltered results rather than an error. When a filter "doesn't work," check in this order: is the key in $filterable, does the request actually contain it, is it non-empty, is a global scope narrowing the query. Add a temporary dd($query->toSql(), $query->getBindings()) rather than guessing.
Empty strings, "0" and arrays. ?status= should usually mean "no filter," not where status = ''. Filter blank values out before they reach the scope. Array syntax (?status[]=a&status[]=b) needs explicit handling and validation; do not assume the package turns it into whereIn.
LIKE and wildcards. If a filter maps to LIKE, user input containing % or _ widens the match. Escape it or constrain the value with a regex rule. Same class of problem with any sort parameter derived from raw input — use an allowlist map, never orderBy($request->input('sort')).
Pagination drops your filters. Laravel's paginator needs withQueryString() (or ->appends($request->query())) or page 2 silently resets the result set. This is the single most common bug report against filtered index pages.
Relations need whereHas. A column on a joined table filtered via a plain where will either error or produce duplicate rows. Implement relation filters as custom methods using whereHas and pair them with with() to avoid N+1.
Soft deletes and global scopes compose silently. A filter that looks broken is often a global scope doing its job. Assert on the generated SQL in one test so scope interactions are visible.
Extension points worth knowing: the model-level filter method resolution (for custom filters) and the published config. If the config is not published, copy defaults into config/filterable.php before changing behaviour, and commit it — an unpinned default changing under you is a bad way to find out about a config key.
Lock the version. With an unmaintained dependency, use an exact constraint and a committed composer.lock. If you patch the package, fork it and point the constraint at your fork rather than editing vendor/, so the fix survives composer install on CI.
How can I help you explore Laravel packages today?