Increasing maximum products per category in Magento

Recently, one of our clients needed to have more than 1000 products in a category on their Magento store. Every time they clicked Save, they got the normal confirmation message letting them know that the category had been saved, but the extra products were never added!

This, it turns out, is because a line of code in Magento’s category save function falls foul of the default limit set for the max_input_vars directive on the servers here at Creare. The offending block of code is (from a CE 1.7.0.2 version of Magento):

// /app/code/core/Mage/Adminhtml/controllers/CatalogController.php
// In the saveAction() function, around Line 307
if (isset($data['category_products']) && !$category->getProductsReadonly()) {
   $products = array();
   parse_str($data['category_products'], $products);
   $category->setPostedProducts($products);
}

The specific bit of code above that exceeds the limit within max_input_vars is the parse_str function. Therefore, to increase the maximum products per category in Magento the easiest thing to do is to set the variable to a higher number. This variable can’t be set during runtime (i.e., within PHP using ini_set), but if we’ve not got access to the server’s php.ini file, we can still set it in our .htaccess file. To do this, we simply include the following line:

php_value max_input_vars 2000

Here, we’re setting the value to 2000. This value, in theory, could be anything you want. However, you will need to keep in mind the reason for max_input_vars existing in the first place is to limit the chance of someone performing a Denial of Service attack on your site with hash collisions. If you do have access to your php.ini file, you can reduce the chance of this a little by specifying this increased limit to a specific area of your site using the below code. Unfortunately though, this won’t work within our .htaccess file:

<LocationMatch "/(index\.php/)?admin/">
   php_value max_input_vars 10000
</LocationMatch>

See this link for details on LocationMatch.

The alternative and, in my option, better solution is to rewrite the Magento controller so that we’re not relying on parse_str. Rewriting a controller in this way isn’t the cleanest task, but its more stable of the available solutions. The below code presumes you’re pretty comfortable already with how to put together a Magento extension.

First, we create a config file that includes the following:

<!-- /app/code/local/NAMESPACE/Categorylimit/etc/config.xml -->
<config>
   ...
   <admin>
      <routers>
         <adminhtml>
            <args>
               <namespace_categorylimit before="Mage_Adminhtml">Namespace_Categorylimit_Adminhtml</namespace_catergorylimit>
            </args>  
         </adminhtml>
      </routers>
   </admin>
   ...
</config>

This tells Magento to check our module for controllers before checking Mage_Adminhtml, allowing us to override what we need. Therefore, the next step is to create the controller. To do this, we have to create the below file, and then include the original and extend our new class from it:

<?php
// /app/etc/local/NAMESPACE/Catergorylimit/controllers/Adminhtml/Catalog/CategoryController.php

require Mage::getModuleDir('controllers', 'Mage_Adminhtml').'Catalog/CategoryController.php';
class Namespace_Categorylimit_Adminhtml_Catalog_CategoryController extends Mage_Adminhtml_Catalog_CategoryController
{

  /**
     * Category save
     */
    public function saveAction()
    {
        if (!$category = $this->_initCategory()) {
            return;
        }

        $storeId = $this->getRequest()->getParam('store');
        $refreshTree = 'false';
        if ($data = $this->getRequest()->getPost()) {
            $category->addData($data['general']);
            if (!$category->getId()) {
                $parentId = $this->getRequest()->getParam('parent');
                if (!$parentId) {
                    if ($storeId) {
                        $parentId = Mage::app()->getStore($storeId)->getRootCategoryId();
                    }
                    else {
                        $parentId = Mage_Catalog_Model_Category::TREE_ROOT_ID;
                    }
                }
                $parentCategory = Mage::getModel('catalog/category')->load($parentId);
                $category->setPath($parentCategory->getPath());
            }

            /**
             * Process "Use Config Settings" checkboxes
             */
            if ($useConfig = $this->getRequest()->getPost('use_config')) {
                foreach ($useConfig as $attributeCode) {
                    $category->setData($attributeCode, null);
                }
            }

            /**
             * Create Permanent Redirect for old URL key
             */
            if ($category->getId() && isset($data['general']['url_key_create_redirect']))
            // && $category->getOrigData('url_key') != $category->getData('url_key')
            {
                $category->setData('save_rewrites_history', (bool)$data['general']['url_key_create_redirect']);
            }

            $category->setAttributeSetId($category->getDefaultAttributeSetId());

            if (isset($data['category_products']) &&
                !$category->getProductsReadonly()) {
                $products = array();
                $cat_products_array = explode('&', $data['category_products']);
foreach($cat_products_array as $products) {
    $temp_array = array();
    parse_str($products, $temp_array);
    list($key, $val) = each($temp_array);
    if (!empty($key) && !empty($val)) {
        $products[$key] = $val;
    }
}
                $category->setPostedProducts($products);
            }

            Mage::dispatchEvent('catalog_category_prepare_save', array(
                'category' => $category,
                'request' => $this->getRequest()
            ));

            /**
             * Proceed with $_POST['use_config']
             * set into category model for proccessing through validation
             */
            $category->setData("use_post_data_config", $this->getRequest()->getPost('use_config'));

            try {
                $validate = $category->validate();
                if ($validate !== true) {
                    foreach ($validate as $code => $error) {
                        if ($error === true) {
                            Mage::throwException(Mage::helper('catalog')->__('Attribute "%s" is required.', $category->getResource()->getAttribute($code)->getFrontend()->getLabel()));
                        }
                        else {
                            Mage::throwException($error);
                        }
                    }
                }

                /**
                 * Check "Use Default Value" checkboxes values
                 */
                if ($useDefaults = $this->getRequest()->getPost('use_default')) {
                    foreach ($useDefaults as $attributeCode) {
                        $category->setData($attributeCode, false);
                    }
                }

                /**
                 * Unset $_POST['use_config'] before save
                 */
                $category->unsetData('use_post_data_config');

                $category->save();
                Mage::getSingleton('adminhtml/session')->addSuccess(Mage::helper('catalog')->__('The category has been saved.'));
                $refreshTree = 'true';
            }
            catch (Exception $e){
                $this->_getSession()->addError($e->getMessage())
                    ->setCategoryData($data);
                $refreshTree = 'false';
            }
        }
        $url = $this->getUrl('*/*/edit', array('_current' => true, 'id' => $category->getId()));
        $this->getResponse()->setBody(
            '<script type="text/javascript">parent.updateContent("' . $url . '", {}, '.$refreshTree.');</script>'
        );
    }


}

Unfortunately, we have to duplicate that whole saveAction() function to just change one line. The line we’ve removed is:

parse_str($data['category_products'], $products);

And we replace it with:

$cat_products_array = explode('&', $data['category_products']);
foreach($cat_products_array as $products) {
    $temp_array = array();
    parse_str($products, $temp_array);
    list($key, $val) = each($temp_array);
    if (!empty($key) && !empty($val)) {
        $products[$key] = $val;
    }
}

Fundamentally, this breaks apart the category products into smaller chunks, allowing us to use parse_str without hitting the item limit. Now you’ll simply need to declare the module in app/etc/modules/Namespace_Categorylimit.xml, and you’re all set!

Stay tuned for a Github link to a complete and working module!