On Sale Layered Navigation Filter for Magento 2
Let shoppers narrow any category or search result to discounted products in one click. A fast, indexer-driven On Sale layered-navigation filter that respects catalog price rules, special prices, tier prices, dated discounts, and customer-group pricing - with parent aggregation...
Key Features:
- Shop By -> Sale Status option in layered navigation on ca...
- "On Sale" and "Regular" options with configurable labels ...
- Real counts next to each option (`On Sale (12)`) that exa...
- Stock-aware counts
Additional Services
Everything in the box
Built-in from day one. No add-ons, no upsell, no licence keys to renew.
"On Sale" and "Regular" options with configurable labels ...
Real counts next to each option (`On Sale (12)`) that exa...
scoped to the current category + visibility, never a global store-wide tally
Stock-aware counts
when Display Out of Stock Products is No (Magento default), the `(N)` excludes OOS rows; when Yes, it includes them. Works identically in both modes.
Overview
Panth Sale Filter is an indexer-driven layered-navigation extension for Magento 2 and Adobe Commerce that adds a one-click On Sale filter to every category page and search result.
The Panth Sale Filter module lets shoppers narrow any catalog list to discounted-only or regular-price-only products with a single click, and every option shows a live count (for example, On Sale (12)) scoped to the current category and visibility rules — never a global tally. Discount detection is driven by a dedicated indexer (panth_salefilter_product) that evaluates each product, customer group, and website to resolve the effective sale status.
The sale filter respects every Magento 2 pricing source merchants already use: catalog price rules with scheduled from_date and to_date windows, per-product special prices, tier prices that fall below regular, and customer-group-specific prices. Parent aggregation flags a configurable, grouped, or bundle product as On Sale as soon as any eligible child qualifies, so composite catalogs behave correctly.
Dated discounts flip automatically via Magento's built-in catalogrule_apply_all nightly cron — no custom cron of its own. Ships with a Luma template; install the companion Hyvä module for an Alpine.js + Tailwind storefront.
Best for:
- Promotion-heavy Magento 2 catalogs that rely on catalog price rules and dated discounts
- B2B and B2C stores using customer-group pricing or tier prices that need correct sale detection
- Merchants who want a fast, FPC-friendly sale filter that survives Hyvä and Luma deployments
Why a Sale Filter
Magento's stock layered navigation can filter by price range or attribute, but there's no out-of-the-box way to say "show me only the products currently on sale" across catalog rules, special prices, and tier prices in one option. Common workarounds (dedicated Sale category, custom attribute flag) fall apart the moment:
- A catalog rule is dated (flips on/off at midnight)
- A special price expires and the product is no longer on sale
- A configurable's children have varying discount state
- A customer group has its own pricing tier
Panth Sale Filter fixes this with a dedicated indexer that resolves the effective sale status per (product, customer group, website) and writes it to a flat table the layered-nav plugin can hit in microseconds. Everything stays accurate as rules come online, expire, or are re-applied — automatically.
What you get
Panth Sale Filter ships every signal a discount-aware storefront needs out of the box:
- One-click On Sale / Regular Price layered-navigation filter with live per-category counts
- Indexer-driven discount detection across catalog rules, special prices, tier prices, and group prices
- Parent aggregation so configurable, grouped, and bundle products surface when any child is on sale
- FPC-safe caching keyed per customer group, category, and filter state
- Admin index grid, CLI reindex command, and URL parameter support for shareable sale links
Key Features
Storefront
- Shop By → Sale Status option in layered navigation on category AND search-result pages
- "On Sale" and "Regular" options with configurable labels per store view
- Real counts next to each option (
On Sale (12)) that exactly match the grid total post-click — scoped to the current category + visibility, never a global store-wide tally - Stock-aware counts — when Display Out of Stock Products is No (Magento default), the
(N)excludes OOS rows; when Yes, it includes them. Works identically in both modes. - Cross-filter accurate — when Brand = Nike, Pattern, Color, Size, or Price range is already active, the "On Sale" count shows the intersection, not the category-wide total. Super-attribute filters on configurables (color / pattern / size on variants) resolve via
catalog_product_index_eav+catalog_product_super_linkso configurable parents matching at least one on-attribute child are counted. - Accurate pager totals —
Items 1-12 of 24reflects the post-filter result even under the Elasticsearch-backedFulltext\Collection - Sort-aware — price asc/desc, name asc/desc, position — all honoured while the filter is active
- "Now Shopping by" chip with a one-click clear, integrated with Magento's standard active-filter UI
- Pagination-safe — filter state is preserved across
?p=2,?product_list_limit=24, and any sort dropdown; sidebar count is invariant across pages
Discount detection
- Catalog price rules — all operators (by_percent, by_fixed, to_percent, to_fixed), dated rules, priority order
- Per-product special prices — with
special_from_dateandspecial_to_dateawareness - Tier prices that fall below the regular price
- Customer-group-specific pricing — NOT LOGGED IN, General, Wholesale, Retailer, or any custom group
- Parent aggregation — a configurable, grouped, or bundle is On Sale as soon as any eligible child is
- All product types — simple, configurable, grouped, bundle, virtual, downloadable
Indexer
- Dedicated indexer
panth_salefilter_productappearing in System → Tools → Index Management - MView subscriptions on
catalog_product_entity_decimal,catalog_product_entity_datetime,catalogrule_product_price,catalog_product_relation,catalog_product_super_link,catalog_product_bundle_selection - Two index modes — Update by Schedule (default, cron-driven, recommended for production) and Update on Save (synchronous, great for staging and debugging)
- Dated-discount aware — flips automatically at discount start/end time via Magento's nightly
catalogrule_apply_allcron, no custom cron needed
Admin & Ops
- Admin index grid at System → Panth Infotech → Sale Filter Index (UI component, filters, column chooser, export)
- Columns: product id, SKU, type, website, customer group, regular price, special price, is-on-sale, updated-at, rule price, discount %, active catalog rules, match source (
Catalog Rule/Special Price/Both) - CLI helpers —
bin/magento panth_salefilter:reindexandpanth_salefilter:status - Cache-friendly — invalidates
panth_salefilter,block_html, andfull_pagetags on every change
Quality & Compliance
- MEQP-compliant — passes Adobe's Magento Extension Quality Program with zero severity-10 violations
- Declarative schema (
db_schema.xml+ whitelist) - Zero third-party PHP dependencies — uses only Magento framework classes
- PHPDoc + strict types throughout
How It Works
- Indexer
Panth\SaleFilter\Model\Indexer\ProductIndexerwalks every product × customer-group × website, resolves the effective "is on sale" flag (catalog-rule price vs special price vs regular), and writes a row intopanth_salefilter_product_index. - MView subscriptions on the relevant upstream tables keep the index fresh without a cron run — product saves, rule re-applies, price changes all propagate through the changelog.
- Layered-navigation plugin runs
afterGetProductCollectiononCatalog\Model\Layer\CategoryandCatalog\Model\Layer\Search. It intersects the index with the current category + visibility, stashes the ordered id list on the collection, and swaps Magento'sSearchResultApplierfor a filter-aware variant so the ES page slice is taken from the filtered list rather than narrowed by it after the fact. getSize()plugin returns the pre-computed post-filter count so the toolbar pager showsN of true-total, notN of unfiltered.
Caching — Per Customer Group, Per Category, Per Filter State
The "On Sale" filter shows different counts to different customer groups (a Wholesale customer's discount is not a Retailer's discount). Cached naively, the first visitor's view would be served to everyone — wrong counts, missing options. This module solves it with two well-known Magento hooks plus a multi-frontend tag invalidator.

