mykeels/laravel-filters
A lightweight Laravel package for declarative, request-driven Eloquent filtering. Define which filters a model allows and let query-string parameters like ?status=active narrow results safely, keeping controllers clean and queries reusable, with support for custom filter logic.
Install and confirm the shape of the API first. The package is small (16 stars), so the README is the source of truth — read it before writing any filter classes, and check the src/ directory to see the actual base class/interface you need to extend.
composer require mykeels/laravel-filters
Then verify:
php artisan package:discover and confirm it appears in bootstrap/cache/packages.php.php artisan vendor:publish --provider="Mykeels\Filters\FilterServiceProvider". If nothing publishes, there is no config to worry about and you can skip this step entirely.Filter class (or interface) and note the exact method signature. Most packages in this family use either an apply($query, $value) method or a filter($value) method; getting this wrong is the #1 source of "why is nothing happening" confusion. Read it before writing your first class.Where to look first: the model/query-builder binding. Find whether filtering is exposed as a local scope (Model::filter($request->query())), a trait (,Filterable), or a middleware that mutates the request. That single decision drives everything else.
First use case — a list endpoint with a search box and two facets:
GET /api/users?name=ada&status=active&created_after=2024-01-01
Create one filter class per concern rather than one giant UserFilter. Start with the simplest possible filter to prove the wiring end-to-end:
namespace App\Filters;
use Mykeels\Filters\Filter; // confirm this namespace against the repo
class NameFilter extends Filter
{
public function apply($query, $value)
{
return $query->where('name', 'like', "%{$value}%");
}
}
Once ?name=ada returns a narrowed result set, you know the plumbing works and you can add filters confidently.
The composability value of this package comes from keeping each filter atomic. A StatusFilter should only know about status; a DateRangeFilter should only know about dates. Compose them in the mapping rather than branching inside a single class.
// Typically: a $filters map on the model, or passed to the scope
protected $filters = [
'name' => \App\Filters\NameFilter::class,
'status' => \App\Filters\StatusFilter::class,
'created_after' => \App\Filters\CreatedAfterFilter::class,
];
The map doubles as your whitelist. Anything not in the map is silently ignored, which is exactly what you want — a client sending ?password_hash=x or ?is_admin=1 must never reach the query builder.
Keep controllers thin but explicit about which query params are legal for which endpoint:
public function index(Request $request)
{
$users = User::query()
->filter($request->only(['name', 'status', 'created_after']))
->with('team')
->paginate(25)
->withQueryString();
return UserResource::collection($users);
}
$request->only([...]) at the call site makes the endpoint's contract readable in one glance and keeps the same model reusable across endpoints with different filter sets (public listing vs. admin console).
For ?status=active,pending or ?ids[]=1&ids[]=2, normalise inside the filter, not in the controller. This keeps the "how do I split this string" decision in one place:
class StatusFilter extends Filter
{
public function apply($query, $value)
{
$values = is_array($value) ? $value : explode(',', $value);
return $query->whereIn('status', array_filter($values));
}
}
Compose with relationship constraints where the filter semantically belongs to a relation. This is where a filter package earns its keep on real apps — ?team=engineering should become a whereHas, not a join you hand-write every time.
class TeamFilter extends Filter
{
public function apply($query, $value)
{
return $query->whereHas('team', fn ($q) => $q->where('slug', $value));
}
}
Because filters operate on the query builder, they are equally usable from console commands, jobs, and exports. That is the strongest argument for this package over a fat controller: User::query()->filter(['status' => 'active']) works in php artisan too, with no request coupling.
Test filters against the builder, not through HTTP — it is faster and pins down the contract:
it('filters by status', function () {
User::factory()->create(['status' => 'active']);
User::factory()->create(['status' => 'banned']);
$result = User::query()->filter(['status' => 'active'])->get();
expect($result)->toHaveCount(1)
->and($result->first()->status)->toBe('active');
});
Add one HTTP-level test per endpoint that asserts an unknown param is ignored — that is the regression you actually care about.
This package is small and lightly maintained — treat it as a pattern, not a platform. With 16 stars and a 0.49 score, expect thin docs, no changelog guarantees, and possible incompatibility with newer Laravel query-builder internals.
composer.json ("mykeels/laravel-filters": "^1.0" — check the real constraint) and commit composer.lock. If the package becomes unmaintained, you want a stable point to fork from.app/Filters, drop the base-class extends, and replace the scope with ~15 lines in a trait. Decide this before you have 40 filter classes.src/, not the README. Small packages frequently drift; a README example that no longer matches the base class produces silent no-ops (the filter runs, returns nothing, and the query passes through unchanged).Security / correctness:
$request->only([...])) rather than $request->all().?created_after=not-a-date should 422, not silently return everything or throw a 500 from the query builder. Pair filters with a FormRequest whose rules mirror the filter map.whereRaw. If you write a filter using raw SQL, bind parameters (whereRaw('LOWER(name) = ?', [strtolower($value)])) — never interpolate the query-string value.?name= arrives as '', not null. Decide per filter whether to skip (if (blank($value)) return $query;) or filter to nothing. Blanking the whole result set on an empty search box is a classic bug.Query behaviour:
whereHas is fine; calling ->with() inside a filter silently changes hydration for the whole endpoint and can surprise callers. Keep with() in the controller.like and case sensitivity differ between MySQL (case-insensitive by default collation) and PostgreSQL (case-sensitive). If you support multiple databases, use whereIlike-style handling or normalise explicitly — otherwise tests pass locally and fail in CI against Postgres.->withQueryString() on the paginator. Easy to miss, and users notice immediately.whereHas on large tables can turn a listing page into a slow one; run ->toSql() and check the indexes you actually need (usually on the foreign key and the filtered column).Debugging:
dd(User::query()->filter($filters)->toSql()) and ->getBindings(). If the SQL looks right but rows are wrong, the issue is in a filter's apply(), not the package.composer dump-autoload after adding files).php artisan config:cache during deploys — a stale cache silently ignores new filter registrations.Extension points worth knowing:
if (blank($value)) return $query;, add your own App\Filters\Filter that extends the package's base and handles the no-op case centrally. Everything else extends yours, and migrating off the package later is a one-file change.How can I help you explore Laravel packages today?