How to Build a Custom Advanced Filters Module for Magento 2 Category Pages

Intro — why custom advanced filters?

If you’ve ever spent time tweaking Magento 2 layered navigation for a large catalog, you know the default filters can be limiting: single-select attributes, clunky price UIs, and filter counts that kill page performance on catalogs with tens of thousands of SKUs. In this post I’ll walk you through building a custom “Advanced Filters” module for Magento 2 category pages. The tone is relaxed — like I’m explaining it to a teammate who knows Magento basics but hasn’t built a custom layered navigation system yet.

What we’ll cover

  • Architecture: creating custom product attributes and integrating with Magento’s native filter system (layered navigation)
  • UX/UI: price range sliders, multi-select filters and real-time (AJAX) search updates
  • Performance: query strategy, caching tips, and using Elasticsearch for faceting
  • Integration with Force Product Stock Status: combining stock state with filters
  • Step-by-step code examples (module skeleton, attribute creation, filter class, JS and controller)

Module overview and design decisions

High level, the module will do three things:

  1. Create and expose custom product attributes configured to work with layered navigation (including multi-select support).
  2. Override/extend Magento’s filter models where necessary to support multi-select and range UI behavior.
  3. Provide an AJAX front-end to apply filters without a full page reload and make sure everything is cache-friendly.

Why not replace Magento’s layered navigation entirely? Because Magento already handles a lot (filter persistence in URLs, product collections, indexers). We’ll hook into its architecture and extend the pieces we need.

Module skeleton — the files you need

Create a module called Magefine_AdvancedFilters (use your vendor name). Basic files:

// app/code/Magefine/AdvancedFilters/registration.php
<?php
use Magento\Framework\Component\ComponentRegistrar;
ComponentRegistrar::register(ComponentRegistrar::MODULE, 'Magefine_AdvancedFilters', __DIR__);

// app/code/Magefine/AdvancedFilters/etc/module.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
  <module name="Magefine_AdvancedFilters" setup_version="1.0.0" />
</config>

We’ll add more files as we go (di.xml, Setup/UpgradeData, filter classes, controllers, JS).

1) Creating custom attributes that work with layered navigation

Magento's layered navigation relies on product EAV attributes. The important flags are:

  • is_filterable — makes the attribute filterable in layered navigation (1 or 2)
  • is_filterable_in_search — filter available in search results
  • used_in_product_listing / used_for_promo_rules — optional but useful
  • frontend_input — select or multiselect for faceted filters

Example: add a multi-select attribute called features that customers can multi-select in layered nav.

// app/code/Magefine/AdvancedFilters/Setup/InstallData.php
<?php
namespace Magefine\AdvancedFilters\Setup;

use Magento\Eav\Setup\EavSetupFactory;
use Magento\Framework\Setup\InstallDataInterface;
use Magento\Framework\Setup\ModuleContextInterface;
use Magento\Framework\Setup\ModuleDataSetupInterface;

class InstallData implements InstallDataInterface
{
    private $eavSetupFactory;

    public function __construct(EavSetupFactory $eavSetupFactory)
    {
        $this->eavSetupFactory = $eavSetupFactory;
    }

    public function install(ModuleDataSetupInterface $setup, ModuleContextInterface $context)
    {
        $eavSetup = $this->eavSetupFactory->create(['setup' => $setup]);

        $eavSetup->addAttribute(
            \Magento\Catalog\Model\Product::ENTITY,
            'features',
            [
                'type' => 'varchar',
                'label' => 'Product Features',
                'input' => 'multiselect',
                'required' => false,
                'user_defined' => true,
                'visible' => true,
                'global' => \Magento\Eav\Model\Entity\Attribute\ScopedAttributeInterface::SCOPE_GLOBAL,
                'is_used_in_grid' => true,
                'is_visible_in_grid' => true,
                'is_filterable' => 1, // 1 = filterable (with results)
                'is_filterable_in_search' => 1,
                'backend' => 'Magento\Eav\Model\Entity\Attribute\Backend\ArrayBackend'
            ]
        );
    }
}