The smart label — Block/LayeredNavigation/FilterRenderer.php
FilterRenderer extends Template and implements IdentityInterface. Two methods do all the work:
getCacheKeyInfo()— returns an array Magento hashes into the block-cache key. We mix in store id, website id, customer group, currency, current category id, and the activesale_filterURL param. Each unique combination gets its own cached HTML fragment, so a Guest never sees a Wholesale-warmed render.getIdentities()— returns the cache tags stamped on every cached entry:cat_p(catalog product) andpanth_salefilter(our own). Whenever a product or catalog rule changes, we clean by these tags and only matching entries are evicted — surrounding pages stay warm.
Critical: customer group is read from
HttpContext, notCustomerSession. Magento'sDepersonalizePluginwipes the session to guest before cacheable blocks render, so the session would always lie.HttpContextis the only safe source.
The trigger — Observer/CatalogRuleSaveAfter.php
Wired in etc/events.xml to four events:
catalogrule_rule_save_commit_after/catalogrule_rule_delete_commit_aftercatalog_product_save_after/catalog_product_delete_after
We listen on _save_commit_after (not _save_after) for rules because Magento's catalog-rule save runs a commit callback that rebuilds catalogrule_product_price after the transaction. Listening earlier would race the rebuild and reindex against stale data.
The observer reindexes (in realtime mode only — schedule mode lets cron catch up) and then calls the cache invalidator unconditionally.
The cleaner — Model/Cache/TagInvalidator.php
Why a dedicated class instead of CacheInterface::clean()? Because some installs put the default and page_cache (FPC) frontends on different Redis databases. CacheInterface::clean() only touches the default frontend → FPC stays stale. Cleaning by cache type (full_page) is the opposite mistake — it nukes every FPC entry in the store and tanks hit rate.
TagInvalidator::invalidate() iterates Cache\Frontend\Pool (which enumerates every configured frontend) and calls clean(MATCHING_ANY_TAG, [cat_p, panth_salefilter]) on each. Surgical, safe across split Redis setups, and defensive — a single backend failure never stops the others.
The Magento 2.4.7 FPC fix — Plugin/Framework/App/PageCache/IdentifierGroupAwarePlugin.php
Magento 2.4.7 moved FPC identifier logic to IdentifierForSave, which keys only on $context->getVaryString(). That string is empty at LOAD time because the customer ContextPlugin runs on beforeExecute (during dispatch) while FPC load happens earlier in aroundDispatch. Net effect: whichever user warms the cache for a URL dictates what every other user sees on the built-in FPC. A guest-warmed category page hides the "Yes" option for logged-in General / Wholesale / Retailer shoppers.
This plugin's aroundGetValue() reads X-Magento-Vary straight off the incoming request cookie (the pre-2.4.7 behavior) and mixes it into the cache key, so each group ends up with its own FPC entry. The cookie is stable from request arrival to response dispatch, so LOAD and SAVE produce the same key within a single request.
Wired in etc/di.xml against both Identifier and IdentifierForSave because Magento injects them separately for load vs save — patching only one gives mismatched keys and zero cache hits.
Reading order
| File | What it does |
|---|---|
Block/LayeredNavigation/FilterRenderer.php |
Smart cache key (getCacheKeyInfo) + identity tags (getIdentities) |
etc/events.xml |
Subscribe observer to product / rule save / delete events |
Observer/CatalogRuleSaveAfter.php |
Reindex + call the invalidator |
Model/Cache/TagInvalidator.php |
Walk every cache frontend, clean by tag |
Plugin/Framework/App/PageCache/IdentifierGroupAwarePlugin.php |
Restore cookie-aware FPC keying on Magento 2.4.7+ |
Admin Index Grid
System → Panth Infotech → Sale Filter Index — a UI-component grid over panth_salefilter_product_index. Useful for debugging a specific product or diffing the catalog after a rule change.

