How to Add a Custom Layered Navigation Filter in Magento 2
Requests for a custom layered navigation filter come in many forms. Show only featured products. Add an on sale filter. Render a yes or no attribute as a single checkbox instead of a list with Yes and No. Before writing code it helps to know how Magento builds the filter list in 2.4, because the right answer is often less code than expected.
How layered navigation works in 2.4
The block Magento\LayeredNavigation\Block\Navigation asks a Magento\Catalog\Model\Layer\FilterList for its filters. There are two virtual types of that class, one for category pages and one for search results, configured in Magento_LayeredNavigation's di.xml. FilterList returns a category filter plus one filter per attribute that is marked as filterable for that context.
During layout preparation the block calls apply() on each filter with the current request. A filter that finds its parameter in the URL adds a condition to the layer's product collection and adds an entry to the layer state, which is the "Now Shopping by" box. Later the template renders each filter's items with counts.
The product collection on a category or search page is a search collection. Conditions added with addFieldToFilter() are turned into an OpenSearch query, and the counts next to each option come from OpenSearch aggregations, which Magento calls facets. This matters for any custom filter: the field you filter on must exist in the search index, and the counts must come from a facet, not from a separate MySQL query per option.
Option 1: a product attribute
Most custom filters are really attributes. If the shop wants to filter by "featured", create a Yes/No or dropdown attribute, set Use in Layered Navigation to Filterable (with results), and assign it to the attribute sets. Magento adds it to the index, builds the aggregation and renders the filter. No custom code.
When the value is computed, for example "on sale" or "ships in 24 hours", the simplest reliable approach is still an attribute, filled by a cron job or by the integration that knows the value. Filtering on a stored attribute is cheap. Computing a value at query time is not something the search layer is built for.
Create the attribute with a data patch so every environment gets it. The full pattern is in creating a product attribute with a data patch. The properties that matter here are filterable set to 1, and filterable_in_search if it should appear on search results too.
Option 2: change how a filter renders
If the attribute is right and only the look is wrong, override the renderer template in your theme. The default template for filter items is Magento_LayeredNavigation::layer/filter.phtml. Swatch attributes use the renderer from Magento_Swatches. A theme override at app/design/frontend/Vendor/theme/Magento_LayeredNavigation/templates/layer/filter.phtml lets you change the markup for every filter. If you only need one filter to look different, branch on the filter's request variable inside the template.
Option 3: a custom filter class
A custom class is worth it when the filter behaves differently from a normal attribute filter. A common case is showing a boolean attribute as a single "Featured only" link that applies value 1, hiding the No option, and controlling the label and position. The example below does that for an attribute called is_featured. The attribute must exist and be filterable, so that it is indexed and has an aggregation. The plugin then removes the default attribute filter and adds the custom one.
The filter class
<?php
declare(strict_types=1);
namespace Vendor\LayeredNav\Model\Layer\Filter;
use Magento\Catalog\Model\Layer;
use Magento\Catalog\Model\Layer\Filter\AbstractFilter;
use Magento\Catalog\Model\Layer\Filter\Item\DataBuilder;
use Magento\Catalog\Model\Layer\Filter\ItemFactory;
use Magento\Framework\App\RequestInterface;
use Magento\Store\Model\StoreManagerInterface;
class Featured extends AbstractFilter
{
private const FIELD = 'is_featured';
public function __construct(
ItemFactory $filterItemFactory,
StoreManagerInterface $storeManager,
Layer $layer,
DataBuilder $itemDataBuilder,
array $data = []
) {
parent::__construct($filterItemFactory, $storeManager, $layer, $itemDataBuilder, $data);
$this->_requestVar = 'featured';
}
public function apply(RequestInterface $request)
{
if ((string)$request->getParam($this->getRequestVar()) !== '1') {
return $this;
}
$this->getLayer()->getProductCollection()->addFieldToFilter(self::FIELD, 1);
$this->getLayer()->getState()->addFilter(
$this->_createItem((string)__('Featured only'), '1')
);
$this->setItems([]);
return $this;
}
public function getName()
{
return __('Featured');
}
protected function _getItemsData()
{
$facets = $this->getLayer()->getProductCollection()->getFacetedData(self::FIELD);
$count = (int)($facets[1]['count'] ?? 0);
if ($count === 0) {
return [];
}
$this->itemDataBuilder->addItemData((string)__('Featured only'), '1', $count);
return $this->itemDataBuilder->build();
}
}
A few details are worth explaining. _requestVar is the URL parameter, so the filtered URL ends in ?featured=1. getFacetedData() reads the aggregation Magento already requested for the filterable attribute, so the count costs nothing extra. Calling setItems([]) after applying hides the option once it is active, which is how the core attribute filters behave for single select values.
The FilterList plugin
<?php
declare(strict_types=1);
namespace Vendor\LayeredNav\Plugin;
use Magento\Catalog\Model\Layer;
use Magento\Catalog\Model\Layer\Filter\AbstractFilter;
use Magento\Catalog\Model\Layer\FilterList;
use Vendor\LayeredNav\Model\Layer\Filter\FeaturedFactory;
class AddFeaturedFilter
{
private array $filters = [];
public function __construct(private readonly FeaturedFactory $featuredFactory)
{
}
public function afterGetFilters(FilterList $subject, array $result, Layer $layer): array
{
$key = spl_object_id($subject);
if (isset($this->filters[$key])) {
return $this->filters[$key];
}
$filters = [];
foreach ($result as $filter) {
if ($this->isFeaturedAttribute($filter)) {
continue;
}
$filters[] = $filter;
}
$filters[] = $this->featuredFactory->create(['layer' => $layer]);
return $this->filters[$key] = $filters;
}
private function isFeaturedAttribute(AbstractFilter $filter): bool
{
return $filter->hasAttributeModel()
&& $filter->getAttributeModel()->getAttributeCode() === 'is_featured';
}
}
The navigation block calls getFilters() more than once per request, once to apply and once to render. FilterList caches its own result, and the plugin does the same so the filter that was applied is the same instance that renders. The FeaturedFactory is generated by Magento, so you do not write it.
di.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<type name="Magento\Catalog\Model\Layer\FilterList">
<plugin name="vendor_layerednav_featured"
type="Vendor\LayeredNav\Plugin\AddFeaturedFilter"/>
</type>
</config>
Put it in etc/frontend/di.xml so it only runs on the storefront. Plugins declared on a class also apply to virtual types built from it, so this covers both category and search pages. If you only want it on category pages, attach the plugin to the category virtual type name from the core di.xml instead.
After deploying, run bin/magento setup:di:compile in production mode and flush the cache. If the filter appears but the count is always zero, the attribute is not filterable for that context or the products were not reindexed. Full page cache also stores the rendered navigation, so test with the cache flushed.
Filters that are not attributes
Sometimes the data is not on the product at all, such as a value from an external system. You can add custom fields to the product document in the search index, but that means extending the index data mapping and the search request configuration, and keeping both in sync during partial reindexes. In most projects I would rather store the value in an attribute and let the core do the indexing. It is less code to maintain through upgrades, and the behavior is the same as every other filter.
A filter based on a MySQL join added to the collection is the option I avoid. The search collection does not support it cleanly, counts end up wrong, and it breaks with the next upgrade.
Performance notes
Each filterable attribute adds an aggregation to every category request, and each option is rendered with a URL. Large option lists, for example a brand filter with two thousand brands, make the HTML heavy and slow down rendering. Keep the filterable list short, limit long lists in the template, and check the result with the steps in the slow category pages checklist. Layered navigation URLs also multiply crawlable pages, so decide early which filtered URLs search engines should index.
FAQ
Why does my new attribute not show in layered navigation?
Check that Use in Layered Navigation is set to Filterable, that the attribute is in the products' attribute sets, that the category is an anchor category, and that catalogsearch_fulltext was reindexed. Attributes with no values on the listed products are hidden.
Can I filter layered navigation by price ranges I define?
Yes. Under Catalog, Layered Navigation in the configuration you can choose automatic ranges or a manual step. For fully custom ranges you would extend the price filter, which is a bigger change than a simple attribute filter.
Do custom filters work with full page cache?
Yes. Each filtered URL is a different cache entry because the query string differs. Make sure the filter only depends on the request and not on session data, or cached pages will show the wrong results.
Should I use a third party layered navigation extension?
For multi select filters, AJAX filtering and SEO friendly filter URLs, a mature extension saves time. For one or two simple filters, a small module like the one above is easier to keep working through upgrades.
I am available for freelance Magento and Adobe Commerce work, including catalog and layered navigation changes. If you need help with this, get in touch.
Related articles
How to Create a Custom Product Type in Magento 2
When a custom Magento 2 product type is worth it, and how to build one: product_types.xml, type and price models, apply_to, inventory, indexing and GraphQL.
Magento 2 Cache Types Explained: Clean, Flush, Custom
What each Magento 2 cache type stores, the real difference between cache:clean and cache:flush, invalidated caches, and how to add your own cache type.
Magento 2 Cart Price Rules: Add a Custom Condition
Add a custom condition to Magento 2 cart price rules: the combine event, a condition model with validate, promo rule attributes and performance traps to avoid.
Magento 2 Configurable Products Performance: What to Fix
Why configurable products slow down Magento 2 pages, admin saves and indexers, and how to fix it: variation counts, swatches, price indexing and data modeling.