Notes:

  • multiselect attributes need ArrayBackend to save properly.
  • To be visible in layered navigation, ensure the attribute has options (via admin or script) and is assigned to product attribute sets.

2) Integrating with Magento native filters — when to extend

Magento’s standard attribute filter ( \Magento\Catalog\Model\Layer\Filter\Attribute ) expects single-value behavior. For multi-select filters we need to extend the class to read arrays from request params and apply multiple OR/AND logic to the collection.

Approach:

  1. Create a preference in di.xml so our class replaces the core attribute filter.
  2. Override the apply() and getItemsData() methods to read multiple values and to compute counts efficiently.
// app/code/Magefine/AdvancedFilters/etc/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">
  <preference for="Magento\Catalog\Model\Layer\Filter\Attribute" type="Magefine\AdvancedFilters\Model\Layer\Filter\Attribute" />
</config>

// app/code/Magefine/AdvancedFilters/Model/Layer/Filter/Attribute.php
<?php
namespace Magefine\AdvancedFilters\Model\Layer\Filter;

use Magento\Catalog\Model\Layer\Filter\Attribute as CoreAttribute;
use Magento\Framework\App\RequestInterface;

class Attribute extends CoreAttribute
{
    /**
     * Override apply to support multi-select values (comma or [] in query)
     */
    public function apply(RequestInterface $request)
    {
        $value = $request->getParam($this->getRequestVar());
        if (!$value) {
            return $this;
        }

        // Support both "a,b,c" and array values
        if (is_array($value)) {
            $values = $value;
        } else {
            $values = explode(',', $value);
        }

        // sanitize
        $values = array_filter($values);

        if (!count($values)) {
            return $this;
        }

        // Apply OR logic: product matches any selected option
        $this->getLayer()->getProductCollection()->addAttributeToFilter(
            $this->getAttributeModel()->getAttributeCode(),
            ['in' => $values]
        );

        // add layer state labels
        foreach ($values as $val) {
            $label = $this->getOptionText($val);
            $this->getLayer()->getState()->addFilter($this->_createItem($label, $val));
        }

        return $this;
    }

    /**
     * Override getItemsData to compute counts properly for multi-select
     */
    public function getItemsData()
    {
        // use index table or aggregations (Elasticsearch) for counts, not collection fetch
        // simplified example: fallback to parent for demo
        return parent::getItemsData();
    }
}

Important: the snippet above is simplified. In production you should compute counts with optimized SQL or use Elasticsearch aggregations (we’ll get to that).

3) UX: Price range, sliders and multi-select controls

Price ranges and sliders are mostly front-end work. Magento already exposes a price filter class, but its UI is server-driven by default. For a modern UX we want:

  • Range slider for price (noUiSlider recommended)
  • Multi-select checkboxes for attributes like color/feature
  • Real-time updates via AJAX (no full page reload) while still keeping filterable URLs shareable

General structure:

  • Layered nav block renders checkboxes and a price slider.
  • When user changes filters, JS builds query string (e.g., ?price=10-100&features=waterproof,lightweight&color[]=red&color[]=blue) and calls a controller or triggers a partial page reload to update the product list and counts.
  • Push the new URL into the browser history (history.pushState) so the page remains linkable.

Sample price slider JS (noUiSlider)

// app/code/Magefine/AdvancedFilters/view/frontend/web/js/price-slider.js
define(['jquery', 'nouislider', 'mage/url'], function ($, noUiSlider, urlBuilder) {
    'use strict';
    return function (config) {
        var slider = document.getElementById(config.sliderId);

        noUiSlider.create(slider, {
            start: [config.min, config.max],
            connect: true,
            range: {
                'min': config.min,
                'max': config.max
            },
            step: config.step || 1
        });

        slider.noUiSlider.on('change', function (values) {
            var min = Math.round(values[0]);
            var max = Math.round(values[1]);

            // Build query string and update via AJAX
            var params = new URLSearchParams(window.location.search);
            params.set('price', min + '-' + max);
            var newUrl = window.location.pathname + '?' + params.toString();

            // Update history and request product list update
            history.pushState({}, '', newUrl);
            $.ajax({
                url: urlBuilder.build('advancedfilters/ajax/update'),
                data: params.toString(),
                type: 'GET'
            }).done(function (html) {
                $('#category-products-list').html(html);
            });
        });
    };
});

