emmadonjo/laravel-filter
A lightweight Laravel package for adding clean, reusable filters to Eloquent queries. Define composable filter classes once, apply them from request parameters or manually, and keep controllers slim, query logic organized, and filtering behavior easy to test and extend.
Install the package via Composer and treat v1.0.0 as the first stable API surface:
composer require emmadonjo/laravel-filter:^1.0
Because 1.0.0 is the first tagged stable release (it follows v0.0.3), read the source and README together before wiring it in — there is no long tail of blog posts or Stack Overflow answers for a package at this stage, so vendor/emmadonjo/laravel-filter/src is your real documentation. Look first at:
php artisan vendor:publish --tag=...) and whether it registers any Artisan generators.use on your models.First use case: an index endpoint backed by a filterable model.
// app/Filters/PostFilter.php (shape depends on the package's base class)
class PostFilter extends Filter
{
public function status(string $value): void
{
$this->query->where('status', $value);
}
public function author(string $value): void
{
$this->query->whereHas('author', fn ($q) => $q->where('name', 'like', "%{$value}%"));
}
}
// Controller
public function index(Request $request)
{
return Post::filter($request->query())->paginate();
}
Verify the call signature (filter() on the model via trait, vs. Filter::apply($query, $input)) and whether the package auto-resolves filter classes by model convention or requires an explicit binding. Do that against a throwaway route with ->toSql() before you build anything on top of it.
One filter class per model, one method per parameter. Keep controllers to a single line and let the filter class own the query mutations. This is the pattern the package is built around and it keeps where clauses out of HTTP-land.
Allowlist parameters explicitly. Whatever the package's default behaviour, add your own guard so a user-supplied ?sort=, ?include= or ?select= cannot reach a column name directly:
protected array $sortable = ['created_at', 'title', 'status'];
public function sort(string $value): void
{
$column = ltrim($value, '-');
if (! in_array($column, $this->sortable, true)) {
return; // or throw ValidationException
}
$this->query->orderBy($column, str_starts_with($value, '-') ? 'desc' : 'asc');
}
Validate at the edge, filter at the query. Pair each filter class with a Form Request that validates types and enums (Rule::in(['draft','published']), integer|min:1). The filter class should be able to assume the value is well-formed. Never let the filter class be the only validation layer — it will get reused from a console command or job eventually.
Compose with scopes, not instead of them. Business rules that apply outside HTTP (tenant scoping, soft-delete visibility, published-only for anonymous users) belong in Eloquent scopes. Filters are for user-driven narrowing. Mixing the two makes it impossible to tell why a row is missing.
Chain with() and withCount() before pagination. Filtering on relationships via whereHas is convenient but easy to turn into N+1 once you render the results. Resolve eager loads in the controller or a resource class, not the filter.
$posts = Post::query()
->with(['author:id,name'])
->filter($request->validated())
->paginate(25)
->withQueryString();
Reusable filters via traits. Date ranges, search, and sort recur across nearly every model. Extract them into small traits (Searchable, Sortable, DateRangeFilterable) mixed into each filter class rather than copy-pasting method bodies.
Conditional filters for authorization. Where the package supports when()-style conditional application (or where you can wrap the input array), gate sensitive parameters behind Gate::allows before they reach the filter, so ?status=any isn't honoured for a user who can only see published records.
v1.0.0 signals API stability. Pin to ^1.0 and treat any future breaking change as a major bump. If you were on 0.0.x, the jump to 1.0.0 is a semver commitment, not a feature drop — the release notes mention only static-analysis fixes, so expect zero behavioural changes from 0.0.3. Diff the two tags anyway if you're upgrading a live app.
Static-analysis fixes mean Larastan/PHPStan friendliness. If you enforce PHPStan in CI, this release is likely to remove the ignoreErrors entries you may have needed for the package's generics on Builder. Re-run analysis after upgrading before assuming anything regressed.
Empty strings are not missing parameters. ?status= will arrive as '' in $request->query(). Guard filter methods with filled($value) rather than relying on the framework to drop it, otherwise you'll filter on where('status', '') and get an empty result set.
Boolean params are strings. ?featured=true is the string 'true', and 'false' is truthy in PHP. Cast explicitly with filter_var($value, FILTER_VALIDATE_BOOLEAN) inside the filter method.
Sorting by a relationship column requires a join. orderBy('author.name') will fail. Either join and qualify the column (posts.id vs users.id ambiguity — always prefix), or use a subquery select. Do not let a user-supplied sort order reach a joined column name.
Pagination loses filters without withQueryString(). Page 2 links drop the query string otherwise, which reads as "the filter stopped working" in bug reports.
Cache keys must include the filter input. If you cache index responses, md5($request->fullUrl()) or a normalised serialisation of the validated filter array — not just the route name — or you'll serve one user's filtered results to another.
per_page is a filter in disguise. Validate and cap it (in:10,25,50,100) or a ?per_page=100000 request will happily serialise your table.
Debugging: wrap the call in a local helper that dumps ->toSql() and ->getBindings() with the input array, and enable DB::listen() in a tinker session. When a filter "does nothing", the cause is almost always the request key not matching the method name — check the package's naming convention and any snake_case/camelCase mapping it applies.
Extension points to watch for: a publishable config (default filters applied to every query, parameter naming, delimiter for array values like status[] vs status=a,b), and a custom filter-path binding in a service provider. Confirm both before you hard-code paths across a large codebase — moving filter classes later is a mechanical but wide-reaching refactor.
Thin ecosystem risk. At 1.0.0 with a single maintainer, keep your filter classes small and dependency-light so that swapping the package for hand-rolled query scopes is a contained change rather than a rewrite. Don't let the package's base class leak into your domain layer (jobs, commands, exports) — accept plain arrays there and construct the query yourself.
How can I help you explore Laravel packages today?