tomodo531/filterable-filters
A Laravel package for reusable, request-driven Eloquent filters. tomodo531/filterable-filters helps you define filter classes and apply them consistently, keeping controllers and models clean while making complex filtering logic easier to organize, compose, and maintain.
This is a two-star, single-release package from December 2022 with no dependents. That context should shape your first hour: read the source before you write any application code. The entire package is almost certainly a handful of files — a service provider, a Filterable trait, and a base Filter/QueryFilter class. Open vendor/tomodo531/filterable-filters/src and skim all of it before continuing. You need to know three things: the trait's name and namespace, the base class contract (which method you override to build a query), and whether a config file is published.
Install and inspect:
composer require tomodo531/filterable-filters
composer show tomodo531/filterable-filters # confirm version + Laravel constraint
php artisan vendor:publish --provider="Tomodo531\FilterableFilters\FilterableFiltersServiceProvider"
Check composer.json inside the package for illuminate/support and php constraints. If it pins ^8.0|^9.0, you are fine on Laravel 9/10 with --ignore-platform-reqs at worst, but a Laravel 11/12 app will need a fork or a replace entry.
First use case: a filtered index endpoint. Take your noisiest listing route — GET /api/orders?status=paid&customer=acme&created_after=2024-01-01 — and move its ad-hoc ->when() chain into a single dedicated filter class. That is the one place where the package earns its keep: it turns a growing pile of conditional when() calls in a controller into one testable class.
If vendor/tomodo531/filterable-filters turns out to be an empty shell or a stub, stop and use spatie/laravel-query-builder instead. With 0 dependents and one release, the risk of it being an abandoned experiment is real, and the migration cost later is higher than the switch cost now.
One filter class per resource, not per endpoint. Put them in app/Filters and let a single OrderFilter serve the API index, the admin table, and the CSV export. The package's trait almost certainly resolves filter classes by convention or by an overridable property; either way, keep the mapping explicit so a new query string key cannot silently reach the database.
Whitelist by construction. The core safety property of any filterable package is that unknown query params are ignored. Verify that by sending ?password=1&id=5 at a model whose filter class does not define those keys, and watch ->toSql(). If the package passes params straight through, wrap it: validate with a Form Request first, then hand only the validated array to the filter.
Validation belongs in a Form Request, not the filter. Filters should receive already-typed data. Do the status => in:paid,pending and created_after => date work in the request class so the filter stays a pure query builder you can unit-test without HTTP.
// Controller — thin
public function index(OrderIndexRequest $request, OrderFilter $filter)
{
return OrderResource::collection(
$filter->apply(Order::query())->paginate(25)
);
}
Compose with sorting and pagination deliberately. Filtering, sorting, and pagination are three separate concerns that all mutate the same builder. Pick an evaluation order and keep it: filter → sort → eager-load → paginate. If sorting is not in this package, apply it after filtering so the filtered set is what gets ordered, and use a column allowlist for the sort key exactly as you did for filters.
Relationship filters are where the value is. ?customer=acme should become whereHas('customer', fn ($q) => $q->where('name', 'acme')), not a join. Keep relationship filters in the same class as column filters so the with() calls that prevent N+1 live next to the whereHas() calls that created the need for them.
Test the filter class directly. Instantiate it with an array, apply it to a Model::query(), assert on ->toSql() and bindings for shape, then assert on actual results for correctness. That gives you fast regression coverage without booting HTTP, which matters a lot for a package you may have to fork.
?page=0, ?active=0, and ?q= arrive as strings. A naive if ($value) skips "0" entirely, so boolean and numeric filters break silently. Use filled() / !== null && !== '' checks in your filter methods.?status[]=paid&status[]=pending gives you an array. Cast and validate it in the Form Request, and make the filter explicitly handle is_array($value) — mixing scalar and array handling in one whereIn is a common source of Array to string conversion errors.LIKE filters kill indexes. A free-text ?q= mapped to where('name', 'like', "%$v%") will not use an index and will scan. For anything non-trivial, move to a full-text index, ->whereFullText(), or Scout — and keep that decision out of the shared filter class so listing pages stay fast.?sort=, or a filter that maps a key to $request->input($key) as a column), you have SQL injection or at minimum schema disclosure. Never let a request value become a column identifier. Allowlist it.app/Support/FilterableFilters or a private package if you depend on it, and pin the version in composer.json ("tomodo531/filterable-filters": "1.0.*", never *). A composer update should not be able to change your query layer.php artisan config:cache will freeze it. If you edit the config and see old behavior, run php artisan config:clear before debugging anything else.->toSql() shows placeholders; use ->ddRawSql() (Laravel 9+), or DB::listen(fn ($q) => logger($q->sql)) to see every query the filters produced. When results are wrong but the SQL looks right, the bug is almost always in the bindings order or in a whereHas that got nested one level too deep.app/Filters/Filter.php) and extend that everywhere. You then have a single place to add cross-cutting behavior — tenant scoping, soft-delete handling, default with() — without touching the vendor directory, which survives the fork you will probably need.Tenant::query()/a global scope) into the filter rather than letting the filter start from Model::query(); otherwise a whereHas filter can leak rows across tenants.apply() last if you are unsure.How can I help you explore Laravel packages today?