In layout, ensure you include the script with appropriate config (min, max, etc.). The advancedfilters/ajax/update controller will render the category product list block only (we'll add that controller later).

4) AJAX controller to return product list HTML

To update the product list without a full reload, create a controller that renders only the product list block and reuses Magento’s catalog block logic.

// app/code/Magefine/AdvancedFilters/Controller/Ajax/Update.php
<?php
namespace Magefine\AdvancedFilters\Controller\Ajax;

use Magento\Framework\App\Action\Action;
use Magento\Framework\App\Action\Context;
use Magento\Catalog\Block\Product\ListProduct;

class Update extends Action
{
    protected $resultPageFactory;

    public function __construct(Context $context, \Magento\Cms\Model\Template\FilterProvider $filterProvider)
    {
        parent::__construct($context);
        $this->filterProvider = $filterProvider;
    }

    public function execute()
    {
        // Note: keep this simple — render the product list via layout handle
        $layout = $this->_objectManager->get('Magento\Framework\View\LayoutInterface');
        $html = $layout->createBlock('Magento\Catalog\Block\Product\ListProduct')->setTemplate('Magento_Catalog::product/list.phtml')->toHtml();
        $this->getResponse()->setBody($html);
    }
}

In reality you’ll want to load the correct category and layered navigation state so the product collection is filtered properly. The simplest approach is to use the current registry category and let Magento layer code apply request filters.

5) Making filters shareable and bookmarkable (URL strategy)

Design your query string convention and keep it predictable:

  • Single-select: ?brand=Sony
  • Multi-select: ?features=waterproof,lightweight or ?color[]=red&color[]=blue
  • Price range: ?price=10-100

When using pushState, always update the query string and allow the server-side filter classes to parse both CSV and array formats. Use SEO-friendly canonical links if you generate many combinations of filters.

6) Performance: optimistic architecture for large catalogs

When you have tens or hundreds of thousands of SKUs, naive queries slow down. Here are practical strategies:

Use Elasticsearch for faceting (recommended)

Magento 2 supports Elasticsearch for catalog search and can return facets (aggregations) for attribute counts. Facets are much faster for large catalogs than SQL aggregate queries. If your store is large, offload filter counts and option lists to Elasticsearch and use the search API to compute counts.

Advantages:

  • Fast facet counting and range aggregations for price
  • Works well with full-text search and filtered queries

Optimize SQL for attribute counts (if not using ES)

If you must rely on MySQL, avoid running separate SELECT COUNT(...) for each filter option on the product collection. Instead use a single aggregated query joining index tables:

SELECT
  eaov.value AS option_id,
  COUNT(DISTINCT cpe.entity_id) AS cnt
FROM catalog_product_entity AS cpe
JOIN catalog_category_product AS ccp ON ccp.product_id = cpe.entity_id AND ccp.category_id = :category_id
JOIN catalog_product_index_eav AS pi ON pi.entity_id = cpe.entity_id AND pi.attribute_id = :attribute_id
JOIN eav_attribute_option_value AS eaov ON eaov.option_id = pi.value
WHERE cpe.type_id = 'simple'
  AND cpe.visibility IN (2,4)
  AND cpe.status = 1
GROUP BY eaov.value

Notes:

  • Magento stores indexed EAV values in tables like catalog_product_index_eav (or specific index tables). Use those to avoid heavy joins to EAV value tables.
  • Filter counts should be computed using these index tables or precomputed into a custom table if your use-case needs extreme speed.

Cache smartly

