ite/filtration-bundle
ITE FiltrationBundle is a Symfony bundle that adds configurable filtering to Doctrine entity queries. Define text, choice, date, boolean, and relation filters, then apply them to query builders to build dynamic search forms and listings with minimal setup.
First, sanity-check the stack. Despite how this package is often categorized, ite/filtration-bundle is a Symfony bundle wired through Symfony's DI container and targeting Doctrine. If you are on Laravel/Eloquent, you cannot composer require your way to value here — your job is to read it as a reference implementation of a declarative filter/sort/paginate contract and port the idea. The day-to-day "leverage" in a Laravel codebase is the pattern, not the dependency.
Minimal steps (Symfony/Doctrine context):
composer.json before installing. Confirm Symfony and doctrine/orm constraints against your version. With 1 star and 0 dependents, expect stale pins — budget a half-day spike and treat a Composer constraint failure as the decision point.composer require ite/filtration-bundle, then register the bundle (config/bundles.php on Symfony 4+, or AppKernel::registerBundles() on legacy).QueryBuilder (or ArrayCollection) and applies filters/sort/pagination. That single "apply" call is the integration point.First use case — an admin list endpoint:
// Controller: parse nothing yourself, hand the request-derived criteria to the filter service.
$qb = $this->userRepository->createQueryBuilder('u');
$result = $this->filterApplicator->apply($qb, 'users_list_filters', $request);
// $result: items + pagination metadata (page, total, per_page)
Start with the smallest possible filter set — one exact-match field (status) and one sortable field — and confirm it against a QueryBuilder. Only then wire in a second filter set for a different endpoint to prove reuse, because reusable filter sets are the whole point of adopting it.
For the Laravel developer porting this: the first durable artifact is a FilterDefinition value object (field → type/operators/allowed) plus a ListQuery object (filters, sorts, page). Build those, then one Eloquent adapter behind them. That is a 1–2 week bounded internal package, and it removes the abandoned-dependency risk entirely.
Declarative filter definitions per resource. Define the allowlist in config, not in the controller. Each field declares its type and the operators it accepts (eq, in, gte/lte for dates, like for text). Keep these definitions next to the entity config or in a dedicated filters.yaml — the point is that the definition is data, so it can be reviewed, diffed, and reused.
One filter set, many surfaces. The high-leverage pattern is registering a named filter set (e.g. orders_list) and reusing it across the admin UI, an export builder, and a JSON API. Same parameter names, same semantics, same pagination envelope. This is what eliminates the if ($request->has('status')) chains that drift between screens.
Apply at the query/domain layer, not the controller. Keep the controller thin:
public function index(Request $request, OrderFilterSet $filters): Response
{
$qb = $this->orders->createQueryBuilder('o'); // query layer
$page = $filters->apply($qb, $request->query->all()); // filter layer owns parsing
return $this->render('orders/index.html.twig', ['page' => $page]);
}
This is what lets the same filtering be reused by a CLI export or a message handler, not just HTTP.
Dual-source capability — use it deliberately. The interesting feature is that the same filter definition applies to an ArrayCollection (in-memory) and a QueryBuilder (database). The productive workflow is:
ArrayCollection fixture, apply the filter set, assert results. No database, fast.QueryBuilder.If you lean on this, keep the definition identical and the data small in tests. Do not treat it as a license to filter in memory in production (see Gotchas).
Authorization-aware filtering. The bundle has no opinion on row-level visibility. Compose it: apply a tenant/role constraint to the QueryBuilder before handing it to the filter applicator, or wrap the applicator in a decorator that injects the scope. Do not rely on the filter layer to enforce visibility — it only knows about whitelisted columns.
Custom filter logic via extension points. Where the built-in operators don't cover a case (full-text, JSON containment, computed ranges), the extensible architecture usually lets you register a custom filter type or decorate the applicator. Add these as small, tested classes rather than special-casing in controllers — otherwise you've reintroduced the duplication you adopted this to remove.
Integration tip — normalize the response shape. Wrap the applicator's output in your own pagination DTO (items, page, per_page, total, sort). This decouples your API contract from the bundle's internal return type, which matters a great deal when the package is unmaintained.
Framework mismatch is the headline gotcha. This is a Symfony bundle against Doctrine. In Laravel you either port the design or use a maintained, Laravel-native equivalent (spatie/laravel-query-builder for whitelisted filter/sort parsing). Do not attempt to bridge Doctrine into Eloquent — that is a rewrite wearing an install.
Never trust client-supplied field names. Filter and sort parameters are user-controlled by definition. Dynamic column interpolation into a QueryBuilder is a direct SQL-injection vector. Enforce a server-side allowlist from your filter definition and add a test that asserts unknown fields are rejected (throw, don't silently ignore). This is the single highest-severity failure mode and the easiest to miss because the abstraction looks like it handles it.
ArrayCollection filtering does not scale. It loads the whole result set into PHP memory per request. If an endpoint ever takes the in-memory path in production, cap it or eliminate it before launch. Reserve ArrayCollection for tests and small fixed sets.
Watch for silent divergence between the two sources. Same request against an ArrayCollection and a QueryBuilder can produce different results (null handling, case sensitivity, collation). This is the hardest bug class to trace. Mitigation: one code path per endpoint — don't mix.
Unindexed filter fields become full scans. The field allowlist is a security control; pair it with an index review. The moment a filterable column is exposed in the UI, ensure it's indexed.
Deep pagination degrades. LIMIT/OFFSET is fine early; plan keyset pagination for large tables, and don't let the abstraction hide that ordering cost.
Debugging: enable Doctrine's SQL logger and dump the generated DQL/SQL for a handful of representative filter combinations. Because the filter layer builds the query for you, "why did this return nothing?" is almost always a wrong operator mapping or an unexpected AND/OR grouping — reading the emitted SQL is faster than stepping through the applicator.
Config quirks: the usual failure is service registration — a filter set that isn't tagged/registered will look wired but silently ignore its filters. Verify by asserting a known filter actually narrows results, not just that the service resolves. On Symfony 4+/5, confirm the bundle is in config/bundles.php; older bundles frequently ship a DependencyInjection/Extension.php expecting legacy config locations.
Own it or skip it. With 1 star and 0 dependents, there is no security response and no compatibility cadence — every PHP/Symfony upgrade becomes your patch. If you adopt: pin a version, vendor/fork with a named internal owner, keep the applicator behind your own DTO so you can swap it, and set a sunset date. For most teams the correct move is to extract the contract and implement it in-house — the transferable idea (declarative, whitelisted, server-driven filtering applied uniformly) is worth the sprint; the package is not worth the dependency.
How can I help you explore Laravel packages today?