Comment créer un module de filtres avancés personnalisé pour les pages catégories de Magento 2
Intro — why custom advanced filtres?
If you’ve ever spent time tweaking Magento 2 layered navigation for a large catalog, you know the default filtres peut être limiting: single-select attributes, clunky prix UIs, and filtre 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 page de catégories. 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 attribut produits and integnote with Magento’s native filtre system (layered navigation)
- UX/UI: prix range sliders, multi-select filtres and real-time (AJAX) recherche updates
- Performance: query stratégie, caching conseils, and using Elasticrecherche for faceting
- Integration with Force Product Stock Status: combining stock state with filtres
- Step-by-étape code exemples (module skeleton, attribute creation, filtre class, JS and contrôleur)
Module aperçu and design decisions
High level, the module will do three things:
- Create and expose custom attribut produits configured to work with layered navigation (including multi-select support).
- Override/extend Magento’s filtre models where necessary to support multi-select and range UI behavior.
- Provide an AJAX front-end to apply filtres without a full page reload and make sure everything is cache-friendly.
Why not replace Magento’s layered navigation entirely? Parce que Magento already handles a lot (filtre persistence in URLs, product collections, indexeurs). We’ll hook into its architecture and extend the pieces we need.
Module skeleton — the fichiers you need
Create a module called Magefine_AdvancedFilters (use your vendor name). Basic fichiers:
// 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 fichiers as we go (di.xml, Setup/UpgradeData, filtre classes, contrôleurs, JS).
1) Creating attributs personnalisés that work with layered navigation
Magento's layered navigation relies on product EAV attributes. The important flags are:
- is_filtreable — makes the attribute filtreable in layered navigation (1 or 2)
- is_filtreable_in_recherche — filtre available in recherche results
- used_in_product_listing / used_for_promo_rules — optional but useful
- frontend_input — select or multiselect for faceted filtres
Example: add a multi-select attribute called fonctionnalités that clients 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 attribut produit sets.
2) Integnote with Magento native filtres — when to extend
Magento’s standard attribute filtre (
\Magento\Catalog\Model\Layer\Filter\Attribute
) expects single-valeur behavior. For multi-select filtres we need to extend the class to read tableaus from request params and apply mulconseille OR/AND logic to the collection.
Approach:
- Create a preference in di.xml so our class replaces the core attribute filtre.
- Override the apply() and getItemsData() méthodes to read mulconseille valeurs 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 Elasticrecherche 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 prix filtre class, but its UI is server-driven par défaut. For a modern UX we want:
- Range slider for prix (noUiSlider recommended)
- Multi-select checkboxes for attributes like color/fonctionnalité
- Real-time updates via AJAX (no full page reload) while still keeping filtreable URLs shareable
General structure:
- Layered nav block renders checkboxes and a prix slider.
- When utilisateur changes filtres, JS builds query chaîne (e.g., ?prix=10-100&fonctionnalités=waterproof,lightweight&color[]=red&color[]=blue) and calls a contrôleur or triggers a partial page reload to update the product list and counts.
- Push the new URL into the bligneser history (history.pushState) so the page remains linkable.
Sample prix 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 contrôleur will render the category product list block only (we'll add that contrôleur later).
4) AJAX contrôleur to return product list HTML
To update the product list without a full reload, create a contrôleur 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 filtreed properly. The simplest approche is to use the current registry category and let Magento layer code apply request filtres.
5) Making filtres shareable and bookmarkable (URL stratégie)
Design your query chaîne convention and keep it predictable:
- Single-select: ?brand=Sony
- Multi-select: ?fonctionnalités=waterproof,lightweight or ?color[]=red&color[]=blue
- Price range: ?prix=10-100
When using pushState, always update the query chaîne and allow the server-side filtre classes to parse both CSV and tableau formats. Use SEO-friendly canonical links if you generate many combinations of filtres.
6) Performance: optimistic architecture for large catalogs
Quand vous have tens or hundreds of thousands of SKUs, naive queries slow down. Voici practical strategies:
Use Elasticrecherche for faceting (recommended)
Magento 2 supports Elasticrecherche for catalog recherche 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 filtre counts and option lists to Elasticrecherche and use the recherche API to compute counts.
Advantages:
- Fast facet counting and range aggregations for prix
- Works well with full-text recherche and filtreed queries
Optimize SQL for attribute counts (if not using ES)
Si vous must rely on MySQL, avoid running separate SELECT COUNT(...) for each filtre 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 valeurs in tables like
catalog_product_index_eav(or specific index tables). Use those to avoid heavy joins to EAV valeur tables. - Filter counts devrait être 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 filtres, so full-page cache (FPC) must reflect filtre state. Strategies:
- Let FPC vary by Vary-by-QueryString clés (Magento FPC doesn’t do this par défaut for every paramètre). Instead, render the product list block as a separate AJAX call and keep it cacheable via Varnish with query chaîne handling.
- Use block cache for layered navigation snippets: compute a cache clé that includes category id, utilisateur group, applied filtres, and currency. That way you can invalidate only the relevant blocks.
- For frequently changing counts, use TTLs on caches (e.g., 60–300 seconds) au lieu de invalidating continuously.
Indexing and cron
Si vous compute filtre counts via SQL or a custom index, keep an index updated by cron or via Magento indexeur mechanism. Avoid computing heavy aggregates on every page view.
7) Combining filtres with Force Product Stock Status
Many stores need to show stock-aware filtres — for instance, hide options that result in zero available stock or allow filtreing by “In Stock” only.
Si vous use an extension like Force Product Stock Status (which forces a product to be shown in a desired stock state), you need filtres to respect that override. Voici two approchees depending on your stock architecture:
Classic inventaire (before MSI)
Magento used cataloginventory_stock_status for stock status. Vous pouvez join that table to your product collection in the filtre apply méthode:
$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 valeurs in a custom table or an attribute, join or filtre by those valeurs instead.
MSI (Multi-Source Inventory)
In Magento 2.3+ MSI, stock status lives behind stock index tables per stock (inventaire_stock_1 etc.). The recommended way is to use stock-aware filtres by using StockRegistry or the stock indexeur APIs or join the appropriate inventaire_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 attribut produit (eg: forced_in_stock) then you should make sure your filtres also consider that attribute when computing counts and filtreing the collection.
8) Example: filtre 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 filtreing happens at the DB level (joins/where) so counts are accurate and fast.
9) Cache clés and invalidation
When caching filtre blocks, include these pieces in the cache clé:
- category_id
- currency
- store_id
- applied_filtres (tried and normalized)
- client_group_id (if prixs/visibility vary)
Example block cache clé méthode:
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 réindexer or stock status changes.
10) Putting it all together — a recommended implémentation path
- Start with attribute creation and ensure options exist. Assurez-vous attributes are set to "Use in Layered Navigation" in admin or via your script de setup.
- Implement multi-select support by extending the attribute filtre class and parsing tableau/CSV params.
- Build the front-end: checkboxes for multi-select champs and noUiSlider for prix. Handle changes in JS and pushState to make URLs shareable.
- Create an AJAX contrôleur that renders the product list block. Keep it lightweight and cacheable via Varnish or server-side caches.
- For counts and option lists, prefer Elasticrecherche facets. Si vous cannot use ES, build a custom index table updated by cron to store counts per category/attribute/option, then read from it quickly.
- 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 filtreing the product collection.
- Carefully craft cache clés for layered navigation blocks and give them sensible TTLs to keep DB load low.
11) Extra conseils and pitfalls
- Don’t run collection->load() during count calculation. Use direct SQL aggregates or moteur de recherche faceting.
- Be careful with regex parsing of URL params — normalize valeurs to avoid duplicate cache entries for equivalent filtres (e.g., both
?color[]=a&color[]=band?color=b,a). - Test with a cold cache and a warm cache. Some logic may look fast on a développeur machine but blow up under production load.
- Use Magento’s indexeurs and avoid doing heavy operations on page requests. If necessary, precompute counts and store them in a compact index table cléed by category/attribute/option.
12) SEO and UX considerations
Keep URLs canonical and paginated. When filtres create many combinations, implement canonical tags to avoid thin duplicate pages and use robots meta tags for paramètres you don’t want indexed. Cependant, utilisateur-driven filtres that match real shopping behavior (like “red + size M”) are often valuable pages to keep indexable. Analyze and decide which paramètre combinations deserve indexation.
Assurez-vous your AJAX updates still allow crawlers to access the full contenu — server-side rendering fallback is still useful for SEO.
13) Example: full flow recap with fichiers 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 filtre)
- app/code/Magefine/AdvancedFilters/Model/Layer/Filter/Attribute.php (multi-select logic)
- app/code/Magefine/AdvancedFilters/view/frontend/web/js/prix-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 Elasticrecherche?
- Does stock-aware filtreing properly reflect Force Product Stock Status overrides?
- Are cache clés deterministic and do they include applied filtres?
- Have you tested performance with representative catalog size and traffic?
- Have you implemented pushState so URLs are shareable and bookmarkable?
Résumé
Building an advanced filtres 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 filtre class, AJAX UI, prix slider). For large catalogs, favor Elasticrecherche for faceting and keep a clear caching stratégie. And if you use Force Product Stock Status, make sure your filtres join the proper stock index tables or attribute overrides so counts and availability reflect reality.
Si vous want, I can:
- Generate the full module zip with all the fichiers I showed (boilerplate + working exemples)
- Provide a deeper walk-through on Elasticrecherche facet implémentation and mapping
- Show comment 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 conseils to ship a working Advanced Filters experience for page de catégories.