Building Custom Filters for Admin UI Component Grids in Magento 2
AI generated
M2
di.xml
Magento 2 · Admin UI Components
Custom Filters for Admin Grids
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.

11 min read UI Components FilterPool Collection Modifier KnockoutJS Grid Performance

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.

11. FAQ: Custom Grid Filters in Magento 2: The Essentials

1Is a custom JavaScript widget enough instead of the full UI component filter pipeline?
For very small, purely client-rendered grids that can work, but on any grid with server-side pagination a pure frontend widget only filters the currently loaded page instead of the whole data set, which confuses admin users.
2Why does my custom filter show up in the form but has no effect?
In most cases the applier registration in the FilterPool via di.xml is missing, or the dataScope of the frontend component doesn't match the field name expected by the applier.
3Can a range filter also be applied to an EAV attribute?
Yes, but the applier then needs to use the relevant collection's EAV attribute join logic instead of a direct addFieldToFilter call, since EAV values don't sit directly as a column on the main table.
4Does the cache need to be cleared manually after changes to the ui_component XML?
Yes, UI component declarations are held in the layout cache, so a bin/magento cache:clean layout is usually required after structural changes to the XML.
5How are several simultaneously active filters combined?
The FilterPool calls the matching applier separately for each active filter, addFieldToFilter calls on the collection are automatically combined with an AND condition.
6What's the difference between filterSelect and a custom multi-select?
filterSelect covers static option lists hard-coded in XML, a custom multi-select with an optionsProvider is needed for option lists determined dynamically from the database.
7How do you test a custom applier without loading the full admin grid in the browser?
An integration test that instantiates the data provider directly and passes a simulated filter request checks the generated SQL condition without UI rendering and runs much faster than a browser test.
8Can filter values also be pre-filled from the URL?
Yes, the UI component registry reads active filters from the bookmark state, which is encoded as a query parameter in the URL, making a direct link with a pre-filled filter possible.
9What happens if the applier throws an exception?
The exception propagates up to the grid request processing and usually results in a generic error message in the admin grid, which is why an applier should be written defensively and rather ignore invalid input than throw an exception.
10Is a custom range filter worth it for an attribute with only a handful of possible values?
With a manageable number of fixed values a simple select filter is usually the better choice, a range filter only pays off for genuine numeric ranges like price, quantity, or date.