daveismyname/laravel-filters
A lightweight Laravel package for adding reusable, request-driven filters to Eloquent queries. Define filters once and apply them from query strings to keep controllers clean and search/filter logic organized.
This release (v2.0.3) is a maintenance drop: dependency bumps plus Laravel 13 support. There are no breaking changes, no new filter API, and no deprecations — upgrading from v2.0.2 is a composer update dcblogdev/laravel-filters and a test run.
Minimal setup:
composer require dcblogdev/laravel-filters
The service provider is auto-discovered. If the package ships a publishable config, publish it and read it before writing any filter classes:
php artisan vendor:publish --tag=filters-config
Where to look first, in order:
vendor/dcblogdev/laravel-filters/src/ — the base filter class and the model trait/scope are the whole public surface; read them rather than the README, they are small.composer.json constraints — with v2.0.3 the package now resolves on Laravel 13, so confirm your app's laravel/framework constraint allows the combination you actually installed (the bump also pulled symfony/http-foundation to 6.4.29).First use case — a paginated listing with a status filter and a text search:
// app/Filters/PostFilters.php
namespace App\Filters;
use Dcblogdev\Filters\Filters;
class PostFilters extends Filters
{
protected array $filters = ['status', 'search'];
public function status($value)
{
return $this->builder->where('status', $value);
}
public function search($value)
{
return $this->builder->where('title', 'like', "%{$value}%");
}
}
// Controller
public function index(Request $request)
{
return Post::filter(new PostFilters($request))
->with('author')
->paginate(25)
->withQueryString();
}
One filter class per model, one whitelist per class. The $filters array is the contract: only listed keys are ever applied, so query-string input can never reach an unlisted column. Treat it as a security boundary and keep it in the class, not in the controller.
Always return the builder. Filter methods must return $this->builder (or the result of a where/whereHas call on it). Calling ->get() or ->first() inside a filter method breaks chaining, pagination, and eager loading downstream — it is the single most common way these classes rot.
Scope authorization before filtering. The filter layer does no policy checks. For anything user-facing, start from an authorized builder and hand that to the filter, or apply ->where('user_id', auth()->id()) first:
$query = Post::where('user_id', auth()->id());
return $query->filter(new PostFilters($request))->paginate();
Ranges, dates, and relations belong in closures or whereHas. A conventional pattern for a date_from/date_to pair keeps the parsing in the filter class so controllers stay thin:
public function dateFrom($value)
{
return $this->builder->whereDate('created_at', '>=', Carbon::parse($value));
}
public function tag($value)
{
return $this->builder->whereHas('tags', fn ($q) => $q->whereIn('slug', (array) $value));
}
Sorting is a separate concern — whitelist it explicitly. A ?sort=-created_at parameter is tempting to implement as a filter, but it needs its own column allowlist; never pass raw input to orderBy:
public function sort($value)
{
$column = ltrim($value, '-');
abort_unless(in_array($column, ['created_at', 'title', 'status'], true), 422);
return $this->builder->orderBy($column, str_starts_with($value, '-') ? 'desc' : 'asc');
}
Keep eager loading out of filters. Loads (with, withCount) belong at the call site so each endpoint can differ; filters that force with() cause N+1 fixes to be duplicated across classes.
Testing. Filters are pure builder transformations, so a feature test through the route covers both parsing and SQL. Assert on the JSON payload rather than SQL strings, and add one test per whitelist entry plus one for an unlisted parameter being ignored:
$this->getJson('/posts?status=draft&bogus=1')
->assertOk()
->assertJsonPath('data.0.status', 'draft');
API resources and FormRequests compose cleanly. Validate what you support in a FormRequest (so bad input is a 422 rather than a silent no-op), then pass the same request into the filter. The two layers are complementary: validation rejects, filters select.
?staus=active returns the unfiltered listing with a 200. If that matters, validate allowed keys in a FormRequest using Rule::in(array_keys(...)).filter_var($value, FILTER_VALIDATE_BOOLEAN) for booleans, Carbon::parse() for dates, (int) for IDs. where('published', 'false') on a boolean column is a classic silent-miss bug.?status= will run where('status', ''). Guard with if (blank($value)) { return $this->builder; } at the top of any filter that can be sent empty by a form.?tag[]=a&tag[]=b gives you an array; ?tag=a gives you a string. Use (array) $value plus whereIn, and be aware of URL length limits on large selections.withQueryString(). This is a Laravel behaviour, not a package one, but it is the most-reported "the filter stops working on page 2" issue.tap. Chained ->filter() calls each receive the builder; returning $this from a method instead of $this->builder will produce confusing "method not found" errors on the model.->dd() / ->dump() on the builder at the call site, or a DB::listen() in a service provider, shows the final SQL and bindings faster than stepping through the filter loop. To confirm a filter method was even invoked, drop a logger()->debug(__METHOD__) at the top temporarily.BaseFilters and extending $filters in each model's class — cheaper than repeating the same where in every filter method. Global defaults that apply to every query belong in a model global scope, not in a filter class.dcblogdev/laravel-filters:^2.0 rather than a patch version so future dependency bumps land without a manual edit. If your app still requires symfony/http-foundation 6.4.18 exactly, the bump to 6.4.29 in this release may force a transitive resolution — check composer why symfony/http-foundation before upgrading.composer.lock before debugging the package.How can I help you explore Laravel packages today?