Create a custom layered navigation filter for filtering sale products in Magento

NavigationRetail store project I'm currently involved with heavily relies on Magento catalog price rules to offer pre-cart discounts on selected articles. Even though Magento CatalogRule module does most heavy lifting when it comes to placing products on sale, code allowing logical organization and filtering for sale products at store frontend is left to the solution provider's imagination. In this article I'll cover the filtering part of this scenario, specifically how to make it possible for customer to filter sale and not on sale products while browsing category view pages. There are couple of ways to tackle this problem, with creating custom layered navigation filter being probably the most logical approach.

If you take a few minutes to review the layered navigation code, you'll come to conclusion that default Magento installation has several layered navigation filter types implemented, namely category, attribute, price and decimal. Before digging deeper here, I must first describe high level organization of layered navigation code.

Overall frontend logic for layered navigation is contained inside Mage_Catalog_Block_Layer_View class, while filter type specific code is a part of classes corresponding to filter types and extending Mage_Catalog_Block_Layer_Filter_Abstract class. Overal backend logic is contained inside Mage_Catalog_Model_Layer class, with type specific filter code being inside corresponding models extending from Mage_Catalog_Model_Layer_Filter_Abstract.

You've probably noticed the separation of concerns principle in action, so you'll also probably guess that implementing custom layered navigation filter type isn't that hard. Thats true for the most part, although removing clumsiness from some chunks of layered navigation code would definitely make this job even easier.

Roughly speaking, in order to implement custom layered navigation filter type you must create corresponding block, model and then inform Mage_Catalog_Block_Layer_View about your new layered navigation filter type. First things first, here's our block implementing frontend logic for sale filter:

<?php
/* app/code/community/Inchoo/Sale/Block/Catalog/Layer/Filter/Sale.php */
 
class Inchoo_Sale_Block_Catalog_Layer_Filter_Sale 
extends Mage_Catalog_Block_Layer_Filter_Abstract
{
 
    public function __construct()
    {
        parent::__construct();
        $this->_filterModelName = 'inchoo_sale/catalog_layer_filter_sale';
    }
 
}

Not much to see here, we just inform the abstract class code about our layered navigation model name so lets proceed to more interesting code. Here's the corresponding Magento model:

<?php
/* app/code/community/Inchoo/Sale/Model/Catalog/Layer/Filter/Sale.php */
 
class Inchoo_Sale_Model_Catalog_Layer_Filter_Sale 
extends Mage_Catalog_Model_Layer_Filter_Abstract
{
 
    const FILTER_ON_SALE = 1;
    const FILTER_NOT_ON_SALE = 2;
 
    /**
     * Class constructor
     */
    public function __construct()
    {
        parent::__construct();
        $this->_requestVar = 'sale';
    }
 
    /**
     * Apply sale filter to layer
     *
     * @param   Zend_Controller_Request_Abstract $request
     * @param   Mage_Core_Block_Abstract $filterBlock
     * @return  Mage_Catalog_Model_Layer_Filter_Sale
     */
    public function apply(Zend_Controller_Request_Abstract $request, $filterBlock)
    {
        $filter = (int) $request->getParam($this->getRequestVar());
        if (!$filter || Mage::registry('inchoo_sale_filter')) {
            return $this;
        }
 
        $select = $this->getLayer()->getProductCollection()->getSelect();
        /* @var $select Zend_Db_Select */
 
        if ($filter == self::FILTER_ON_SALE) {
            $select->where('price_index.final_price < price_index.price');
            $stateLabel = Mage::helper('inchoo_sale')->__('On Sale');
        } else {
            $select->where('price_index.final_price >= price_index.price');
            $stateLabel = Mage::helper('inchoo_sale')->__('Not On Sale');
        }
 
        $state = $this->_createItem(
            $stateLabel, $filter
        )->setVar($this->_requestVar);
        /* @var $state Mage_Catalog_Model_Layer_Filter_Item */
 
        $this->getLayer()->getState()->addFilter($state);
 
        Mage::register('inchoo_sale_filter', true);
 
        return $this;
    }
 
    /**
     * Get filter name
     *
     * @return string
     */
    public function getName()
    {
        return Mage::helper('inchoo_sale')->__('Sale');
    }
 
    /**
     * Get data array for building sale filter items
     *
     * @return array
     */
    protected function _getItemsData()
    {
        $data = array();
        $status = $this->_getCount();
 
        $data[] = array(
            'label' => Mage::helper('inchoo_sale')->__('On Sale'),
            'value' => self::FILTER_ON_SALE,
            'count' => $status['yes'],
        );
 
        $data[] = array(
            'label' => Mage::helper('inchoo_sale')->__('Not On Sale'),
            'value' => self::FILTER_NOT_ON_SALE,
            'count' => $status['no'],
        );
        return $data;
    }
 
    protected function _getCount()
    {
        // Clone the select
        $select = clone $this->getLayer()->getProductCollection()->getSelect();
        /* @var $select Zend_Db_Select */
 
        $select->reset(Zend_Db_Select::ORDER);
        $select->reset(Zend_Db_Select::LIMIT_COUNT);
        $select->reset(Zend_Db_Select::LIMIT_OFFSET);
        $select->reset(Zend_Db_Select::WHERE);
 
        // Count the on sale and not on sale
        $sql = 'SELECT IF(final_price >= price, "no", "yes") as on_sale, COUNT(*) as count from ('
                .$select->__toString().') AS q GROUP BY on_sale';
 
        $connection = Mage::getSingleton('core/resource')->getConnection('core_read');
        /* @var $connection Zend_Db_Adapter_Abstract */
 
        return $connection->fetchPairs($sql);
    }
 
}

