Back to blog home

Creating Custom CMS Layout Handles

For a recent project an interesting requirement came up where the CMS facilities of Magento would be used for a variety of page types, each would have distinct content and layout. I know what you are thinking -- this is MADNESS. But, it was not convenient or realistic for the client to manage the configuration of custom layout code for each page they created as there would be several quadrillion of them. In our example, we will use a site which sells albums from music artists. Instead of doing something sane, like integration WordPress, we will utilize Magento's CMS capabilities to allow the creation of distinct pages for artists, albums, and musical genres. Each page type has its own layout, such as customized blocks showing similar artists or other albums (on an artist page) the given artist has released. It is also possible each type may need to include specific JavaScript fanciness or CSS markup and that we should probably never include these on every page in the system to achieve this.

Standard Magento's CMS facilities are limited and thus do not have the concept of CMS page type. Let's be honest -- they have other things that need more attention than the CMS. What we will do with our module is the following:

Introduce the creation of CMS page type as a sort of attribute of a CMS page (meaning, we'll just add a field to the cms_page table in the database). Allow you to select the CMS page type when creating or editing a CMS page in the administration panel via the miracle of the drop-down form element. Create custom layout handles for each type which we can then use in our layout XML files to customize the structure of each page type distinctly.

A few notes before we begin:

The code can be found in Jarlssen's intentionally public GitHub repository which can be found here: http://github.com/Jarlssen/Jarlssen_CmsLayoutHandles. I make no guarantee that it functions flawlessly nor do I offer endless support for it should you decide to use it (but, feel free to use it for any non-evil purpose you like). This is written for a Magento CE 1.7.0.2 shop; while I see no immediate reason it would not work for EE (or later versions of CE), it is possible it will simply break and spit pithy exceptions at you and/or your users.

The module contains a convenient 'modman' file -- if you are not familiar with modman, check it out here: http://github.com/colinmollenhour/modman (hint: it makes things much cleaner and easier to manage, especially if you want to do something crazy like upgrade your Magento version in the future).

THE CODE

For the sake of brevity, I will not post inane things like the module XML file in this blog entry. If you want to see it in all of it's nine-line glory, just clone the module from GitHub or use the shiny web interface to navigate to it. What I will focus on are the more complex components which are:

  • How we introduce page type into a CMS page (i.e. the install script and the associated models).
  • How we make this actually show up in the CMS edit administration page (this will blow your mind).
  • How we create custom layout handles that Magento will recognize and use (with force).
  • A brief sample of how the custom layout handle can be used in a layout XML file (hint: like every other layout handle).

 

Again, I would not recommend copying and pasting the code from the blog post as it is publicly available in GitHub. It is here mainly so I can comment on it and explain the process and thought behind it.

THE INSTALL SCRIPT

The first thing we will need is our install script, which will modify the 'cms/page' table by adding a single integer field. This is relatively straightforward and should look something like this:

<?php
$installer = $this;
$cmsTable = $installer->getTable( 'cms/page' );
$installer->getConnection( )->addColumn( $cmsTable, 'page_type', "INTEGER DEFAULT 0" );
$installer->getConnection()->query( "UPDATE {$installer->getTable('cms/page')} SET page_type = 0" );

 

The important thing to note here is that we will use the widely accepted number '0' as the default (and must therefore SET this for existing CMS pages), which represents a standard Magento CMS page. This is all of your CMS pages up until this point.

WARNING: Oh, hey, this only works for CMS pages. Nowhere have I mentioned CMS static blocks nor will I after this sentence: they are NOT supported in any way by this module.

THE SOURCE MODEL

We now need to define the source model for our new field (page type). A source model will define WHAT the field is and, in this case, the set of possible values for it and the corresponding labels. If you're adventurous, you could update this code to allow one to dynamically add and remove page types from the administration backend. In our situation, the set of possible CMS page types is fixed and the client promised it would never ever change for any reason until the end of time, so we will use a simpler approach and define them within our source model as constants. This is called trust.

Open and create the file 'Model/Source/Pagetype.php' and get your copy and paste fingers ready:

class Jarlssen_CmsLayoutHandles_Model_Source_Pagetype
{
/**
* These constants define the possible set of page types we support.  Leave '0' alone as the standard type;  the rest can be customized as desired.
*/
const STANDARD = 0;
const ARTIST   = 1;
const ALBUM    = 2;
const GENRE    = 3;
/**
* This will be the prefix for our custom layout handles -- these are the handles we actually USE in our layout XML files to structure them.  I generally recommend using
* all caps here to avoid any possible conflict with controllers in other modules, but it's your shop, so feel free to throw caution to the wind and drive with the
* caps lock key off.
*/
const LAYOUT_HANDLE_PREFIX = 'CMS_TYPE_';
/**
* This will convert our user-unfriendly integer representations into labels we can localize into any of the commonly spoken languages on Earth.
*
* @return array
*/
public function toFieldOptionArray() {
 
        $_options = array(
            self::STANDARD     =>  Mage::helper( 'cmslayouthandles' )->__( 'Magento Standard Page' ),
            self::ARTIST       =>  Mage::helper( 'cmslayouthandles' )->__( 'Artist Page' ),
            self::ALBUM        =>  Mage::helper( 'cmslayouthandles' )->__( 'Album Page' ),
            self::GENRE        =>  Mage::helper( 'cmslayouthandles' )->__( 'Genre Page' ),          
        );
 
        return $_options;
    }
/**
* This method will simply return the complete layout handle for a given page type as a string.
*
* @param int $pageType The page type (as integer); see above const definitions.
* @return string
*/
    public function getLayoutUpdateHandle( $pageType ) {
        switch( $pageType ) {
            case self::ARTIST:
                return self::LAYOUT_HANDLE_PREFIX . 'ARTIST';
                break;
            case self::ALBUM:
                return self::LAYOUT_HANDLE_PREFIX . 'ALBUM';
                break;
            case self::GENRE:
                return self::LAYOUT_HANDLE_PREFIX . 'GENRE';
                break;
case self::STANDARD:
            default:
                return false;
                break;                 

 

The drawback here is that if the client does decide "hey, we need a CMS page type for tours," well then you have to go back and edit this file in about three places to add support. The plus side is, though, it is ONLY this file, and only three places.

THE ADMIN BACKEND

This is the most complex part -- as usual. Anytime you deal with the admin backend it is painful and often leads to tears and in some cases severe depression. This is even more true when you must manipulate a core, built-in interface of some kind, as we need to do. In our case, we need to manipulate our CMS page editing form to support the following:

Display a drop-down with page types for the user to select on the initial form when creating or editing a CMS page.

Actually, that's it! It will automatically pre-select the current page type for existing pages (or 0 for new pages) and save this field into the cms_page table as if it had always been there.

We will do this by using events Magento conveniently throws around when it wants to brag about doing something, which means we need to create an Observer model in the usual place and tell Magento to listen to them within the observer section of our config.xml.

Create '

Model/Observer.php' with the following code:

 

<?php
class Jarlssen_CmsLayoutHandles_Model_Observer
{
/**
     * This captures the CMS edit page in the backend before it is rendered and adds the page type field.
     *
     * @param Varien_Event_Observer $observer
     * @return Jarlssen_CmsLayoutHandles_Model_Observer
     */
    public function prepareForm( Varien_Event_Observer $observer ) {
        $_form = $observer->getEvent()->getForm();
        $_page = Mage::registry( 'cms_page' );
 
/**
* Create a fieldset to put our page type selector in.  Maybe this is overkill but we may to expand this later
* to add other input fields related to page types (tagging, for example).
*/
        $_fieldset = $_form->addFieldset(
            'pagetype_fieldset',
            array(
                'legend'    => Mage::helper( 'cmslayouthandles')->__( 'Page Type' ),
                'class'     => 'fieldset-wide',
            )
        );
 
        /**
         * Pull our page types from our backend source class.
         */
        $_optionsModel = Mage::getSingleton( 'cmslayouthandles/source_pagetype' );
 
/**
* Add it to our form's brand new fieldset.
*/
        $_fieldset->addField( 'page_type', 'select', array(
            'name'      => 'page_type',
            'title'     => Mage::helper( 'cmslayouthandles' )->__( 'Page Type' ),
            'label'     => Mage::helper( 'cmslayouthandles' )->__( 'Page Type' ),
            'options'   => $_optionsModel->toFieldOptionArray( ),
            'required'  => false,
        ));
      
        return $this;
    }
    
/**
* Called before a page is rendered -- we need to remove pre-defined "default" layout handles for our non-standard
* page types because they always take priority and destroy all of our hard work (meaning, our custom layout handles in
* layout.xml files).
     *
     * @param Varien_Event_Observer $observer
     * @return Jarlssen_CmsLayoutHandles_Model_Observer
     */
    public function onCmsRenderPage( Varien_Event_Observer $observer ) {
        /**
         * Our arguments from the method.  We need both.  Here is why:
         *  - Action:  In order to fetch layout and strip out bad handles.
         *  - CmsPage:  We need to know the type so we only do this application to non-standard CMS pages.
         */
        $action = $observer->getEvent()->getControllerAction();
        $cmsPage = $observer->getEvent()->getPage();
 
        /**
         * Obtain this list from Mage_Page/config.xml -- it defines all root page templates/layouts.
         */
        $removeHandles = Mage::getModel( 'page/config' )->getPageLayoutHandles();
 
        /**
         * From the page, get the page type.  If it is <> standard (0), then we should strip out the auto-added
         * handles from the $removeHandles list because they are not only extraneous but they also interfere with
         * what we are doing.
         */
        if( $cmsPage->getPageType() != Jarlssen_CmsLayoutHandles_Model_Source_Pagetype::STANDARD ) {
            foreach( $removeHandles as $handle ) {
                $action->getLayout()->getUpdate()->removeHandle( $handle );
            }
        }
 
        return $this;
    }  

 

Where to begin. There are two different events we capture here and start to cause chaos within. I'll go over both briefly, but hopefully the comments were sufficiently descriptive and clear.

  • prepareForm: All we are doing here is injecting our drop-down element into the form when a CMS page form is rendered in the admin backend. Should be clear enough.
  • onCmsRenderPage: This one is tricky so I'll try to explain why it is here and why it drives me insane. When rendering a CMS page in the store frontend, Magento seems to use either the custom root template OR the overall store layout default and will IGNORE any setTemplate() call within a local.xml layout handle. This is obviously a problem since we use custom layout handles and custom root templates for the various types. Therefore, this observer has to capture the 'cms_page_render' event (which I am so happy exists), from Cms/Helper/Page.php::_renderPage() method. Here we can optionally REMOVE these stubborn handles (if it is one of our non-standard CMS page types). So our observer method simply removes ALL pre-existing page handles (based on the list defined in Mage_Page).

Confused yet?

THE PAGE CONTROLLER

Now the fun part -- the controller which actually renders a CMS page has to be modified to actually USE the custom layout handles when rendering the page.

<?php
require_once 'Mage/Cms/controllers/PageController.php';
 
class Jarlssen_CmsLayoutHandles_PageController extends Mage_Cms_PageController
{
    /**
     * @var string Layout handle name for custom CMS page type.
     */
    protected $_layoutHandle = null;
 
    /**
     * We overload/overwrite the CMS page controller (which is used only when viewing/rendering a CMS page).  We
     * do this so we can capture the CMS page, then get its PAGE TYPE.
     * From here, we adjust the layout by inserting the appropriate block/template into the layout for the given
     * type.
     */
    public function viewAction() {
 
        /**
         * First check to see if we have a standard page -- if so, just let it pass through and render like
         * normal.  Otherwise, we need to do more checking to apply a custom handle.
         */
        $pageId = $this->getRequest()->getParam( 'page_id', $this->getRequest()->getParam( 'id', false ));
        $cmsPage = Mage::getModel( 'cms/page' )->load( $pageId );
 
/**
* Standard page -- just render it and get out of here before anyone asks for ID.
*/
        if( $cmsPage->getPageType() == '0 ' ) {
            if( !Mage::helper( 'cms/page' )->renderPage( $this, $pageId )) {
                $this->_forward( 'noRoute' );
            }
            return;
        }
 
        /**
         * Handle non-standard page types.  Type list in source model.
         */
        $sourceModel = Mage::getSingleton( 'cmslayouthandles/source_pagetype' );
        $layoutUpdateHandle = $sourceModel->getLayoutUpdateHandle( $cmsPage->getPageType( ));
 
        /**
         * If we have one, add the custom layout handle based upon the type.  It will be applied and rendered
         * along with the others (cms_page, default, or any custom ones defined in the CMS page parameters)
         * in the standard rendering method [renderPage] below.
         */
        $this->_layoutHandle = $layoutUpdateHandle;
 
        /**
         * Store the CMS page object in the registry for convenience.  The custom layouts may contain blocks which
         * depend upon this.  Also, our observer.
         */
        Mage::register( 'cms_page', $cmsPage );
 
        if( !Mage::helper( 'cms/page' )->renderPage( $this, $pageId )) {
            $this->_forward( 'noRoute' );
        }
 
        return;
    }
 
    /**
     * Overload this to add our custom CMS type layout -- it has to be done this way instead of in the above action method --
     * why, I am uncertain, but this works.  I believe it has to do with the ORDER of the handles being rendered or maybe vegetables.
     *
     * @return Jarlssen_CmsLayoutHandles_PageController
     */
    public function addActionLayoutHandles() {
        parent::addActionLayoutHandles();
        if( $this->_layoutHandle ) {
            $this->getLayout()->getUpdate()->addHandle( $this->_layoutHandle );
        }
        return $this;
    }
}

 

CONFIG.XML

Who's ready for a huge block of XML!?

Since the config.xml for this module contains various observers and a controller "rewrite" I feel I should actually comment on it and enter it here, even though most of you should probably already have it open from GitHub. Also, I tend to not put comments in these XML files because no.

<?xml version="1.0"?>
<config>
    <modules>
        <Jarlssen_CmsLayoutHandles>
            <version>0.1.0</version>
        </Jarlssen_CmsLayoutHandles>
    </modules>
 
    <global>
 
        <models>
            <cmslayouthandles>
                <class>Jarlssen_CmsLayoutHandles_Model</class>                
            </cmslayouthandles>            
        </models>
 
 
        <resources>
            <cmslayouthandles_setup>
                <setup>
                    <module>Jarlssen_CmsLayoutHandles</module>
                    <class>Mage_Catalog_Model_Resource_Eav_Mysql4_Setup</class>
                </setup>
                <connection>
                    <use>core_setup</use>
                </connection>
            </cmslayouthandles_setup>
            <cmslayouthandles_write>
                <connection>
                    <use>core_write</use>
                </connection>
            </cmslayouthandles_write>
            <cmslayouthandles_read>
                <connection>
                    <use>core_read</use>
                </connection>
            </cmslayouthandles_read>
        </resources>
 
        <helpers>
            <cmslayouthandles>
                <class>Jarlssen_CmsLayoutHandles_Helper</class>
            </cmslayouthandles>
        </helpers>
 
    </global>
 
    <frontend>
        <routers>
            <cms>
                <args>
                    <modules>
                        <Jarlssen_CmsLayoutHandles before="Mage_Cms">Jarlssen_CmsLayoutHandles</Jarlssen_CmsLayoutHandles>
                    </modules>
                </args>
            </cms>
        </routers>
        <events>
            <cms_page_render>
                <observers>
                    <jarlssen_cmslayouthandles_cms_page_render>
                        <type>singleton</type>
                        <class>cmslayouthandles/observer</class>
                        <method>onCmsRenderPage</method>
                    </jarlssen_cmslayouthandles_cms_page_render>
                </observers>
            </cms_page_render>
        </events>
    </frontend>
 
    <adminhtml>
        <events>            
            <adminhtml_cms_page_edit_tab_main_prepare_form>
                <observers>
                    <jarlssen_cmslayouthandles_prepare_form>
                        <type>singleton</type>
                        <class>cmslayouthandles/observer</class>
                        <method>prepareForm</method>
                    </jarlssen_cmslayouthandles_prepare_form>
                </observers>
            </adminhtml_cms_page_edit_tab_main_prepare_form>
        </events>
    </adminhtml>
</config>

 

The main things to pay attention to here are:

We override (or whatever) the Mage_Cms controller so we can create our own viewAction() to use our custom layout handles. This is something you should be careful with if you have another module installed which also overrides this because Magento handles this confusion by the entirely logical and error-proof system of "whoever-is-loaded-first-gets-the-rewrite." We need to capture the 'cms_page_render' frontend event for this same reason. We need to capture the cleverly named 'adminhtml_cms_page_edit_tab_main_prepare_form' event so that page type becomes a selectable field in our backend form. What are the odds that this event actually exists? The rest is basic: we have no block or model rewrites (gasp) and no resource models to define. You may want to put in some localization/translate stuff in here; all strings appear only in the admin backend (as a side note). If you, or your client, are one of those people who use the "preview" function for CMS pages, it may be possible that additional effort is needed here.

THE REST

The rest of the module is basic Magento module fare.

The default Helper is required for localization support and, of course, all those functions you have no clue where else to put.

The modman file, which as I mentioned above, is present for those of you clever people using this tool in your Magento setups.

 

IMPLEMENTING YOUR CUSTOM LAYOUTS

Now we can do some fun stuff in our frontend by using all of our new custom layout handles in any of our layout XML files. In most situations (if following best practices), this should be your local.xml file within your theme/package. Obviously for every store the content of this section will be entirely different, but below is a simple example to show you what you can do:

<CMS_TYPE_ARTIST>
 
<!-- Restructure the page like a boss -->
<reference name="root">
            <action method="addBodyClass"><classname>cms-artist-page</classname></action>
            <action method="setTemplate"><template>page/2columns-right_ap.phtml</template></action>
            <action method="setIsHandle"><applied>1</applied></action>
            <remove name="breadcrumbs"/>
            <block type="core/template" name="right" as="right" template="cms/artist_page_sidebar.phtml"/>
            <block type="core/template" name="similar_artists" as="other_artists" template="catalog/product/view/similar_artists.phtml"/>
</reference>
<!-- Add some exclusive JS or CSS for this page type only; make other page types jealous. -->
<reference name="head">
     <action method="addItem">
                <type>skin_css</type>
                <name>css/jquery.isotope.css</name>
                <params />
            </action>
    </reference>
</CMS_TYPE_ARTIST>

 

You get the idea. Now any CMS page which is created with the 'Artist' page type selected will automatically be rendered using this layout handle, as if by the magic.

Note that the standard 'cms_page_view' layout tag will be used by ALL pages, so you can still put common things within this handle, and existing special pages (i.e. cms_index_index) are still supported and work per usual.

WHERE DO WE GO FROM HERE?

The possibilities are limited only by your imagination and system specifications. You could add the ability to define custom page types from within the admin backend itself and even define their layout XML updates so that instead of in an easy to find single local.xml file they are instead buried deep within the jungle of the Magento database. You could add page type groups so you can define common XML updates to multiple page types that have similar content. There are probably fantastic things I have not even imagined yet that could be done with this module. Don't let budget or "time" hold you back.