sidus/filter-bundle
Symfony bundle for building reusable, configurable filters over Doctrine queries and collections. Define filter sets once, then apply them from request parameters or code, keeping filtering logic out of controllers and repositories. Handy for admin listings and search screens.
Reality check first. This is a 1-star, 0-dependent package whose last release was June 2021. You are not adopting a community standard; you are adopting a small, annotation-era Symfony bundle maintained by one author. Treat the source and its test suite as the documentation — the README covers the happy path, not the edges.
Install and pin.
composer require sidus/filter-bundle
Because the last tag predates Symfony 6, expect to fight composer why-not on symfony/form, doctrine/orm, and php. Pin the exact version in composer.json and commit the lock file; there will not be a patch release if something breaks. Register the bundle in config/bundles.php and check config/packages/ for any required minimal config (the bundle is largely convention-driven, so a missing service definition usually shows up as an autowiring error rather than a missing config key).
Where to look first, in this order:
Sidus\FilterBundle\Annotation\FilterConfig and Annotation\Filter — the declarative surface. Everything else is plumbing around these two.Sidus\FilterBundle\Configuration\Filter / FilterConfig — the immutable value objects those annotations compile into.Sidus\FilterBundle\Search\FilterData — the per-request mutable carrier of user input.Handler\FilterHandlerProvider and Handler\FilterHandler — how you get from "a config name" to "something that can run a query".QueryBuilder.Tests/ in the repo — the fastest way to see a working end-to-end wiring, since the README example will not match your Symfony version verbatim.First use case. A back-office list page. Annotate the entity with a FilterConfig that names a filter set and declares which properties are filterable and of what type; in the controller, resolve the handler for that config name, bind the incoming request into FilterData, hand the resulting query to your paginator, and render. The win is that the list of filterable fields, their types, their labels, and their form options live next to the entity rather than being re-declared in a controller and again in a Twig template.
If you cannot get a trivial one-filter list working in an afternoon against your Symfony version, stop and pick a different approach — the maintenance cost of dragging this bundle forward will exceed the cost of writing a FilterDto + QueryBuilder mapper yourself.
Declare filter sets once, use them in many places. The core idea is that a FilterConfig is a named, immutable description: name, label, ordered list of filters, each with a type (string, number, date, boolean, choice, entity, …), form options, and per-type attributes. Because it is immutable, one entity can carry several configurations — user_admin_list, user_public_search, user_export — and each controller picks the set it needs. Prefer this over trying to mutate a config at runtime; the package is not designed for that.
Keep request-shaped state out of the definition. The split is deliberate: FilterConfig + Filter describe what may be filtered; FilterData carries what this request asked for. In a controller the shape is roughly:
$filterHandler = $this->filterHandlerProvider->getFilterHandler('user_admin_list');
$filterData = new FilterData($filterHandler->getFilterConfig());
$form = $this->createForm(FilterDataType::class, $filterData);
$form->handleRequest($request);
$queryBuilder = $this->userRepository->createListQueryBuilder();
$filterHandler->getQueryHandler()->handle($queryBuilder, $filterData);
// $queryBuilder now carries WHERE / ORDER BY clauses and bound parameters
$pager = new Pagerfanta(new DoctrineORMAdapter($queryBuilder));
(Confirm the exact method names against the 2021 tag — the handle() signature and whether sorting/pagination live in FilterData or a sibling object is the one thing most likely to have drifted across releases.)
Provider-agnostic is the actual selling point. The query handler interface is the seam. If your list is backed by Doctrine, use the shipped ORM handler. If part of your catalogue lives in Elasticsearch or a legacy API, implement the same interface and you get the same filter definitions, the same form, and the same FilterData — you only rewrite the translation from filters to query clauses. This is worth doing if you genuinely have two backends; if you only have Doctrine, the abstraction is overhead you pay for and never use.
Sorting and pagination belong to the handler, not the template. Let the query handler apply ORDER BY from the whitelisted sortable fields and let it stay inside the same QueryBuilder you pass to your paginator adapter. Do not re-apply ordering in the controller after handling — you will silently discard it.
Reuse the form for the UI. FilterDataType is a normal Symfony form built from the FilterConfig. Render it once in Twig, submit it via GET, and the query string becomes your shareable/bookmarkable list URL. For JSON APIs, walk FilterConfig::getFilters() to emit a filter-schema document that the front end uses to build its own controls — that keeps the field whitelist in one place across HTML and API surfaces.
Wire custom types sparingly. Registering a custom filter type buys you a new input widget plus the query fragment it implies. Do this for genuinely reusable semantics (e.g. a "date range with relative presets"); for one-off filters, prefer existing types with different form options.
Test at the query level. Spin an in-memory SQLite kernel, build the handler from the container, apply a FilterData populated by hand, and assert on $queryBuilder->getDQL() and getParameters(). That catches the majority of regressions without booting a browser.
doctrine/annotations installed purely to read the filter declarations — and if that package disappears from your dependency graph during a Doctrine upgrade, your entire filtering layer stops being discovered.FilterConfig or a decorator around the query handler that post-processes the QueryBuilder. Reaching into the config with reflection is a smell that will break on upgrade.order.customer.country will join two tables, and with a paginator that means duplicate rows and inflated counts. Check whether the handler emits DISTINCT (or whether you need to add it), and watch the EXPLAIN on large tables.null targets. You will almost always need explicit "is empty"/"is not empty" filters in addition to the value-based ones, because the generic types filter on equality/LIKE.FilterConfig can reach the QueryBuilder. Never derive a config name, a field name, or a sort column from raw user input. If you catch yourself doing $config->addFilter(new Filter($request->get('sort'))), you have reintroduced SQL injection through the back door.$queryBuilder->getDQL() and $queryBuilder->getParameters() before and after handle(). When a filter "does nothing", it is nearly always the type's parameter conversion (a date string not parsed, a choice value not matching a case, an entity id vs. object mismatch) rather than the handler.FilterHandlerProvider to inject configs from somewhere other than annotations (YAML, database, feature flags). The last one is the cleanest way to avoid annotations entirely if you are stuck on a modern PHP stack.KnpPaginator and adapt the QueryBuilder yourself. Do not expect the handler to return pages.CHANGELOG and the actual interface definitions rather than a blog post or the README — with a package this small, the source is the only reliable spec.How can I help you explore Laravel packages today?