ongr/filter-manager-bundle
Symfony bundle providing a filter manager for building and applying Elasticsearch filters to searches. Integrates with ONGR ElasticsearchBundle so you can define, register, and reuse filters like terms and ranges cleanly across your application.
Install and register. This package isn't a standalone ES client—it's a Symfony bundle that sits on top of ongr/elasticsearch-bundle. Install both, then register Ongr\FilterManagerBundle\OngrFilterManagerBundle in AppKernel/bundles.php. It pulls in an ES connection config, so have ongr_elasticsearch configured and a working index before you start.
Look first at the bundle's own README.md and the Resources/config/services.yml — the service IDs (ongr_filter_manager.filter_manager, the repository wrapper, and the tag ongr_filter_manager.filter) are the real entry points. The README's YAML examples are the most useful documentation; the Filter/ and Repository/ directories are small and readable.
First use case: a product listing page with facet filters. Define one manager (a named group of filters + a repository) and the filters underneath it in config.yml:
ongr_filter_manager:
managers:
product_list:
repository: es.manager.default.product
filters: [color, price, brand]
filters:
color:
type: multi_choice
request_field: color # ?color=red,blue
field: color
document_class: AppBundle:Product
aggregations:
name: color
type: terms
size: 20
load_choices: true
price:
type: range
request_field: price
field: price
document_class: AppBundle:Product
aggregations:
name: price
type: range
ranges:
- { from: 0, to: 50 }
- { from: 50, to: 100 }
load_choices: true
Wire it in a controller. Resolve the named manager, hand its repository to your view, and read the documents/aggregations it produced. Typical shape:
$filterManager = $this->get('ongr_filter_manager.filter_manager_repository')
->getManager('product_list');
return $this->render('product/list.html.twig', [
'filterManager' => $filterManager,
'products' => $filterManager->getRepository()->getDocuments(),
]);
Confirm the exact manager-resolver service ID against the bundle's services.yml — that's the one place where this bundle varies across versions.
Render in Twig by looping filters → choices, not by hand-building query strings:
{% for filter in filterManager.getFilters() %}
<h3>{{ filter.name }}</h3>
<ul>
{% for choice in filter.choices %}
<li>
<a href="{{ choice.url }}">{{ choice.name }} ({{ choice.count }})</a>
</li>
{% endfor %}
</ul>
{% endfor %}
The first end-to-end win is a filter sidebar whose links toggle ES terms aggregations while the product grid re-query reflects the active filters.
One manager per page section. Define product_list, category_list, brand_list as separate managers. Each manager owns its own repository and filter set, so a category sidebar and a product grid never interfere. This is the intended usage—managers are cheap to define.
Separate "search" filters from "facet" filters. A search filter (free text) maps to a query clause, while choice/multi_choice/range filters map to aggregations. Keep them in the same manager but expect them to apply differently to the ES query vs. post-filter. If you need facet counts to reflect the whole corpus rather than the filtered one, look for the apply_to_aggregations option on individual filters (or its equivalent in your version) before you start hand-writing post-filters.
Multi-choice is the default for product lists. multi_choice reads an array from a single request param (?color=red&color=blue or comma-separated depending on config). choice is single-value and is a better fit for "sort" or "category" selectors. Range filters (range, date_range, boolean) derive choices from your YAML range definitions rather than the ES aggregation—so range filter counts behave differently from term counts; don't be surprised.
load_choices: true is effectively required. Without it the aggregation is built but getChoices() returns an empty array. This is the single most common "why is my sidebar blank" cause.
Custom filters: extend AbstractFilter, implement getChoices() and isActive(), tag the service. That's the extension point. Register the service with ongr_filter_manager.filter (optionally with a type attribute matching your YAML type). This is the clean way to add things like "in stock" booleans driven by non-ES logic or "on sale" filters that need a custom query clause.
Controller→Twig contract. Expose the manager as-is and let Twig call getFilters(), getActiveFilters(), and filter.isActive(). That keeps URL-generation logic (adding/removing a param, preserving the others) centralized in Choice::getUrl() instead of sprinkled across templates. For pagination, pass along the same manager so page-2 links retain active filters.
Compose with a paginator. The manager's repository gives you the filtered query; hand that query to Knp/Pagerfanta in the usual ES way. Don't rebuild the query yourself—you'll bypass the filter application and get counts that don't match the sidebar.
Multiple managers per request are fine and expected; resolve each by name in the controller. Share the ES connection/config, not the manager.
This is a 2022-era package with a small footprint (26 stars, 0 direct dependents). Pin Symfony and ongr/elasticsearch-bundle versions together and expect to do compatibility work (e.g. Filter type hints, container aliasing) if you're on Symfony 5+/6+. There is no active maintenance; treat the source as the documentation of record.
ES mapping is your problem, not the bundle's. A terms aggregation on a text-analyzed field returns buckets per token, not per product—so color: "dark red" becomes two buckets. Use keyword fields or sub-fields (field: color.keyword) in the filter config. This bites everyone the first time.
Aggregation size defaults to 10. If you have 30 colors, silently you get 10 choices. Set size explicitly on every terms aggregation, or you'll chase a "missing facet values" bug for a while.
Active detection depends on the request. Filters read from the current Request via the request stack. In long-running workers (Roadrunner, Swoole, Messenger consumers) the manager is not request-safe—instantiate/scoped it per request or reset it, or you'll leak filters between requests.
Debug by dumping the generated query. Before you fight the sidebar, dump the repository's query ($manager->getRepository()->getQuery() or the equivalent getter) to see the exact aggregation and post_filter payload. Compare it to what you'd send to _search by hand. 90% of "filter isn't applied" cases are visible in that payload.
document_class must match the repository's index type. Mismatches produce empty aggregations rather than errors, which makes them look like config typos. Cross-check the repository alias, the document class, and the ES index.
URL params must survive your router. Choice URLs are generated from the current request plus the toggle. If you use localized routes or custom slugs that strip unknown query strings, filter URLs silently break. Test a choice link with two active filters before going further.
Range filters don't get counts from ES. They're rendered from the ranges config, so counts are either absent or computed separately. If you need count-per-range, add a redundant range aggregation and read it yourself, or build a custom filter.
Extension points, in order of usefulness: (1) custom filter class extending AbstractFilter with the ongr_filter_manager.filter tag; (2) overriding the Twig template that renders filters/choices; (3) configuring apply_to_aggregations/post-filter behavior per filter. Avoid subclassing the manager or repository—those internals are the least stable surface.
Caching: filter configs and manager definitions can be cached by the container; aggregations cannot. If you cache the rendered sidebar, key it on the full set of active request params, not just the page.
How can I help you explore Laravel packages today?