Layered navigation results change with filters, so full-page cache (FPC) must reflect filter state. Strategies:

  • Let FPC vary by Vary-by-QueryString keys (Magento FPC doesn’t do this by default for every parameter). Instead, render the product list block as a separate AJAX call and keep it cacheable via Varnish with query string handling.
  • Use block cache for layered navigation snippets: compute a cache key that includes category id, user group, applied filters, and currency. That way you can invalidate only the relevant blocks.
  • For frequently changing counts, use TTLs on caches (e.g., 60–300 seconds) instead of invalidating continuously.

Indexing and cron

If you compute filter counts via SQL or a custom index, keep an index updated by cron or via Magento indexer mechanism. Avoid computing heavy aggregates on every page view.

7) Combining filters with Force Product Stock Status

Many stores need to show stock-aware filters — for instance, hide options that result in zero available stock or allow filtering by “In Stock” only.

If you use an extension like Force Product Stock Status (which forces a product to be shown in a desired stock state), you need filters to respect that override. Here are two approaches depending on your stock architecture:

Classic inventory (before MSI)

Magento used cataloginventory_stock_status for stock status. You can join that table to your product collection in the filter apply method:

$collection = $this->getLayer()->getProductCollection();
$collection->getSelect()->joinLeft(
    ['stock' => $collection->getTable('cataloginventory_stock_status')],
    'e.entity_id = stock.product_id AND stock.website_id = ' . (int)$websiteId,
    ['stock.stock_status']
);
$collection->getSelect()->where('stock.stock_status = ?', 1);

If Force Product Stock Status stores forced values in a custom table or an attribute, join or filter by those values instead.

MSI (Multi-Source Inventory)

In Magento 2.3+ MSI, stock status lives behind stock index tables per stock (inventory_stock_1 etc.). The recommended way is to use stock-aware filters by using StockRegistry or the stock indexer APIs or join the appropriate inventory_stock_* view (these are created as virtual tables in DB).

// Example: join the inventory stock table (auto-created view)
$collection->getSelect()->joinLeft(
    ['is' => $collection->getTable('inventory_stock_' . $stockId)],
    'e.sku = is.product_sku',
    ['is.stock_status']
);
$collection->getSelect()->where('is.stock_status = 1');

With Force Product Stock Status, if the extension changes stock visibility via a product attribute (eg: forced_in_stock) then you should make sure your filters also consider that attribute when computing counts and filtering the collection.

8) Example: filter apply with stock awareness and multi-select

// simplified example inside your Attribute filter apply()
$value = $this->getRequest()->getParam($this->getRequestVar());
if (!$value) { return $this; }
$vals = is_array($value) ? $value : explode(',', $value);
$collection = $this->getLayer()->getProductCollection();

// join stock index (MSI or legacy) — pseudo
$collection->getSelect()->joinLeft(
    ['stock' => $collection->getTable('cataloginventory_stock_status')],
    'e.entity_id = stock.product_id AND stock.website_id = ' . (int)$websiteId,
    []
);

// If Force Product Stock Status introduced a special attribute/flag, join that data as well
// For performance, use indexed table if available

$collection->addAttributeToFilter(
    $this->getAttributeModel()->getAttributeCode(),
    ['in' => $vals]
)->getSelect()->where('stock.stock_status = ?', 1);

The important part is that stock filtering happens at the DB level (joins/where) so counts are accurate and fast.

9) Cache keys and invalidation

When caching filter blocks, include these pieces in the cache key:

  • category_id
  • currency
  • store_id
  • applied_filters (sorted and normalized)
  • customer_group_id (if prices/visibility vary)

Example block cache key method:

public function getCacheKeyInfo()
{
    $categoryId = $this->getLayer()->getCurrentCategory()->getId();
    $applied = $this->getRequest()->getQueryValue();
    ksort($applied);
    return [
        'MAGEFINE_ADV_FILTERS',
        $categoryId,
        $this->_storeManager->getStore()->getId(),
        $this->_customerSession->getCustomerGroupId(),
        sha1(json_encode($applied))
    ];
}

