liqrgv/query-filter-laravel
A Laravel package for building expressive, reusable query filters. Apply request-based filtering, sorting, and search to Eloquent queries with clean, composable filter classes—ideal for APIs and admin lists.
Install and confirm the package's actual surface before building on it — it's a small (10-star) package, so pin the version and read src/ once.
composer require liqrgv/query-filter-laravel
composer.json first for the supported PHP/Laravel range. Small packages often lag a major Laravel release; confirm it matches your app before committing.php artisan vendor:publish interactive list). Skip if the package is config-free.QueryFilter/RequestFilter class you extend per resource.?name=jo&status=active onto where clauses. Never let it accept arbitrary column names.First use case — a filtered index endpoint:
// routes/api.php
Route::get('/users', [UserController::class, 'index']);
// app/Http/Controllers/UserController.php
public function index(Request $request)
{
return User::query()
->filter($request) // or ->applyRequest($request) — verify exact name
->paginate(25);
}
Start with 2–3 scalar filters on one model, assert the SQL with ->toSql() in a test, then expand. Don't wire it into every controller before you've verified behavior on one.
Per-resource filter classes (preferred). Define one class per model/resource that declares allowed params and how each maps to the builder. This keeps controllers thin, makes filters unit-testable, and gives you a single place to audit for injection and N+1 issues.
class UserFilter
{
protected array $allowed = ['name', 'status', 'created_after', 'sort'];
public function name($q, $v) { $q->where('name', 'like', "%{$v}%"); }
public function status($q, $v) { $q->where('status', $v); }
public function createdAfter($q, $v) { $q->where('created_at', '>=', $v); }
}
Layer your own wrapper. Even if the package exposes a trait, create a thin app-level abstraction (App\Support\FilterableQuery or a base filter class) so swapping packages later is a one-file change. This is the single most valuable habit with a low-adoption dependency.
Validate before filtering. Put query params through a FormRequest (or $request->validate([...])) so date formats, enums, and page sizes are sane before they reach the builder. Validation also documents the API in one place.
Combine with eager loading deliberately. Filters that touch relations usually compile to whereHas/whereRelation. Apply filters before with() so relation existence checks and eager loads don't fight each other, and consider whereRelation(...)->with(...) only when you actually need the relation data.
Sorting and pagination as first-class params. Treat ?sort=-created_at and ?per_page= as part of the same filter contract. Map sort keys through an allowlist (['name', 'created_at']) rather than passing column names through.
Compose filters across endpoints. For admin vs. public listings, either subclass the resource filter (disable fields) or pass an allowed-fields array to the constructor. Avoid branching inside the filter with auth() checks — pass explicit context in.
Test the SQL, not just the response. A filter test should assert the generated query for a given input:
$sql = User::query()->filter($request)->toSql();
$this->assertStringContainsString('"status" = ?', $sql);
where/orderBy without an allowlist, you have a SQL-injection-adjacent surface and unpredictable behavior on typos. Always restrict to declared filters.null. ?name= frequently arrives as "", not missing. Decide per filter whether empty means "ignore" or "search for empty", and encode that in the filter method. Silent LIKE '%%' full-table scans are a common production surprise.?status[gt]=active or ?sort=name;drop. If the package accepts arrays, validate the shape (string|in:..., array with max: keys) and reject operator-style input.whereHas for a list is fine on count but can duplicate or multiply rows if you also join. Prefer whereRelation / subqueries and confirm with DB::listen or Telescope under a seeded dataset.?created_after=2024-13-01 should fail validation, not silently become 1970. Normalize to your app timezone and cast explicitly in the filter.count() (e.g., groupBy added by a filter). Verify paginate()->total() on every filter you ship.$query->toSql() and getBindings() in a local middleware, or enable DB::listen for the endpoint. When a filter "does nothing", it's almost always a name/allowlist mismatch rather than a bug in the package.OR grouping), implement it as a subclass in your app rather than editing vendor/."^x.y" and read the diff before bumping; keep your app-level wrapper so a swap is cheap.How can I help you explore Laravel packages today?