Categories


Archives


Recent Posts


Categories


In Depth Magento Dispatch: Top Level Routers

astorm

Frustrated by Magento? Then you’ll love Commerce Bug, the must have debugging extension for anyone using Magento. Whether you’re just starting out or you’re a seasoned pro, Commerce Bug will save you and your team hours everyday. Grab a copy and start working with Magento instead of against it.

No Frills Magento Layout is the only Magento front end book you'll ever need. Get your copy today!

Routing is the heart of every web application framework. Requests flow from the internet to your web application. The routing engine takes the information from those requests and sends it to your code. Then, new information (ripe with oxygen, if we want to stretch the metaphor too far) is returned back to the internet. Magento’s routing system is extremely powerful and flexible, but over the years it’s developed a lot of plaque around its arteries. This article is the first in a series that seek to fully explore how Magento takes a requested URL and dispatches it to your PHP code.

Abstraction

Magento’s routing system, like much of Magento, requires you to think at two different levels of abstraction. First, you need to understand there’s a potentially unlimited number objects that are responsible for routing logic, and only one of these objects will “win” any particular request. Magento ships with four such objects.

Abstraction level two is that each of these routing objects has a different set of rules for how a particular URL should be routed to a particular controller. These rules will seem similar, but contain many subtle differences that can trip up a developer who is not mentally keeping track of which router object’s rules they’re looking at.

While an abstract, configuration based MVC framework can help large teams work well together, the dark side of this is the left hand of the team often doesn’t know what the right hand had in mind. This can lead to different metaphors at different levels of abstraction that, at first glance, don’t make any sense. It’s only when you understand operation of the entire system that things start to fall into place. Keep this in mind as we hack through the jungles of code ahead. There is a method to all this madness.

Routing Match Iteration

Routing happens in the dispatch method of Magento’s front controller object, in the following nested loop structure

#File: app/code/core/Mage/Core/Controller/Varien/Front.php
while (!$request->isDispatched() && $i++<100) {
    foreach ($this->_routers as $router) {
        if ($router->match($this->getRequest())) {
            break;
        }
    }
}

Let’s take a look at the inner foreach loop first. At this point in Magento’s system dispatch, the front controller object has an internal property array named $_routers. In a default Magento system, this array contains four instantiated router objects. They are (in order)

Mage_Core_Controller_Varien_Router_Admin
Mage_Core_Controller_Varien_Router_Standard
Mage_Cms_Controller_Router
Mage_Core_Controller_Varien_Router_Default

One at a time, the front controller object takes the objects instantiated from these classes, and calls their match method (passing in the Magento request object)

$router->match($this->getRequest())

Put another, pseudo-code way, the foreach loop accomplishes something similar to this

$router = new Mage_Core_Controller_Varien_Router_Admin();
    $results  = $router->match($this->getRequest());
    if($results)
    {
        goto EXIT;
    }   

    $router = new Mage_Core_Controller_Varien_Router_Standard();
    $results  = $router->match($this->getRequest());
    if($results)
    {
        goto EXIT;
    }   

    $router = new Mage_Cms_Controller_Router();
    $results  = $router->match($this->getRequest());
    if($results)
    {
        goto EXIT;
    }   

    $router = new Mage_Core_Controller_Varien_Router_Default();
    $results  = $router->match($this->getRequest());
    if($results)
    {
        goto EXIT;
    }   

EXIT:
    //continue with other things here

If the match method returns true, there’s been a router match, and the front controller object will break out of the foreach loop.

However, just because there’s been a router match, it doesn’t mean the request has been dispatched. The outer while loop will check the request object to see if it’s been dispatched with the following line.