Use a short TTL and make sure to flush or update caches on reindex or stock status changes.

10) Putting it all together — a recommended implementation path

  1. Start with attribute creation and ensure options exist. Make sure attributes are set to "Use in Layered Navigation" in admin or via your setup script.
  2. Implement multi-select support by extending the attribute filter class and parsing array/CSV params.
  3. Build the front-end: checkboxes for multi-select fields and noUiSlider for price. Handle changes in JS and pushState to make URLs shareable.
  4. Create an AJAX controller that renders the product list block. Keep it lightweight and cacheable via Varnish or server-side caches.
  5. For counts and option lists, prefer Elasticsearch facets. If you cannot use ES, build a custom index table updated by cron to store counts per category/attribute/option, then read from it quickly.
  6. Integrate stock status by joining the stock index tables (legacy or MSI) and integrate Force Product Stock Status logic so forced stock overrides are honored when computing counts and filtering the product collection.
  7. Carefully craft cache keys for layered navigation blocks and give them sensible TTLs to keep DB load low.

11) Extra tips and pitfalls

  • Don’t run collection->load() during count calculation. Use direct SQL aggregates or search engine faceting.
  • Be careful with regex parsing of URL params — normalize values to avoid duplicate cache entries for equivalent filters (e.g., both ?color[]=a&color[]=b and ?color=b,a).
  • Test with a cold cache and a warm cache. Some logic may look fast on a developer machine but blow up under production load.
  • Use Magento’s indexers and avoid doing heavy operations on page requests. If necessary, precompute counts and store them in a compact index table keyed by category/attribute/option.

12) SEO and UX considerations

Keep URLs canonical and paginated. When filters create many combinations, implement canonical tags to avoid thin duplicate pages and use robots meta tags for parameters you don’t want indexed. However, user-driven filters that match real shopping behavior (like “red + size M”) are often valuable pages to keep indexable. Analyze and decide which parameter combinations deserve indexing.

Make sure your AJAX updates still allow crawlers to access the full content — server-side rendering fallback is still useful for SEO.

13) Example: full flow recap with files to add

Files we added or modified in this guide:

  • app/code/Magefine/AdvancedFilters/registration.php
  • app/code/Magefine/AdvancedFilters/etc/module.xml
  • app/code/Magefine/AdvancedFilters/Setup/InstallData.php (add attributes)
  • app/code/Magefine/AdvancedFilters/etc/di.xml (preference for attribute filter)
  • app/code/Magefine/AdvancedFilters/Model/Layer/Filter/Attribute.php (multi-select logic)
  • app/code/Magefine/AdvancedFilters/view/frontend/web/js/price-slider.js
  • app/code/Magefine/AdvancedFilters/Controller/Ajax/Update.php
  • Layout xml adjustments to include our JS and to mark product list as updatable

14) Final checklist before going live

  • Are attribute options indexed or available in Elasticsearch?
  • Does stock-aware filtering properly reflect Force Product Stock Status overrides?
  • Are cache keys deterministic and do they include applied filters?
  • Have you tested performance with representative catalog size and traffic?
  • Have you implemented pushState so URLs are shareable and bookmarkable?

Summary

Building an advanced filters module in Magento 2 is both a front-end and back-end task. The trick is to re-use Magento’s layered navigation concepts where possible (attributes, layer, product collection) and extend only the pieces you need (multi-select filter class, AJAX UI, price slider). For large catalogs, favor Elasticsearch for faceting and keep a clear caching strategy. And if you use Force Product Stock Status, make sure your filters join the proper stock index tables or attribute overrides so counts and availability reflect reality.

If you want, I can:

  • Generate the full module zip with all the files I showed (boilerplate + working examples)
  • Provide a deeper walk-through on Elasticsearch facet implementation and mapping
  • Show how to adapt the module for MSI-based stock tables

Want me to generate the full module code ready to drop into app/code/?


Written for Magefine — focused on extensions and hosting for Magento 2 stores. Practical, performance-aware tips to ship a working Advanced Filters experience for category pages.