We have several functions here, with Inchoo_Sale_Model_Catalog_Layer_Filter_Sale::apply() being the most important. This function does the actual filtering by modifying product collection's where clause. As you can see we apply different limitations to select depending on whether sale only, or not on sale filter is applied. Second important function is Inchoo_Sale_Model_Catalog_Layer_Filter_Sale::_getCount() that uses little bit of MySQL aggregate magic to retrieve on sale and not on sale products count for displaying next to our filter labels.

Next, we move on to Mage_Catalog_Block_Layer_View business. In order to apply following code, you can rewrite this class, or even better just extend it and replace Magento built in navigation block with your own using layout XML where necessary. If your site has an area that displays only sale products, you'll most definitely won't need this filter shown there, because of that I advise you to avoid rewriting the original Magento block. So here's the code:

<?php
/* app/code/community/Inchoo/Sale/Block/Catalog/Layer/View.php */
 
class Inchoo_Sale_Block_Catalog_Layer_View 
extends Mage_Catalog_Block_Layer_View
{
    const SALE_FILTER_POSITION = 2;
 
    /**
     * State block name
     *
     * @var string
     */
    protected $_saleBlockName;
 
    /**
     * Initialize blocks names
     */
    protected function _initBlocks()
    {
        parent::_initBlocks();
 
        $this->_saleBlockName = 'inchoo_sale/catalog_layer_filter_sale';
    }
 
    /**
     * Prepare child blocks
     *
     * @return Mage_Catalog_Block_Layer_View
     */
    protected function _prepareLayout()
    {
        $saleBlock = $this->getLayout()->createBlock($this->_saleBlockName)
                ->setLayer($this->getLayer())
                ->init();
 
        $this->setChild('sale_filter', $saleBlock);
 
        return parent::_prepareLayout();
    }
 
    /**
     * Get all layer filters
     *
     * @return array
     */
    public function getFilters()
    {
        $filters = parent::getFilters();
 
        if (($saleFilter = $this->_getSaleFilter())) {
            // Insert sale filter to the self::SALE_FILTER_POSITION position
            $filters = array_merge(
                array_slice(
                    $filters,
                    0,
                    self::SALE_FILTER_POSITION - 1
                ),
                array($saleFilter),
                array_slice(
                    $filters,
                    self::SALE_FILTER_POSITION - 1,
                    count($filters) - 1
                )
            );
        }
 
        return $filters;
    }
 
    /**
     * Get sale filter block
     *
     * @return Mage_Catalog_Block_Layer_Filter_Sale
     */
    protected function _getSaleFilter()
    {
        return $this->getChild('sale_filter');
    }
 
}

Important function from the preceding code is definitely Inchoo_Sale_Block_Catalog_Layer_View::_prepareLayout() where we add our Mage_Catalog_Block_Layer_Filter_Sale block to Magento layout. Other significant function is Inchoo_Sale_Block_Catalog_Layer_View::getFilters() where we inject our sale filter into filters array to be displayed right after category filter. This is the clumsy part of code I was talking about because order of layered navigation filters (other than attribute type) is more or less hardcoded and we must use two array_slice() invocations to position our block where we want it. Not quite good looking, but gets the job done and it works great. After setting everything up, here's how it looks like with Magento sample data using the default Community Edition theme:

Custom Layered Navigation FIlter

These code fragments are just one part of an extension named Inchoo Sale (how original, I know), so if you want to see the whole thing in action, Inchoo Sale Magento extension code is available from my GitHub account.

