- In Depth Magento Dispatch: Top Level Routers
- In Depth Magento Dispatch: Standard Router
- In Depth Magento Dispatch: Stock Routers
- In Depth Magento Dispatch: Rewrites
- In Depth Magento Dispatch: Advanced Rewrites
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
- Provide a
match
method which examines the request object and returnstrue
if the router wishes to “claim” a request and stop other router objects from acting - Mark the request object as dispatched, or through inaction fail to mark it as dispatched
-
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.