midsonlajeanty/laravel-filters
A Laravel package for adding reusable, request-driven filters to Eloquent models. Define filter classes and let incoming query parameters refine your database queries cleanly, keeping controllers and models lean.
Install the package via Composer and, on first install, publish its config if you plan to customise the default namespace or filter path:
composer require midsonlajeanty/laravel-filters
php artisan vendor:publish --tag=laravel-filters
Two things to read first:
Filterable trait plus a $filters property or filter() scope).Minimal first use case — a search endpoint on User:
// app/Filters/UserFilter.php
class UserFilter extends Filter
{
public function name($value)
{
return $this->builder->where('name', 'like', "%{$value}%");
}
public function status($value)
{
return $this->builder->where('status', $value);
}
}
// User model
use Filterable;
protected $filters = UserFilter::class;
// Controller
User::filter($request->only(['name', 'status']))->paginate();
That is the whole loop: define methods named after query params, wire the trait to the model, pass a whitelisted array from the request.
One filter class per model, methods named after query params. This is the dominant pattern and the one to standardise on early. The filter class becomes the single place where you audit "what can a client filter on", which is exactly where you want that knowledge to live.
Always pass a whitelist, not $request->all(). Whether you use $request->only([...]), a form request's validated(), or the package's own allowed-filters config, the boundary should be explicit. A filter class only defines what a filter does, not which filters are permitted on a given endpoint — that distinction matters when the same model is exposed on an admin and a public route.
Chain with existing query logic, don't replace it. Filters compose naturally with scopes, with(), and sorting before the terminal call:
User::query()
->with('team')
->active() // your own scope
->filter($request->validated())
->sortBy($request->query('sort'))
->paginate(25);
Relationship filters. For a filter on a related model, prefer whereHas inside the filter method rather than a join, so you keep the model's hydration and any global scopes intact:
public function team($value)
{
return $this->builder->whereHas('team', fn ($q) => $q->where('slug', $value));
}
Default / fallback behaviour. If the package exposes a default method or a catch-all hook, use it for sensible defaults (e.g. status = 'published' for public listings). Otherwise, keep defaults in the controller so the filter class stays free of presentation decisions.
Range and date filters. Split into paired methods (price_from / price_to) rather than one magic value, so validation and query-string documentation stay obvious.
Testing. Filters are cheap to test with assertDatabaseHas-free query assertions — build the filter against a request array and assert on toSql() bindings or on the returned collection. This is the fastest place to catch a renamed column before it hits production.
Semver discipline matters here. The package is pre-1.0, so minor bumps (0.1 → 0.2) can legitimately contain breaking changes. The v0.1.2 release is a patch bump with no changelog detail beyond the compare link — treat it as a bug-fix release and safe to adopt, but do not infer compatibility guarantees from it. Pin the constraint to ^0.1.2 and read the compare diff before taking any 0.2.x minor.
Method names are the public API. Renaming a filter method silently changes the accepted query string. Either keep query params snake_case and add explicit aliases, or accept camelCase params — but be consistent, because a mismatch fails quietly (unknown filters are ignored) rather than erroring.
Silent ignores hide typos. A misspelled param (statuss=active) usually falls through as "no filter applied", which looks like a data problem in the UI. During development, log or assert on any request keys that were passed but not consumed by the filter class — it turns a class of subtle bugs into a loud one.
Whitelisting is a security control, not a convenience. Every filter method is raw query-builder access. Passing unfiltered request data into filter() and having a permissive catch-all method is a straight path to unintended LIKE scans or column enumeration. Never accept a column name directly from the request.
Ordering of filters changes results when they overlap. Two filters touching the same column (where then orWhere) compose in method-definition order, not request order. If behaviour depends on ordering, combine them into a single method.
Watch for N+1 on relation filters. whereHas is fine, but loading the relation afterwards needs with() — filtering and eager-loading are separate concerns and the package will not add the latter for you.
Debugging tip. Add a temporary dd($this->builder->toSql(), $this->builder->getBindings()) inside a single filter method to confirm it is firing at all. Nine times out of ten, "the filter isn't working" is either a name mismatch or the trait/$filters wiring missing on that model.
Extension points to reach for first (before forking): the base filter class for shared helpers (range parsing, boolean coercion), a Sortable-style companion trait for ?sort=, and a form request per endpoint for validation. Pushing validation into the filter class couples it to HTTP and makes it awkward to reuse from console commands or queued jobs — keep filter classes request-agnostic and pass plain arrays.
How can I help you explore Laravel packages today?