while (!$request->isDispatched() && $i++<100) {
//$request and $this->getRequest() are the same object

If the request hasn’t been dispatched, the while loop continues and the front controller object will try the foreach loop again. The $i++<100 is there to ensure the outer loop stops after 100 attempts. If the request isn’t dispatched by then, we bail in a major way because something’s not right

if ($i>100) {
    Mage::throwException('Front controller reached 100 router match iterations');
}

Assuming the front controller object found a match, we fire off some events that can be hooked into, and then tell the response object to send its contents to the browser (via the sendResponse method).

Mage::dispatchEvent('controller_front_send_response_before', array('front'=>$this));
Varien_Profiler::start('mage::app::dispatch::send_response');

$this->getResponse()->sendResponse();

Varien_Profiler::stop('mage::app::dispatch::send_response');
Mage::dispatchEvent('controller_front_send_response_after', array('front'=>$this));

And we’re done. Routing is complete, and output has been sent to the browser.

Router Object’s Implicit Contract

As you can see, when viewed from this level of abstraction, a router object has three main responsibilities. It must

  1. Provide a match method which examines the request object and returns true if the router wishes to “claim” a request and stop other router objects from acting

  2. Mark the request object as dispatched, or through inaction fail to mark it as dispatched

  3. Set the body/contents of the request object, either directly or via another Magento system

It’s important to note that there’s nothing at this level of abstraction about action controllers, modules, or even URLs. MVC hasn’t even entered the picture. This is how a hyper-abstract system like Magento rolls. The creator of each router object chooses how their object will tackle the above problems. Magento’s Admin, Standard, and Cms routers each trigger, in their own way, an MVC subsystem. However, there’s nothing stopping a third party developer from creating a router that does something else entirely. Also, as previously mentioned, the specifics of each of the above router object’s varies.

Of course, nothing is quite that that simple with Magento. It turns out things get a little more complicated when you consider the Default router object, Mage_Core_Controller_Varien_Router_Default. This is the last router object added to the front controller object. If its match method is called, that means no other router objects in the system claimed the request, and Magento needs to engage its 404/no-route logic. The way the default router does this is to jigger the request object to point to the no route page. Then, because of the outer while loop, this new, jiggered request object will be passed through each router’s match method again, with one of them eventually claiming it. We’ll get to the specifics of this in later articles.

Best Laid Plans

If the above seems to be confusing, that’s because it is. We said a match method returning true indicates that a router object has “claimed” a particular request. However, it’s also interpreted by the front controller object in such a way that further router objects are NOT checked if a match returns true.

This gives each router object creator the power to influence the behavior of the front controller object, and in turn the responsibility of knowing how their objects might influence the rest of the system.

On top of that, a case could be made that the logic in the Default router seems like it might better belong in the front controller object routing logic itself. While routers like Admin and Standard operate (more or less), independently from the front controller object, the Default router relies on their behavior. These objects become dependent on the implementation of the other, arguably defeating the modular goals of an abstract system like Magento.

On top of that (shesh!), because of the behavior of the Default router, a case could be made that each router object has an additional responsibility: Respond to a “no route” request object. Except for Default, because that’s the one that got us in this mess in the first place.

It’s hard to backport a single design philosophy behind routing in Magento. Again and again, we come back to the need to be aware of multiple levels of abstractions to truly work with the Magento system. It’s another case where the realities of deadlines, schedules, lack of QA/Test, and the frantic business models of the 21th century have pushed system developers directly into the arms of the chaos they’re trying to avoid.

Of course, tangling with chaos is half the fun.

Where do Routers Come From?

As previously mentioned, there are four router objects in a standard Magento installation.

Mage_Core_Controller_Varien_Router_Admin
Mage_Core_Controller_Varien_Router_Standard
Mage_Cms_Controller_Router
Mage_Core_Controller_Varien_Router_Default

We’re going to, eventually, cover the match logic defined in each one. However, before we get to that we need to cover how and where Magento instantiates these objects.

As part of its startup process, the main Magento “App” model instantiates a front controller object, and then immediately calls its init method.

#File: app/code/core/Mage/Core/Model/App.php
protected function _initFrontController()
{
    $this->_frontController = new Mage_Core_Controller_Varien_Front();
    Mage::register('controller', $this->_frontController);
    Varien_Profiler::start('mage::app::init_front_controller');

    $this->_frontController->init();

    Varien_Profiler::stop('mage::app::init_front_controller');
    return $this;
}

If we take a look at the init method,

#File: app/code/core/Mage/Core/Controller/Varien/Front.php
public function init()
{
    Mage::dispatchEvent('controller_front_init_before', array('front'=>$this));

    $routersInfo = Mage::app()->getStore()->getConfig(self::XML_STORE_ROUTERS_PATH);

    Varien_Profiler::start('mage::app::init_front_controller::collect_routers');
    foreach ($routersInfo as $routerCode => $routerInfo) {            
        if (isset($routerInfo['disabled']) && $routerInfo['disabled']) {
            continue;
        }
        if (isset($routerInfo['class'])) {
            $router = new $routerInfo['class'];
            if (isset($routerInfo['area'])) {
                $router->collectRoutes($routerInfo['area'], $routerCode);            
            }            
            $this->addRouter($routerCode, $router);
        }
    }
    Varien_Profiler::stop('mage::app::init_front_controller::collect_routers');

    Mage::dispatchEvent('controller_front_init_routers', array('front'=>$this));

    // Add default router at the last
    $default = new Mage_Core_Controller_Varien_Router_Default();
    $this->addRouter('default', $default);

    return $this;
}

we can see the entire initialization of the front controller object is spent loading router objects. The first key line to zero in on is

$routersInfo = Mage::app()->getStore()->getConfig(self::XML_STORE_ROUTERS_PATH);

This line loads a series of nodes from the combined config.xml at the path

web/routers #path comes from the string in the self::XML_STORE_ROUTERS_PATH constant

In a stock Magento installation, there are two nodes at that location.

<web>
    <!-- ... -->
    <routers>
        <admin>
            <area>admin</area>
            <class>Mage_Core_Controller_Varien_Router_Admin</class>
        </admin>
        <standard>
            <area>frontend</area>
            <class>Mage_Core_Controller_Varien_Router_Standard</class>
        </standard>
    </routers>
    <!-- ... -->
</web>

We have an <admin> node, and a <standard> node. Each one of these nodes contains information that specifies the configuration of a router object. The init method loops over these nodes and pulls out the <class> name to instantiate a router

$router = new $routerInfo['class'];

then calls the router’s collectRoutes method (we’ll cover what this means in a later article)

$router->collectRoutes($routerInfo['area'], $routerCode);            

and then adds the router to the front controller object.

//$routerCode is the name of the node, generated by the <code>foreach</code> 
$this->addRouter($routerCode, $router);

Substituting concrete values for the real code above, the execution would look something like this.

$router = new Mage_Core_Controller_Varien_Router_Admin();
$router->collectRoutes('admin','admin');
$this->addRouter('admin',$router);

$router = new Mage_Core_Controller_Varien_Router_Standard();
$router->collectRoutes('frontend','standard');
$this->addRouter('standard',$router);    

The addRouter method is a simple key/value store for the $_routes property.

public function addRouter($name, Mage_Core_Controller_Varien_Router_Abstract $router)
{
    $router->setFront($this);
    $this->_routers[$name] = $router;
    return $this;
}

which also assigns the router a reference to the front controller object.

There’s two important things to note here. The first is that, when it comes to routing, order maters. Because the matching will loop over all the $_routers in numeric order, that means items added first have a chance to match before items added later. Magento iterates the the nodes in order, so since admin is first, it’s the first router object checked.

The second thing to note, (in retrospect), is one of the naming snafus of Magento. The first router we add is the admin router for the admin area. The second router we add is the standard router for the frontend area. The decision (or more likely, the engineering side effect) to not make these names (frontend vs. standard) consistent causes a lot of confusion. We’ll be coming back to this time and time again.

Missing Routers

So that’s two router objects added, but we know there are four total. What gives?

If we return to our init method, we can see the main loop is nestled between two event dispatches.

Mage::dispatchEvent('controller_front_init_before', array('front'=>$this));
...
Mage::dispatchEvent('controller_front_init_routers', array('front'=>$this));

These events are there to allow end user programmers to add their own custom routers via event observers. In fact, Magento itself uses an observer to add the Cms router object. Checkout the following system configuration

<!-- File: app/code/core/Mage/Cms/config.xml -->
<events>
    <controller_front_init_routers>
        <observers>
            <cms>
                <class>Mage_Cms_Controller_Router</class>
                <method>initControllerRouters</method>
            </cms>
        </observers>
    </controller_front_init_routers>
</events>

This sets up Mage_Cms_Controller_Router::initControllerRouters as an observer of the controller_front_init_routers event. If we look at the class and method itself

#File: app/code/core/Mage/Cms/Controller/Router.php
class Mage_Cms_Controller_Router extends Mage_Core_Controller_Varien_Router_Abstract
{
    //...
    public function initControllerRouters($observer)
    {
        /* @var $front Mage_Core_Controller_Varien_Front */
        $front = $observer->getEvent()->getFront();

        $front->addRouter('cms', $this);
    }
    //...
}

we can see the observer gets a reference to the front controller object, and then adds itself as the Cms router. This is a little mind bending if you’ve only worked with stand alone Package_Module_Model_Observer classes. Any object in Magento can be on observer, so by making the router itself the observer we save Magento a little work in that it doesn’t need to instantiate yet another class.

So why two events? Remember, order matters. You may want your router to run before the first Magento router, or alternately after all the routers have run. Even with both events, things can get tricky here, as there’s no way (short of a rewrite) to insert a router between Admin and Standard, and inserting something between Standard and Cms would require you carefully tune the Magento module load order.

The fourth and final router object is the Default router. This is added at the end of the init method, after both events and the main loop have executed.

$default = new Mage_Core_Controller_Varien_Router_Default();
$this->addRouter('default', $default);

Remember that Default is a little weird, in that it passes control back to the main while loop. It always matches, and therefore needs to be last.

Wrap Up

So that’s Magento’s router objects as seen from the higher abstraction level. So far we’ve seen a lot of code, but haven’t actually gotten to any routing. While it may seem abstract and tedious, this grounding in the front controller object and its contained router objects is necessary before diving into match methods.

In our next article, we’ll cover Magento’s Standard router object, which is the foundation the other router objects are built on.

Originally published August 29, 2011
Series NavigationIn Depth Magento Dispatch: Standard Router >>