samushi/queryfilter
Samushi QueryFilter is a Laravel package that adds clean, reusable query filtering to your Eloquent models. Define filters once and let requests drive dynamic, composable queries without bloating controllers or scattering conditions throughout your codebase.
composer require samushi/queryfilter. Before anything else, check the package's composer.json for the illuminate/* constraint — with a low-star package this is the most common source of breakage on Laravel 10/11. If auto-discovery is supported the service provider registers itself; otherwise add it to config/app.php.php artisan vendor:publish --provider="Samushi\QueryFilter\QueryFilterServiceProvider" --tag=config. If no config is published, there is no global config — everything is per-model, which is actually simpler.src/ (usually small — a trait plus one or two abstract classes), and any config/ file. With a 10-star package the source is the documentation; budget 20 minutes to read all of it rather than guessing at behavior.Filterable-style trait to your model, then apply it in a controller:// app/Models/Invoice.php
use Samushi\QueryFilter\Concerns\Filterable;
class Invoice extends Model
{
use Filterable;
}
// app/Http/Controllers/Api/InvoiceController.php
public function index(Request $request)
{
return Invoice::query()
->filter($request) // exact method name varies — check the trait
->paginate(25);
}
if ($request->filled('status')) branches. Pick that endpoint, move the filters into a dedicated class, and verify with ->toSql() that the generated query is what you expect before touching a second endpoint. One endpoint is enough to validate the package; don't refactor the whole API on day one.One filter class per resource, not per endpoint. The cleanest pattern with filter packages is a InvoiceFilter class holding the whole whitelist, reused by the admin list, the export job, and the report query. If the package ships a base filter class, extend it; if it only offers a trait, define your own allowedFilters() array on the model and keep it there.
Declarative whitelist, imperative escape hatch. Almost all of these packages resolve a request key to a column via a declared map. Keep the map declarative (status, customer_id, created_at) and drop to a closure/method only for the two or three filters that need real logic (date ranges, full-text, related-model search). Resist putting business rules in the map.
// Pseudo-shape — mirror whatever the package actually exposes
protected array $filters = [
'status' => 'exact',
'customer_id' => 'exact',
'q' => 'like:name,email', // note the column target
'created_from' => 'date:>=',
'created_to' => 'date:<=',
];
Validate before you filter. Treat the query string as untrusted input even though the package whitelists keys. A FormRequest (or an inline $request->validate()) for status in an enum, per_page between 1 and 100, and dates as date_format:Y-m-d gives you 400s instead of empty result sets that look like bugs. This is the single highest-value habit with this class of package.
Compose, don't nest. Keep filter() early in the chain so subsequent with(), withCount(), and orderBy() see a constrained query. Sorting packages fight with eager loads when the order of calls is reversed, and it produces subtly different results.
Sorting and pagination belong in the same layer. A filter package that only handles where clauses leaves you writing ?sort=-created_at&per_page=50 by hand in every controller. If the package supports it, use it; if not, put a sort() macro on a base query builder so the two features live together and share the whitelist.
Test the filter class, not the endpoint. Instantiate the filter with a fake request and assert on ->toSql() plus bindings, or hit an in-memory SQLite database with seeded rows. Filter bugs are almost always off-by-one date boundaries or a missing column in the whitelist — both show up instantly in a table-driven test and take forever to spot through HTTP.
Reuse across contexts. The same filter class should serve an API endpoint, an artisan command (--status=overdue), and a queued export. Build it so it accepts an array or a request object; that's what makes it worth the dependency instead of raw when() calls.
where($key, $value), ?sort=password or ?filter[users.password]=x is a data-leak vector. Verify in the source that unknown keys are skipped silently or throw — and prefer the throwing mode in development.?status= is not the same as no status param. Most implementations treat '' as a value and generate where status = '', which returns nothing and looks like a broken query. Add a global "strip empty params" step before filtering.LIKE and collation. Case sensitivity depends on the database collation, so a like filter behaves differently on MySQL vs Postgres vs SQLite. If the package doesn't wrap values in %...% for you, you'll need to — and on Postgres consider ilike.customer.name usually needs a whereHas, not a column reference. If the package maps it to a raw column you'll get an SQL error; if it maps to a join you'll get duplicate rows unless you add distinct() or groupBy. Check which one it does before shipping.created_to=2024-01-31 means midnight (excluding the rest of the day) or end-of-day. Encode it as >= start and < start of next day in one place.toSql() shows ?. Log with DB::listen() or use ->dd() on a query with bindings to catch a filter passing booleans as strings ('false' is truthy in some casts)."samushi/queryfilter": "^1.0" in composer.json plus a committed composer.lock is non-negotiable. Read the changelog before every minor bump.app/QueryFilters/ classes so that if it is abandoned you replace the base class in one place rather than editing 40 controllers. Concretely: your filters extend App\Support\BaseFilter, which extends the package class. The extra indirection costs nothing and buys you a fork-free migration.before()/after() style hooks (good for tenant scoping) and any config for default sort. Add tenant scoping there — ->filter($request) in a controller bypasses your global scope assumptions if the package builds a fresh query.Model::filter($request)->toSql() and ->getBindings() first, then ->dd() with bindings, then EXPLAIN if a filter column isn't indexed. Add indexes for every column you expose as an exact filter; a whitelist without indexes is a slow-query generator.How can I help you explore Laravel packages today?