isma/api-filters-bundle
A Laravel package for clean, query-string-driven API filtering. Turn request parameters into Eloquent queries with reusable filters, sorting, and pagination—keeping controllers thin and endpoints consistent. Simple to configure, easy to extend, and built for real-world REST APIs.
First, calibrate expectations: isma/api-filters-bundle is a Symfony bundle, not a Laravel package. It has no laravel/framework dependency, no auto-discovered service provider, and its strategies emit Doctrine QueryBuilder expressions, not Eloquent. If your day job is 100% Laravel, the realistic value here is architectural — it is a clean reference implementation of the strategy-pattern approach to filter resolution. The direct Laravel equivalents you would actually ship are spatie/laravel-query-builder (whitelist-based, ?filter[...] and ?sort= out of the box) or a small in-house FilterStrategy set built on Eloquent scopes. Read this package's source the way you'd read a design doc.
If you are on a Symfony service (or a mixed team), these are the minimal steps:
composer require isma/api-filters-bundle, then add the bundle to config/bundles.php. There is no Flex recipe — assume you wire it manually and run bin/console cache:clear afterwards. Pin a version: as of v1.3.0 the package is iterating (v1.2.1 → v1.3.0 shipped within one cycle), so ^1.3 is safer than *.src/DependencyInjection/ (this tells you the config tree and therefore the exact YAML keys), then the directory holding the strategy classes. The bundle's whole contract is: allowed filter definitions in → HTTP query params resolved → matching strategy invoked against a query builder. Ten minutes in DependencyInjection/Configuration.php will save you an hour of guessing key names, because a low-star package's README drifts from its code — and it just grew three new operator names that the docs may lag behind.Request $request and a QueryBuilder for Product, hands both to the bundle's resolver, and returns the paginated result:GET /api/products?filter[name][ilike]=widget&filter[price][gte]=100&filter[status][ieq]=active
That single endpoint is the entire value proposition: the front-end composes filter params freely, the back-end stays in control of which columns and operators are legal.
Where to look first, concretely: src/ for the strategy interface (your extension point), Resources/config/ or config/ for the service definitions (where strategies get tagged), and the tests — with 0 dependents and a single maintainer, the test suite is the most reliable spec you have. For v1.3.0 specifically, open the strategies for ilike, ieq, and inoteq first: they are the newest code, they are where the case-normalisation behaviour lives, and PR #11/#12 are recent enough that edge cases (NULLs, wildcards, non-ASCII) may not be covered.
Whitelist-per-resource, not global. Define a filter set per entity/endpoint rather than one giant config. A Product endpoint should not be able to filter on User.password_changed_at just because a strategy exists for it. This is the single most important pattern in the package and the one most easily lost in translation.
One strategy per operator, composed not branched. The healthy shape is:
eq, ieq, like, ilike, gte/lte, in, inoteq, between, isNull as distinct strategy classesQueryBuilder, the field, and the raw value, and returns a modified builderWhen you extend it, add a class rather than a switch. That is what makes the bundle worth copying into Laravel: your Eloquent version becomes scopeEq, scopeIlike, scopeBetween, resolved from a static $allowedFilters array — same shape, no Doctrine.
Treat the case-insensitive family as opt-in per field. v1.3.0's ieq / ilike / inoteq are the right operators for human-entered identity fields — filter[email][ieq], filter[name][ilike], filter[username][ieq]. They are the wrong default for everything else: they almost certainly compile to LOWER(col) LIKE LOWER(:val) (or a LOWER()-wrapped compare) because ILIKE is Postgres-only, which means no plain B-tree index. Enable them on the handful of columns that need them and keep eq/like on the rest.
Compose with sorting and pagination at the edge, not inside strategies. Let filters narrow the builder, then apply orderBy and pagination in the controller or a dedicated query-object. Mixing pagination into a filter strategy makes both untestable.
Feature-test the HTTP contract, unit-test the strategies. The resolver is thin; the risk lives in param parsing (filter[price][gte]=foo, nested arrays, missing operator, unknown operator name). Write functional tests asserting the emitted SQL/DQL or the result set for a handful of param shapes, and keep a small unit test per strategy — with null values, mixed case, and values containing %/_.
Adapter layer if you must bridge to Laravel. If you genuinely want this bundle's config format in a Laravel app, write a thin adapter: parse the same filter[field][operator]=value shape, resolve against the same whitelist, and dispatch to Eloquent scopes instead of Doctrine strategies. Keep the adapter in one namespace (App\Queries\Filters) so it doesn't leak into models. The new operators port cleanly — Eloquent's whereRaw('LOWER(col) LIKE ?') plus a Str::lower() binding is the whole implementation.
Integration tip for Symfony teams: register custom strategies with the tag the bundle expects (check the compiler pass in DependencyInjection/) and inject any repository/authorization service into the strategy constructor. A strategy that can consult the current user is how you get row-level filtering (filter[tenant] resolving to "current user's tenant" rather than trusting the client) instead of client-controlled scoping.
composer.json, and be prepared to fork. Do not put it on a critical path you can't patch yourself. Newly added strategies are the least-battle-tested code in the tree; audit ilike/ieq/inoteq yourself before trusting them in production.config/bundles.php, YAML config tree, Doctrine QueryBuilder, service tags. Any tutorial or Stack Overflow answer you find for "Laravel API filters" will not map. Budget for translation, not copy-paste.ILIKE is not portable — check how v1.3.0 actually implements it. Postgres has native ILIKE; MySQL, MariaDB and SQLite do not. If the strategy emits raw ILIKE, you have a Postgres-only bundle and it will blow up on other drivers. If it wraps in LOWER(), you have a silent index bypass. There is no third option that is free, so decide which trade-off you're buying. Also test non-ASCII: LOWER() on some collations doesn't case-fold É/İ the way a user expects.inoteq and NULL columns. LOWER(col) <> LOWER(:val) returns NULL — not true — when col IS NULL, so those rows vanish from the result set. That's SQL-correct and usually product-wrong. If nullable columns are filterable with a negation, add an explicit OR col IS NULL or document the behaviour loudly.like/ilike. ?filter[name][like]=100% is not a literal percent unless the strategy escapes % and _. Verify escaping; if it's absent, a user-supplied value can turn a targeted like into a table scan, and ?filter[x][ilike]=% matches everything.eq silently, a typo'd front-end request returns wrong data with a 200. Prefer an explicit 400 for unknown filter or operator keys — loud failures beat quietly-wrong result sets. This matters more now that there are more valid operator strings to typo (ieq vs eq vs inoteq is a one-character, data-changing difference).?filter[does_not_exist][eq]=1. Whichever way it behaves, decide deliberately and test it; silent ignore vs. exception is a client-breaking contract either way.distinct will return duplicated parents. Test counts, not just "it returned rows."field → operator → value) before the builder is executed, and dump the DQL. Nine times out of ten the bug is param shape mismatch between front-end and whitelist, not the strategy — and now also a case-sensitivity mismatch between what the client sent and what the DB compares.bin/console cache:clear; in dev, stale container config produces "no filters applied" behaviour that looks like a package bug. New operator names are config-tree keys, so this applies to the v1.3.0 upgrade too — clear the cache before concluding the upgrade failed.ilike doesn't handle your driver or your collation, write your own strategy rather than patching the resolver.spatie/laravel-query-builder. You get the same front-end contract with a maintained package and no Doctrine in your stack — and Eloquent's whereLike($col, $val, caseSensitive: false) covers the new operator family natively.How can I help you explore Laravel packages today?