| Column | Description |
|---|---|
| Product ID | Magento entity id |
| SKU | Product SKU |
| Type | simple / configurable / grouped / bundle / virtual / downloadable |
| Website | Website code + id |
| Customer Group | Group name + id |
| Regular Price | Pre-discount price |
| Special Price | Configured special price (if any) |
| On Sale | Yes / No — the effective sale state |
| Updated At | Last indexer write timestamp |
| Rule Price | Final price after the best applicable catalog rule |
| Discount % | Relative discount vs regular price |
| Active Catalog Rules | Comma list of matching rules |
| Match Source | Catalog Rule · Special Price · Both |
Grid filters include on-sale only, match source, and an applicable rules text filter.
Indexing
panth_salefilter_product appears in System → Tools → Index Management:

Modes
- Update by Schedule (default) — MView changelog captures changed product ids, Magento's
indexer_update_all_viewscron (runs every 1 minute by default) processes them. Recommended for production. - Update on Save — observers fire
reindexRowinline on every relevant save. More DB writes during imports, but the storefront reflects changes instantly. Great for staging and debugging.
Switch modes from the Index Management grid (Actions → Update Mode) or via CLI:
bin/magento indexer:set-mode schedule panth_salefilter_product
bin/magento indexer:set-mode realtime panth_salefilter_product
Dated Discounts — When Does the Index Refresh?
Our indexer is event-driven, not polling. Three triggers keep it fresh:
1. Immediate — save observers
When you save a product or a catalog rule, CatalogRuleSaveAfter fires:
- Update on Save → calls
$indexer->reindexRow($productId)synchronously in the same request - Update by Schedule → the MView framework writes the changed ids into
panth_salefilter_product_cl
2. MView changelog + Magento's indexer cron
The mview.xml subscription tracks:
catalog_product_entity_decimal(special_price, price)catalog_product_entity_datetime(special_from_date, special_to_date)catalogrule_product_price(rule-computed per-product prices)catalog_product_relation+catalog_product_super_link+catalog_product_bundle_selection(child/parent links)
Magento's indexer_update_all_views cron runs every 1 minute. In Update by Schedule mode, our indexer catches up to any change within ~60 seconds.
3. The critical piece — daily catalog-rule refresh
This is what handles dated discounts. Magento ships a cron job catalogrule_apply_all (from Magento\CatalogRule\Cron\DailyCatalogUpdate) configured to run every day at midnight:
<!-- vendor/magento/module-catalog-rule/etc/crontab.xml -->
<job name="catalogrule_apply_all" method="execute"
instance="Magento\CatalogRule\Cron\DailyCatalogUpdate">
<schedule>0 0 * * *</schedule>
</job>
It:
- Recomputes
catalogrule_product_pricefor the current date — rules whose window starts today come online, rules whose window ended yesterday drop out. - Fires
catalogrule_after_apply— our observer catches this (realtime) or the mview changelog captures thecatalogrule_product_priceinserts (schedule).
The same mechanism handles dated special_price via Magento's catalog_product_price indexer, which is invalidated nightly and rebuilds against today's date.
Concrete timeline example
Create rule "Summer Sale — 25% off" with from_date = 2026-06-01, to_date = 2026-06-30, saved on 2026-04-20.
| Date/Time | What happens |
|---|---|
| 2026-04-20 14:33 | Rule saved. Our observer runs → index built as of 2026-04-20. Rule inactive, products NOT on sale yet. |
| 2026-04-20 14:34 | MView cron ticks — nothing to do, changelog empty. |
| 2026-05-31 23:59 | Last cron of the month — nothing changes, products still not on sale. |
| 2026-06-01 00:00 | catalogrule_apply_all fires. Rule is now active. catalogrule_product_price gets fresh rows. Our mview subscription detects the inserts. |
| 2026-06-01 00:01 | indexer_update_all_views ticks, processes our changelog → products flip to On Sale. |
| 2026-06-01 00:02 | First shopper hits ?sale_filter=1 and sees the newly-discounted products. |
| 2026-07-01 00:00 | catalogrule_apply_all runs. Rule expired. Rows deleted from catalogrule_product_price. Mview captures → products flip back to Regular within a minute. |
Worst-case lag for a discount starting/ending at a specific time: ~1 minute after midnight, bounded by the index cron group's schedule.
Prerequisites
# Magento's own cron must be running
bin/magento cron:install # once, at setup
# OS cron then invokes bin/magento cron:run every minute
If bin/magento cron:run is not firing, nothing time-gated works — not just our module, but all Magento price/rule scheduling. This is a baseline Magento requirement, not something our module adds.
Force an immediate re-check
bin/magento catalog:rule:apply-all # behave as if it's midnight right now
bin/magento indexer:reindex panth_salefilter_product
bin/magento cache:flush
URL Parameters
?sale_filter=1— on-sale only?sale_filter=0— regular-price only (honoured only while Show Not On Sale Option is enabled)
Parameters are preserved across pagination (&p=2), per-page override (&product_list_limit=24), and sort (&product_list_order=price&product_list_dir=desc).
Changelog
1.0.14
- Fix: sidebar "On Sale (N)" now mirrors every grid-applied constraint so the number always equals the post-click grid total. Covers: stock filter (
Display Out of Stock ProductsYes/No), every other active layered-nav filter (Brand, Color, Pattern, Size, Price range, category drill-down), and super-attribute filters on configurable products (resolved viacatalog_product_index_eavexpanded throughcatalog_product_super_link). - Fix:
?pattern=X&sale_filter=1no longer broadens the grid. The plugin'safterGetProductCollectionhook runs BEFORE the layered-navigation block populatesLayer::getState(), so state-based filter mirroring silently missed every sibling filter and the module's customSearchResultApplier(which bypasses ES whenITEMS_FLAGis set) then rendered the full category-wide on-sale set. Plugin now reads active filters from$request->getParams()and resolves each non-reserved key viaEavConfig. - Safety: if a mirrored filter would zero the count (EAV super-attribute values tied to children outside the current category), skip the mirror rather than hide the sidebar option — wider approximate count is strictly better than a missing filter.
- Verified: 56/56 PASS across indexer mode (realtime / schedule) ×
show_out_of_stock(0 / 1) × theme (Hyvä / Luma) × 3 categories × 4 cross-filter combinations (none, color=49, pattern=196, color+pattern) × pagination.
1.0.13
- Fix: stock filter is now honoured in the sidebar count and in the Plugin's
COUNT_FLAG/ITEMS_FLAG. Previously a category with 48 in-stock on-sale items displayed "On Sale (69)" because the count collection included OOS products Magento core had already removed from the grid.
1.0.5
- Docs: complete README rewrite with screenshots, animated admin-configuration GIF, full compatibility matrix, FAQ, and indexer-timing deep dive.
1.0.4
- Fix: Update on Save mode now actually reindexes on product save (previously only flagged the index as stale, so changes weren't visible until someone ran
indexer:reindexmanually).
1.0.3
- Fix: honour storefront sort (position / price asc-desc / name asc-desc) while the sale filter is active. Previously all sort directions returned the same slice in category position order.
1.0.2
- Fix: correct grid + pager total under the ES-backed
Fulltext\Collection. Replaces the defaultSearchResultApplierwith a filter-aware variant; plugsgetSize()to return the post-filter count.
1.0.1
- Fix: count the full category, not just the visible page (toolbar pagination was leaking into the count query).
1.0.0
- Initial release.
Troubleshooting
| Issue | Cause | Resolution |
|---|---|---|
| Filter option doesn't appear in sidebar | Module disabled or indexer empty | bin/magento module:enable Panth_SaleFilter and bin/magento indexer:reindex panth_salefilter_product |
| Counts are wrong / stale | FPC serving old markup | bin/magento cache:clean full_page block_html |
| Dated discount hasn't flipped on/off | Magento cron not running | Verify bin/magento cron:run is scheduled in your OS crontab every minute |
| Sidebar shows Regular option when it shouldn't | "Show Not On Sale Option" is enabled but no regular products exist in scope | Toggle it off in Stores → Configuration → Sale Filter, or verify your category has at least one non-discounted product |
Consumer with the same name is running when starting queue consumer |
Stale MySQL lock from a crashed consumer | Clear stuck locks: TRUNCATE queue_lock; (backup first) or restart MySQL |
Pager total wrong under ES (of 24 with 12 filtered) |
Running < v1.0.2 | Upgrade to ^1.0.2, reindex, flush FPC |
| Sort dropdown ignored with filter active | Running < v1.0.3 | Upgrade to ^1.0.3 |
| Sidebar "On Sale (N)" overcounts vs grid — includes out-of-stock products when Display Out of Stock Products is No | Running < v1.0.13 | Upgrade to ^1.0.14, reindex, flush FPC |
| Sidebar "On Sale (N)" doesn't shift when Brand/Color/Pattern is also active | Running < v1.0.13 | Upgrade to ^1.0.14, reindex, flush FPC |
?pattern=X&sale_filter=1 renders MORE products than ?pattern=X alone |
Running < v1.0.14 (plugin lost ES attribute filter when ITEMS_FLAG took over the Fulltext applier) |
Upgrade to ^1.0.14, reindex, flush FPC |
| "On Sale" sidebar missing entirely on a page with other filters active | Running < v1.0.14 — an EAV-index mirror zeroed out the count | Upgrade to ^1.0.14 (sidebar stays visible with a conservative count when EAV doesn't cover a super-attribute) |
Enable bin/magento deploy:mode:set developer and tail var/log/system.log for diagnostic output.
Compatibility
| Requirement | Versions Supported |
|---|---|
| Magento Open Source | 2.4.4, 2.4.5, 2.4.6, 2.4.7, 2.4.8 |
| Adobe Commerce | 2.4.4, 2.4.5, 2.4.6, 2.4.7, 2.4.8 |
| Adobe Commerce Cloud | 2.4.4 — 2.4.8 |
| PHP | 8.1.x, 8.2.x, 8.3.x, 8.4.x |
| MySQL | 8.0+ |
| MariaDB | 10.4+ |
| Search Engine | Elasticsearch 7/8, OpenSearch 1/2 |
| Hyvä Theme | 1.3+ (via companion module) |
| Luma Theme | Native support |
Tested on:
- Magento 2.4.8-p4 with PHP 8.4 and Elasticsearch 8
- Magento 2.4.7 with PHP 8.3 and OpenSearch 2
- Magento 2.4.6 with PHP 8.2 and Elasticsearch 7
Installation
Composer (Recommended)
composer require mage2kishan/module-sale-filter
bin/magento module:enable Panth_SaleFilter
bin/magento setup:upgrade
bin/magento setup:di:compile
bin/magento indexer:reindex panth_salefilter_product
bin/magento cache:flush
Hyvä storefronts
Also install the companion module — it ships the Alpine.js / Tailwind template and the Hyvä Appearance admin group:
composer require mage2kishan/module-sale-filter-hyva
bin/magento module:enable Panth_SaleFilterHyva
bin/magento setup:upgrade
bin/magento setup:di:compile
bin/magento cache:flush
Manual installation via ZIP
- Download the latest release ZIP from Packagist or GitHub Releases
- Extract to
app/code/Panth/SaleFilter/in your Magento installation - Run the same commands starting from
bin/magento module:enable Panth_SaleFilter
Verify installation
bin/magento module:status Panth_SaleFilter
# Expected: Module is enabled
bin/magento indexer:status panth_salefilter_product
# Expected: Ready / Update by Schedule / idle
After installation, navigate to:
Admin → Stores → Configuration → Panth Extensions → Sale Filter
Configuration
Stores → Configuration → Panth Extensions → Sale Filter
| Field | Default | Description |
|---|---|---|
| Enabled | Yes | Master switch. Off = filter vanishes from layered nav and ?sale_filter=… URL params become no-ops. |
| Filter Title | Sale Status | Heading shown above the options in the sidebar. |
| Option Label — On Sale | On Sale | Label for the discounted option. |
| Show "Not On Sale" Option | No | When on, a second option surfaces so shoppers can toggle to regular-price products. |
| Option Label — Not On Sale | Regular | Label for the regular-price option. |
| Show Product Count | Yes | Toggles the (12) counter next to each option. |
| Include Special Prices | Yes | When off, the indexer ignores per-product special_price. |
| Include Catalog Rules | Yes | When off, the indexer ignores catalog price rules. |
| Filter Position | 100 | Sort order within layered navigation — lower values appear higher in the sidebar. |
All fields are store-scoped — you can set different labels per store view for multi-locale installations.
Uninstall
bin/magento module:disable Panth_SaleFilter
composer remove mage2kishan/module-sale-filter
bin/magento setup:upgrade
The panth_salefilter_product_index table and MView changelog are dropped automatically by setup:upgrade once the module is removed.
License
Proprietary. See LICENSE. Commercial licence granted per Magento installation.
CLI Reference
# Full reindex
bin/magento indexer:reindex panth_salefilter_product
# …or our dedicated command (same effect, friendlier output)
bin/magento panth_salefilter:reindex
# Health check
bin/magento panth_salefilter:status
bin/magento indexer:status panth_salefilter_product
# Switch modes
bin/magento indexer:set-mode schedule panth_salefilter_product
bin/magento indexer:set-mode realtime panth_salefilter_product
# Refresh only catalog rules (as if midnight)
bin/magento catalog:rule:apply-all
# Clear caches tagged by this module
bin/magento cache:clean panth_salefilter
| Module Category | Catalog & Products |
|---|---|
| Best For | All Sizes |
Need this customised?
Talk to Kishan directly — written quote, scope and timeline within 24 hours. No sales call.
On Sale Layered Navigation Filter for Magento 2





