How UI component declaration, custom filter types, and the collection modifier work together
The standard filters that ship with admin UI components cover most grids well, but they hit their limits fast once you need value ranges, dynamically populated option lists, or business-specific edge cases. Understanding how filter declaration, the frontend component, and the collection modifier interact lets you add any filter type cleanly instead of hacking around the grid with client-side JavaScript.
Table of Contents
- 1. Where standard UI component filters stop being enough
- 2. Declaring filters in the UI component XML
- 3. Implementing a custom range filter as a KnockoutJS component
- 4. Multi-select filters with dynamic options
- 5. The central filter registry and the FilterPool
- 6. The collection modifier that produces real database filtering
- 7. Wiring a custom data provider
- 8. Common failure modes when debugging custom filters
- 9. Performance considerations for custom grid filters
- 10. Summary
- 11. FAQ
1. Where standard UI component filters stop being enough
An admin grid in Magento is described by a listing.xml that declares columns, mass actions, and filters. For the common cases, text search, select dropdown, date range, Magento ships ready-made filter components that require no custom code at all. As soon as a filter needs to represent a numeric value range, such as stock quantity between two thresholds, or offer a multi-select drawn from a runtime-determined option list, the standard palette runs out.
The tempting but wrong move is rebuilding the filter client-side with a bespoke JavaScript snippet and shipping the unfiltered collection to the browser. That works for tiny data sets, but breaks down for any catalog with more than a few thousand rows because filtering then happens in the browser instead of the database. The clean path goes through the three building blocks Magento provides for exactly this purpose: the filter declaration in listing.xml, the frontend component in JavaScript, and the server-side collection modifier.
2. Declaring filters in the UI component XML
Every column in listing.xml can be assigned its own filter type via a filters node or directly through the column component's arguments. Magento distinguishes between the visible filter in the form above the grid and the underlying data source that later forwards the selected value to the collection. For a custom range filter, the standard filterRange component serves as the base, extended with a custom UI component XML element that renders two input fields for minimum and maximum values.
The exact match of the dataScope between the filter component and the field name expected later by the collection modifier is critical. A typo here causes a silent failure: the filter form looks correct, but the filter never reaches the collection because the UI component registry stores the value under a different key than expected.
<!-- app/code/Mironsoft/AdminGridFilters/view/adminhtml/ui_component/product_stock_listing.xml -->
<column name="qty" sortOrder="30">
<settings>
<filter>rangeFilter</filter>
<label translate="true">Stock Quantity</label>
<dataType>number</dataType>
</settings>
</column>
<argument name="data" xsi:type="array">
<item name="js_config" xsi:type="array">
<item name="component" xsi:type="string">
Mironsoft_AdminGridFilters/js/grid/filters/range-filter
</item>
</item>
</argument>
3. Implementing a custom range filter as a KnockoutJS component
The frontend component for a range filter ideally inherits from Magento_Ui/js/grid/filters/filter, the shared base prototype of all filter widgets. Two observable fields for the lower and upper bound cover the core logic, the important part is correctly implementing getPreview for the human-readable preview of the active filter and getDataScope for submitting the value to the central filter registry that the server reads its grid query parameters from.
The second building block is the matching Knockout template, which renders two side-by-side number inputs with a separator. Because the template file lives under view/adminhtml/web/template and is not loaded automatically, it has to be explicitly referenced in the filter's js_config, otherwise the component only renders an empty div with no visible inputs.
// app/code/Mironsoft/AdminGridFilters/view/adminhtml/web/js/grid/filters/range-filter.js
define(['Magento_Ui/js/grid/filters/filter'], function (Filter) {
'use strict';
return Filter.extend({
defaults: {
template: 'Mironsoft_AdminGridFilters/filters/range',
valueFrom: '',
valueTo: '',
listens: {
valueFrom: 'updatePreview',
valueTo: 'updatePreview'
}
},
/**
* Get value that is displayed in a preview.
*
* @returns {String}
*/
getPreview: function () {
if (!this.valueFrom() && !this.valueTo()) {
return '';
}
return this.valueFrom() + ' - ' + this.valueTo();
},
/**
* Get filter data scope for server query parameters.
*
* @returns {Object}
*/
getDataScope: function () {
return {
from: this.valueFrom(),
to: this.valueTo()
};
}
});
});
4. Multi-select filters with dynamic options
A multi-select filter with a static option list is already covered by the standard Magento_Ui/js/grid/filters/elements/ui-select component and needs no custom code. It's a different story once the option list is only known at runtime, for example every supplier code currently present in a catalog. That case requires a custom optionsProvider class on the PHP side that implements ArrayInterface and reads options from the relevant table instead of hard-coding them in XML.
To avoid re-querying the database on every single grid request, a simple in-memory cache inside the optionsProvider instance for the duration of a request pays off. A cache spanning multiple requests via Magento's cache framework is usually unnecessary here, since the option list is only queried when the filter form first renders, not on every sort or page change.
<?php
declare(strict_types=1);
namespace Mironsoft\AdminGridFilters\Model\Source;
use Magento\Framework\Data\OptionSourceInterface;
use Magento\Framework\App\ResourceConnection;
/**
* Provides the currently used supplier codes in the catalog
* as a dynamic option list for the multi-select grid filter.
*/
class SupplierCodeSource implements OptionSourceInterface
{
/**
* @param ResourceConnection $resourceConnection Database connection access
*/
public function __construct(
private readonly ResourceConnection $resourceConnection,
) {
}
/**
* Builds the option list from the distinct values of the supplier column.
*
* @return array<int, array{value: string, label: string}>
*/
public function toOptionArray(): array
{
$connection = $this->resourceConnection->getConnection();
$select = $connection->select()
->from(
$this->resourceConnection->getTableName('catalog_product_entity_varchar'),
['value']
)
->distinct(true)
->where('value IS NOT NULL');
$options = [];
foreach ($connection->fetchCol($select) as $code) {
$options[] = ['value' => $code, 'label' => $code];
}
return $options;
}
}
5. The central filter registry and the FilterPool
On the server side, every active filter first lands not on the collection directly, but in Magento\Framework\View\Element\UiComponent\DataProvider\FilterPool. This pool maps each declared filter type to an ApplierInterface implementation that knows how to translate a given filter value into an actual where condition. Standard filters already have appliers for text, select, and date range, a range filter over a numeric attribute needs its own applier.
Registering a custom applier happens via di.xml as a virtual type inside the filter_pool arguments of the relevant UI component data provider. Skip this step and the filter stays visible and interactive in the form but has zero effect on the returned grid rows, a failure mode that only becomes clear once you inspect the generated SQL query closely.
<!-- app/code/Mironsoft/AdminGridFilters/etc/adminhtml/di.xml -->
<type name="Magento\Framework\View\Element\UiComponent\DataProvider\FilterPool">
<arguments>
<argument name="appliers" xsi:type="array">
<item name="qty_range" xsi:type="object">
Mironsoft\AdminGridFilters\Model\Filter\RangeFilterApplier
</item>
</argument>
</arguments>
</type>
6. The collection modifier that produces real database filtering
The actual applier is a lean class implementing ApplierInterface, whose apply method translates the raw data submitted by the filter into an addFieldToFilter condition on the collection. This is exactly where the rule from the PHPStan guidelines applies: never call addFieldToFilter with a raw integer, always use the explicit array form, otherwise comparison operators can be misinterpreted depending on the attribute type.
For the range filter this means two chained conditions, one for the lower and one for the upper bound, both only applied when the respective value is actually present in the request. If only the lower bound is submitted, the upper condition has to be skipped entirely instead of generating an always-true or always-false condition from an empty value that would corrupt the rest of the filter result.
<?php
declare(strict_types=1);
namespace Mironsoft\AdminGridFilters\Model\Filter;
use Magento\Framework\View\Element\UiComponent\DataProvider\Document;
use Magento\Framework\View\Element\UiComponent\DataProvider\Filter;
use Magento\Framework\View\Element\UiComponent\DataProvider\ApplierInterface;
use Magento\Framework\Data\Collection;
/**
* Translates the range filter value into two chained
* addFieldToFilter conditions on the grid collection.
*/
class RangeFilterApplier implements ApplierInterface
{
/**
* Applies the range condition to the given collection.
*
* @param Collection $collection Active grid collection
* @param Filter $filter Active filter with raw values from the request
* @return void
*/
public function apply(Collection $collection, Filter $filter): void
{
$condition = $filter->getCondition();
$field = $filter->getField();
if (!is_array($condition)) {
return;
}
if (!empty($condition['from'])) {
$collection->addFieldToFilter($field, ['gteq' => $condition['from']]);
}
if (!empty($condition['to'])) {
$collection->addFieldToFilter($field, ['lteq' => $condition['to']]);
}
}
}
7. Wiring a custom data provider
Grids based on a custom collection rather than one of the standard collections need their own data provider inheriting from Magento\Ui\DataProvider\AbstractDataProvider. Inside this provider, the FilterPool is injected via dependency injection and referenced correctly in getSearchResult and prepareUpdateUrl. The classic mistake is injecting the FilterPool but never actually connecting it to the collection, so filters are declared and registered correctly on paper but simply never invoked.
With custom collections that join multiple tables, it also has to be certain that the field referenced by the filter can be uniquely resolved to one table. If a column of the same name exists in two joined tables, addFieldToFilter throws an ambiguous column error at runtime, most easily avoided with an explicit table prefix notation in the field name.
8. Common failure modes when debugging custom filters
Three sources of error show up over and over in practice: a dataScope mismatch between the filter component and what the applier expects, a forgotten cache flush after changes to the ui_component XML, since UI component declarations end up in the generated layout cache, and an applier registered in the wrong scope, adminhtml instead of global. All three symptoms look identical: the filter appears correct in the frontend but has no effect on the displayed rows.
The fastest way to narrow down the cause is the network panel in the browser's dev tools: the request sent to the server carries the actual submitted raw values in the filters query parameter. If the expected key is missing entirely, the problem sits in the frontend component's dataScope. If the key is present but the result is still unfiltered, the fault most likely lies in the applier or its registration.
9. Performance considerations for custom grid filters
A custom filter is only as fast as the column it's applied to. Range filters over EAV attributes that live in a dedicated value table instead of the grid's flat structure quickly cause full table scans without an additional index once the catalog reaches a few hundred thousand rows. A composite index on entity_id and attribute_id noticeably reduces the cost here, but should only be added if the filter is actually used regularly in day-to-day admin work.
For dynamic multi-select options, it's also worth checking how many values actually end up in the generated IN clause. More than a few dozen simultaneously selected options can lead to noticeably slower execution plans on some database configurations compared to an equivalent join against a temporary value list, a detail that only becomes relevant with very large catalogs in practice.
| Filter Type | Frontend Component | Server-side Building Block | Typical Use Case |
|---|---|---|---|
select |
Magento_Ui/js/grid/filters/elements/select | Standard applier (core) | Status values, fixed enums |
dateRange |
Magento_Ui/js/grid/filters/range | Standard applier (core) | Creation and update dates |
| Range filter (custom) | custom extension of filter.js | custom ApplierInterface | Stock quantity, price range |
| Dynamic multi-select | ui-select with optionsProvider | custom OptionSourceInterface class | Supplier codes, store-specific values |
| Text full-text search | Magento_Ui/js/grid/filters/elements/input | Standard applier (core) | SKU, name, free text |
Mironsoft
Magento development, module consulting, and system architecture
A Magento project that needs a second opinion or experienced execution?
We build custom Magento modules, advise on architecture decisions, and take on complex implementations, from service contract planning to production-ready deployment.
Architecture Consulting
Have module and system architecture thought through properly before you build.
Custom Module Development
Build custom Magento modules cleanly, following best practices.
Code Review & Audit
Have existing modules reviewed for performance, security, and maintainability.
10. Summary
Custom Grid Filters in Magento 2: The Essentials
Declaration
Assign a filter type in listing.xml and match dataScope exactly to the field name used in the applier.
Frontend
Derive a KnockoutJS component from Magento_Ui/js/grid/filters/filter and reference a dedicated template.
Backend
Implement ApplierInterface, register it in the FilterPool via di.xml, and always use the array form of addFieldToFilter.
Performance
Keep an eye on indexes and IN clause size for EAV range filters on large catalogs.