Today we’re going to revisit an old topic. Back in 2010 I wrote an article on setting up admin routes in Magento. While the article was technically sound, the techniques it described are not sanctioned by the Magento core team, which has led to incompatibilities throughout the years.
This article will describe the correct and blessed steps to setup a new admin page in Magento. For the impatient, the source code for this article is available for download.
Creating an Admin Route in Magento
Admin routes are tied closely to the admin console’s menu items. While it’s possible to create an admin route without a menu item, it complicates things for beginners. This may seem like an extraneous first step, but you’ll understand the detour by the end of the article, and have the knowedge you need to setup your own menu-less admin routes.
Setting up a custom menu in Magento is a relatively simple configuration task. If you’re done any Magento module development, you’re familiar with a module’s config.xml
file. More modern versions of Magento have a second config file named adminhtml.xml
. This file is where configuration related to he admin console is put, and was introduced to help reduce the memory footprint of Magento’s configuration object for frontend store access.
If you take a look at the Mage_Catalog
‘s adminhtml.xml
file, you’ll see the following structure inside the second level <menu/>
node.
<!-- File: app/code/core/Mage/Catalog/etc/adminhtml.xml -->
<config>
<menu>
<catalog translate="title" module="catalog">
<title>Catalog</title>
<sort_order>30</sort_order>
<children>
<products translate="title" module="catalog">
<title>Manage Products</title>
<action>adminhtml/catalog_product/</action>
<sort_order>0</sort_order>
</products>
<!-- ... -->
</children>
<!-- ... -->
</catalog>
</menu>
This partial section of the configuration is responsible for the Catalog -> Manage Products
menu.
Magento will use the <title/>
node for the menu text. The <action/>
node determines the route/URL, and the <sort_order/>
node determine the sorting order for the menu in relation to the other module’s menus.
The section of the configuration also uses Magento’s clumsy/verbose <children/>
syntax. Magento uses this syntax when describing hierarchical relationships. The <catalog/>
node above has three child nodes, <title/>
, <sort_order>
, and <children/>
<!-- File: app/code/core/Mage/Catalog/etc/adminhtml.xml -->
<catalog translate="title" module="catalog">
<title>...</title>
<sort_order>...</sort_order>
<children>
...
</children>
</catalog>
The nodes <title/>
and <sort_order/>
are descriptive nodes for the parent <catalog>
node. Nodes inside the <children/>
describe menu items that are children of the the top level menu item Catalog
. In other words, nodes that are child nodes describe the parent node, except for nodes inside the child node <children/>
, which describe actual child menu items.
This pattern is used elsewhere in the Magento configuration. It’s a little verbose, but it’s an important concept to learn if you’re going to do any non-trivial Magento development.
Setting Up our Own Menu
We’re ready to start coding. We’re going to assume you know how to create your own empty Magento module. The module we’ll be using for this article is named Pulsestorm_Adminhello
, located in the community
code pool.
To setup a new top level menu item, create an adminhtml.xml
file in your module with the following contents
<!-- File: app/code/community/Pulsestorm/Adminhello/etc/adminhtml.xml -->
<config>
<menu>
<pulsestorm translate="title" module="pulsestorm_adminhello">
<title>Pulse Storm</title>
<sort_order>1</sort_order>
<children>
<example>
<title>Example</title>
<sort_order>1</sort_order>
<action>adminhtml/adminhello/index</action>
</example>
</children>
</pulsestorm>
</menu>
</config>
Here we’ve created a new top level admin menu named Pulse Storm
, with a single item named Example
. Our URL/route is adminhtml/adminhello/index
. You’ll also notice we’ve used Magento’s standard locale translation attributes (translate
and module
)
<!-- File: app/code/community/Pulsestorm/Adminhello/etc/adminhtml.xml -->
<pulsestorm translate="title" module="pulsestorm_adminhello">
These attribute tell Magento the <title/>
node should be translated by the Mage::helper('pulsestorm_adminhello')->__($string)
helper method. Because of this, we’ll need to add a standard data helper to our module.
To do so, make sure your module’s config.xml
(not adminhtml.xml
) includes the following definition for helper classes
#File: app/code/community/Pulsestorm/Adminhello/etc/config.xml
<config>
<!-- ... -->
<global>
<helpers>
<pulsestorm_adminhello>
<class>Pulsestorm_Adminhello_Helper</class>
</pulsestorm_adminhello>
</helpers>
</global>
</config>
and that your module contains an empty data
helper.
#File: app/code/community/Pulsestorm/Adminhello/Helper/Data.php
<?php
class Pulsestorm_Adminhello_Helper_Data extends Mage_Core_Helper_Abstract
{
}
With the above in place, clear your cache and log into the Magento admin as a user with the special all resource action. You should see the new menu item.
Menu ACL
We’re not quite done with our menu item. Above, we told you to log into the admin console as a user with the special all permission. If you ignored this advice, and logged in as a user with restriced permission, you wouldn’t have seen the menu item.
That’s because Magento’s admin console only renders menu items that users have explicit permission to view. The all user has explicit permission to view everything, but users with assigned role resources do not.
Take a look at the admin menu block’s _buildMenuArray
method
#File: app/code/core/Mage/Adminhtml/Block/Page/Menu.php
protected function _buildMenuArray(Varien_Simplexml_Element $parent=null, $path='', $level=0)
{
<!-- ... -->
foreach ($parent->children() as $childName => $child) {
if (1 == $child->disabled) {
continue;
}
$aclResource = 'admin/' . ($child->resource ? (string)$child->resource : $path . $childName);
if (!$this->_checkAcl($aclResource)) {
continue;
}
<!-- ... main loop body ... -->
}
This loop is where Magento runs through all the <menu/>
configuration nodes to extract the information it will need to render a menu item. However, before going ahead with the main body of the loop, it uses the name of each menu node to create an ACL resource path.
#File: app/code/core/Mage/Adminhtml/Block/Page/Menu.php
$aclResource = 'admin/' . ($child->resource ? (string)$child->resource : $path . $childName);
and then runs an ACL check on each node.
#File: app/code/core/Mage/Adminhtml/Block/Page/Menu.php
if (!$this->_checkAcl($aclResource)) {
continue;
}
If the check fails, Magento skips this iteration of the loop with continue
, (in other words, the rending of that menu items is skipped)
In our case the ACL paths are
admin/pulsestorm
admin/pulsestorm/example
ACL stands for Access Control List. Covering Magento’s ACL implementation could be its own mini-book, but simply put Magento has a hierarchical tree structure that defines all sorts of permissions for backend functionality. The hierarchy is for organization purposes — and items under the <admin/>
node indicate a user has access to an entire area of the admin console. Since our user doesn’t have access to the admin/pulsestorm
section, a menu for the admin/pulsestorm
section is not rendered.
Unfortunately, Magento doesn’t automatically derive ACL permissions for added menu items. Whenever you add a new menu item, you also need to add nodes to your configuration defining an access control permission.
To add an ACL permission for our new Pulse Storm menu item (admin/pulsestorm
), add the following nodes to your adminhtml.xml
file.
<!-- File: app/code/community/Pulsestorm/Adminhello/etc/adminhtml.xml -->
<config>
<menu>
<!-- ... -->
</menu>
<acl>
<resources>
<admin>
<children>
<pulsestorm translate="title" module="pulsestorm_adminhello">
<title>Top Level Pulse Storm Menu Item</title>
<sort_order>1</sort_order>
</pulsestorm>
</children>
</admin>
</resources>
</acl>
</config>
With the above in place, clear your cache and look at a role in the System -> Permissions -> Roles
admin section
As you can see, we now have a new, top level ACL resource named Top Level Pulse Storm Menu Item
. If you grant your “non-all” user this permission, they’ll be able to see our Pulse Storm
menu.
To review what we did above: All ACL resource are configured under the top level <acl/>
node.
<acl>
<resources>
<admin>
<children>
<!-- all ACL resources -->
</children>
</admin>
</resources>
</acl>
You’ll notice the same <children/>
pattern we described above is being used here. The <pulsestorm/>
node name is should match the <menu/>
node name, and <sort_order/>
controls the position of the ACL resource in the UI. The only reason our menu item isn’t first is the Sales, Dashboard, and External Page Cache ACL nodes also have a sort_order
of 1
.
We’ve given the top level Pulse Storm
menu an ACL resource. Now we need to give the Example
menu a similar one. Since our <example/>
menu node is a child of our <pulsestorm/>
menu node, we need to use the same structure for our <acl/>
nodes.
<!-- File: app/code/community/Pulsestorm/Adminhello/etc/adminhtml.xml -->
<config>
<!-- ... -->
<acl>
<resources>
<admin>
<children>
<pulsestorm translate="title" module="pulsestorm_adminhello">
<title>Top Level Pulse Storm Menu Item</title>
<sort_order>1</sort_order>
<children>
<example>
<title>Example Menu Item</title>
</example>
</children>
</pulsestorm>
</children>
</admin>
</resources>
</acl>
</config>
As you can see, we’ve added an <example/>
node under <pulsestorm/>
‘s <children/>
node. This will give us a new ACL resource under Top Level Pulse Storm Menu Item
.
Assign this new resource to any user in your system, clear your cache, and they’ll have access to the Example
menu under the top level Pulse Storm
menu.
Important: ACL resource are cached in the PHP session object. This means it’s often necessary to log in and log out to view changes related to newly configured ACL resources.
Magento Admin URLs
We’re almost ready to get to our controller file. Before we do, let’s take a look at the structure of a Magento admin URL. If you look at the Catalog -> Manage Products
url (expand the menu, and then right-click to copy), a few things stand out abou the URL
http://magento.example.com/index.php/admin/catalog_product/index/key/1c95163f6d9e1q7f36cf54a74941580d/
The first is the key
path paramater.
key/1c95163f6d9e137f36cf54a74941580d
This is a nonce that’s added to each Magento admin URL. This helps protect the system from automated CSFR attacks. If you take a look at the URL for our Pulse Storm -> Example
menu item
http://magento.example.com/index.php/admin/adminhello/index/key/b8584ba1ed62576e8b3e6fbbc2eb9f2c/
you’ll see a similar, but slightly different nonce. Each URL has its own nonce, and if you attempt to access the product page directly (sans nonce) you’ll be pushed to the dashboard
http://magento.example.com/index.php/admin/catalog_product/index
This is why we setup a menu item for our new admin URL. It’s impossible to simply visit an admin URL, you need the system to generate a nonce for you. If you need to generate a non-menu URL for an admin page, use the adminhtml/url
model.
Mage::getMode('adminhtml/url')->getUrl('route/path/here');
Speaking of route paths, the other thing you may have noticed is our URLs use the admin
front name
admin/catalog_product/index
admin/adminhello/index
but were configured with the adminhtml
frontname.
<action>adminhtml/catalog_product/</action>
<action>adminhtml/adminhello/index</action>
Why the discrepancy? When you provide Magento with a route string, you’re not providing it with a literal URL. The first section of the route string actually indicates which <router/>
node Magento should look at to find the URL’s front name. So, adminhtml
in the route string points to the <adminhtml/>
router node, which has a <frontName/>
of admin
.
<!-- File: app/code/core/Mage/Adminhtml/etc/config.xml -->
<admin>
<routers>
<adminhtml>
<use>admin</use>
<args>
<module>Mage_Adminhtml</module>
<frontName>admin</frontName>
</args>
</adminhtml>
</routers>
</admin>
This means admin
gets used in the generated URL. If you tried to use admin
in the route string
<action>admin/adminhello/index</action>
you’d end up with a URL that has a blank frontname, since there’s no <admin/>
router.
http://magento.example.com/index.php//adminhello/index/key/a8584ba1ed62576e8b3e6fbbc2eb9f2c/
If you found that all a little confusing, don’t worry; it is confusing. The language and concepts around URL design in Magento are very soft, and weak technical evangelism/documentation by the core team have made the things extra messy. The fact nearly every other module in the system uses <routers/>
nodes that are named the same as their <frontNames/>
makes this extra tricky.
Software developers who haven’t done a lot of web development always seem to bungle URL design — and that’s one thing Magento keeps from its Zend Framework roots.
Magento Front Name Sharing
Now that we understand how Magento admin routes are generated, we’re finally able to start in on our admin console controller configuration and creation. If you’re familiar with frontend URL routing, you know that each module in Magento “claims” a particular frontname. For example, the Mage_Catalog
module claims all URLs that start with the string catalog
http://magento.example.com/index.php/catalog/*
This presents a problem if you want to create a new page for the admin console. While it’s possible to create a new frontName
for an admin console URL, there are many shared admin console features (media browser, ajax helpers, etc.) that assume your URL starts with the admin
frontname.
This “one module”/”one frontname” concept (if not the actual code) is borrowed from Zend Framework’s default module routing implementation. As we griped earlier, software developers who aren’t “web native” often come up with a generic URL solution that fits their needs, but creates difficulties for developers used to less rigid URL handling.
Fortunately, Magento 1.3 introduced the ability for modules to share a frontname. That’s the configuration syntax we’ll be covering below. It’s about half an abstraction more than you’ll normally have to deal with, but consistently named admin URLs will save you time and trouble in the end.
Setting up the Admin Controller
As previously mentioned, the URL we’re trying to create is
http://magento.example.com/index.php/admin/adminhello/index/
That’s a frontname of admin
, a controller name of adminhello
, and an action method name of index
. First, we’re going to create a controller, and then we’re going to handle the shared frontName
.
Magento admin controllers share the same naming convention as frontend controllers. The URL name of adminhello
is transformed into a PHP classname ending in AdminhelloController
(preceded by the current module name), and this controller file should be placed in your module’s controllers
folder.
To implement this in our module, create the following file with the following contents.
#File: app/code/community/Pulsestorm/Adminhello/controllers/AdminHelloController.php
<?php
class Pulsestorm_Adminhello_AdminhelloController extends Mage_Adminhtml_Controller_Action
{
public function indexAction()
{
$this->loadLayout();
$this->renderLayout();
}
}
The one key difference with an admin controller is it extends the class Mage_Adminhtml_Controller_Action
. This is the base admin controller, and contrains extra logic to validate the user session, process the nonce, and other tasks that don’t apply to Magento’s frontend. Notice our indexAction
method uses the same loadLayout
and renderLayout
methods as the Magento frontend. Layout and template files for the backend can be found at app/design/adminhtml
, and are beyond the scope of this article.
With our controller in place, the only thing left is to configure our module to use the admin
frontend. To do so, add the following node to your module’s config.xml
#File: app/code/community/Pulsestorm/Adminhello/etc/config.xml
<config>
<!-- ... -->
<admin>
<routers>
<adminhtml>
<args>
<modules>
<Pulsestorm_Adminhello after="Mage_Adminhtml">Pulsestorm_Adminhello</Pulsestorm_Adminhello>
</modules>
</args>
</adminhtml>
</routers>
</admin>
<!-- ... -->
</config>
Clear your cache, and then click on our Example
link with the above configuration and controller file in place, and you should see a blank admin page.
The important part of this configuration is the <modules/>
node. When you’re setting up a new frontname, you use a singular <module/>
.
<!-- File: app/code/core/Mage/Adminhtml/etc/config.xml -->
<admin>
<routers>
<adminhtml>
<use>admin</use>
<args>
<module>Mage_Adminhtml</module>
<frontName>admin</frontName>
</args>
</adminhtml>
</routers>
</admin>
Here the configuration is telling Magento this single module is responsible for the admin
frontname. Our new <modules/>
node (plural) tells Magento
Hey, you should check these modules as well
So, when Magento tries to find a controller file for an admin URL, it will also check for any module configured inside this <modules/>
tag.
<Pulsestorm_Adminhello after="Mage_Adminhtml">Pulsestorm_Adminhello</Pulsestorm_Adminhello>
The important part of this node is the text — the name of the node simply needs to be unique. Convention dictates that it should be named the same as the module you’re configuring.
The after="Mage_Adminhtml"
attribute is important. Without it, if your module contained a controller with the same name as another Magento module (think IndexController
), Magento might pick your module’s controller when it should pick another. This behavior can be used to explicitly create a controller override using the before="Mage_Adminhtml"
analog. This also means you should take care and choose a unique global name for your admin controllers since the possibility exists of conflicting with a third party module’s controller, and it would be impossible to add before
nodes for every module ever created.
Debugging Controller Selection
If you’re having trouble getting the above code working, the Mage_Core_Controller_Varien_Router_Standard::_validateControllerClassName
method is your best bet for resolving naming issues.
#File: app/code/core/Mage/Core/Controller/Varien/Router/Standard.php
protected function _validateControllerClassName($realModule, $controller)
{
$controllerFileName = $this->getControllerFileName($realModule, $controller);
if (!$this->validateControllerFileName($controllerFileName)) {
return false;
}
$controllerClassName = $this->getControllerClassName($realModule, $controller);
if (!$controllerClassName) {
return false;
}
// include controller file if needed
if (!$this->_includeControllerClass($controllerFileName, $controllerClassName)) {
return false;
}
return $controllerClassName;
}
This is the section of Magento’s dispatch where it creates the controller class and file name. Adding in a few temporary var_dump
s
var_dump($controllerFileName);
var_dump($controllerClassName);
will show you the class name and file name Magento’s expecting for your controller. This information will usually point to the subtle typo that’s preventing your controller from working. If you don’t see a controller name that related to your module, it means your config.xml
configuration is incorrect.
Wrap Up
That’s your admin hello world, brought up to date for Magento best practices. Magento’s routing system is one of those places where the cost/benefits analysis of a configuration over convention system start tip towards convention. The various non-standard ways you can configure a route in Magento and have it “work”, but not be anticipated by other system code, points towards the importance of enforcing these convention in the system itself. Of course, these things are out of our control — the important thing is understanding what’s expected by Magento and ensuring our own code works within those expectations.