Chat on WhatsApp

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...

Magento 2.4.6–2.4.8 PHP 8.1–8.4 Luma Ready Free

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

$0.00
In stock
SKU
panth-sale-filter
Links
Pay with Wise
Lifetime Updates Every Magento release
1-Year Free Support Email + WhatsApp
Adobe-Certified Magento 2 Developer
Free Forever No subscription, no upsell
What you get

Everything in the box

Built-in from day one. No add-ons, no upsell, no licence keys to renew.

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...

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_link so configurable parents matching at least one on-attribute child are counted.
  • Accurate pager totalsItems 1-12 of 24 reflects the post-filter result even under the Elasticsearch-backed Fulltext\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_date and special_to_date awareness
  • 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_product appearing 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 modesUpdate 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_all cron, 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 helpersbin/magento panth_salefilter:reindex and panth_salefilter:status
  • Cache-friendly — invalidates panth_salefilter, block_html, and full_page tags 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

  1. Indexer Panth\SaleFilter\Model\Indexer\ProductIndexer walks every product × customer-group × website, resolves the effective "is on sale" flag (catalog-rule price vs special price vs regular), and writes a row into panth_salefilter_product_index.
  2. 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.
  3. Layered-navigation plugin runs afterGetProductCollection on Catalog\Model\Layer\Category and Catalog\Model\Layer\Search. It intersects the index with the current category + visibility, stashes the ordered id list on the collection, and swaps Magento's SearchResultApplier for a filter-aware variant so the ES page slice is taken from the filtered list rather than narrowed by it after the fact.
  4. getSize() plugin returns the pre-computed post-filter count so the toolbar pager shows N of true-total, not N 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.

How the cache works

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 active sale_filter URL 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) and panth_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, not CustomerSession. Magento's DepersonalizePlugin wipes the session to guest before cacheable blocks render, so the session would always lie. HttpContext is 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_after
  • catalog_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.

Sale Filter Index grid

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:

Index Management

Modes

  • Update by Schedule (default) — MView changelog captures changed product ids, Magento's indexer_update_all_views cron (runs every 1 minute by default) processes them. Recommended for production.
  • Update on Save — observers fire reindexRow inline 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:

  1. Recomputes catalogrule_product_price for the current date — rules whose window starts today come online, rules whose window ended yesterday drop out.
  2. Fires catalogrule_after_apply — our observer catches this (realtime) or the mview changelog captures the catalogrule_product_price inserts (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 Products Yes/No), every other active layered-nav filter (Brand, Color, Pattern, Size, Price range, category drill-down), and super-attribute filters on configurable products (resolved via catalog_product_index_eav expanded through catalog_product_super_link).
  • Fix: ?pattern=X&sale_filter=1 no longer broadens the grid. The plugin's afterGetProductCollection hook runs BEFORE the layered-navigation block populates Layer::getState(), so state-based filter mirroring silently missed every sibling filter and the module's custom SearchResultApplier (which bypasses ES when ITEMS_FLAG is set) then rendered the full category-wide on-sale set. Plugin now reads active filters from $request->getParams() and resolves each non-reserved key via EavConfig.
  • 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:reindex manually).

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 default SearchResultApplier with a filter-aware variant; plugs getSize() 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

  1. Download the latest release ZIP from Packagist or GitHub Releases
  2. Extract to app/code/Panth/SaleFilter/ in your Magento installation
  3. 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

More Information
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.

WhatsApp

On Sale Layered Navigation Filter for Magento 2

$0.00
Step up

Customers usually upgrade to