That's about it. In my next article I'll show you how to display desired product collection, for example sale items, at your own controller action using Magento built in blocks together with layered navigation and all, so stay tuned for more. Enjoy!

DevGenii

E-commerce is a breeze with Magento Certified Developer Plus & Zend Certified PHP Engineer nearby. Get in touch!

10 thoughts on “Create a custom layered navigation filter for filtering sale products in Magento

  1. Andy Ingham

    Hi Marko,

    Many thanks for the post – great timing as I’m currently working on something which requires this kind of customisation.

    A quick question regarding the….

    $select->where('price_index.final_price >= price_index.price');

    ..code: I guess that ‘$select’ is a reference to the query object, so changing it here is changing the underlying query? In my case I need to:

    1. Add a custom product attribute to the main query
    2. Add a join to a custom table based on the custom attribute
    3. Filter on an attribute belonging to the joined table

    I’m trying to use addAttributeToSelect method, etc., on the getSelect() object, but hitting problems. Maybe I should be amending the query at the getProductCollection() level instead?

    Any help much appreciated, but otherwise thanks for the informative post.

    Andy

    Reply
    1. Marko Author

      Hi Andy,
      I’m glad you find this useful.

      Regarding your query, $select is an object of Varien_Db_Select so you can use all methods provided by this class including join(). This is the most flexible low level interface to your query. If you do not come from Magento background, you’ll most definitely pick the Varien_Db_Select Zend_Select approach, that’s OK. Here’s an example in my code:

      https://github.com/Marko-M/Inchoo_Sale/blob/0.3.0/app/code/community/Inchoo/Sale/Model/Observer.php#L98-L139

      You can also work on the collection object without calling getSelect() and then you can use the Magento provided methods like addAttributeTo*. This gives less flexibility but it’s preferred when working with Magento in most (but not all) situations.

      Good luck,
      Marko

      Reply
  2. Maurice

    First of all; very helpful article! Everytime I read something that has been labeled with “Inchoo”, the code works great, is clean and a delight to work with!

    This is the first piece however that did not work for me out of the box. (on Magento 1.9.x)
    I got the following error SQLSTATE[42S21]: Column already exists: 1060 Duplicate column name 'price' (which does makes sense seeing the raw SQL gotten from the select object.)

    Instead of trying to make it work, I have implemented a different counting mechanism. What I did is replacing the getCount() method in Inchoo_Sale_Model_Catalog_Layer_Filter_Sale

    protected function _getCount()
    {
        $result = array(
            'yes' => 0,
            'no'  => 0
        );
     
        $collection = $this->getLayer()->getProductCollection();
     
        $count = $collection->getSelectCountSql();
        $count->where('price_index.final_price query()->fetchColumn();
     
        $count = $collection->getSelectCountSql();
        $count->where('price_index.final_price >= price_index.price');
        $result['no'] = (int) $count->query()->fetchColumn();
     
        return $result;
    }

    Which makes it 2 queries instead of one, but at least it’s working for me now!
    Maybe this helps someone else as well.

    Cheers again for the great article!

    Reply
    1. Marko Author

      Hi Maurice,
      thanks for the feedback. I’m glad you find it useful, I’ll inspect the multiple price in select issue when I find some time.

      Cheers!

      Reply
  3. Sarah

    Hello Marko!

    Thanks for the post! I’ve never created filters before as I have improved layered navigation by Amasty installed, it has that functionality.
    But I was asked to create such a filter for my clients. I did that! This is a big win for me. Thanks again for the tutorial!

    Reply
  4. chamal

    hi,

    I have installed module in github with a magento 1.9.0.1 installation but I can not see the onSale filter in the default lauered navigation, Is there any spesific things to do ?

    regards
    chamal

    Reply
  5. Iris

    I’ve also installed the extension from github and as chamal already stated, I can’t see the on-sales Filter in the frontend (Magento 1.8.0.0).

    Can you please give any advice how to fix this?

    Kind regards,
    Iris

    Reply
  6. rajesh Kumar

    Hi Marko,

    This article is useful and I liked it, I have installed your’s extension from Github successfully, but on frontend I am unable to see Sale based filter option(Iam using magento 1.9) tried on default template and rwd theme as well but no success.

    Thanks,
    Rajesh

    Reply
  7. Rekha

    Hello
    I want to show layered navigation on catalog advanced search result page. If you suggest a solution for that,it would be great

    Reply

Leave a Reply

Your email address will not be published. Required fields are marked *