Get it All
Together

Only a very small handful of people from Rochester and the surrounding areas – those who like local music – will get the reference in the title of this post. Maybe a few people outside of the Rochester area. That’s OK. It’s a small homage to a favourite local band…

By now, most of us who are committed to social web development take it as a given that URLs should be as clean as possible for as much content on your site as possible[2. For a good discussion of clean URLs, their meaning and their use, see this article.]. Clean, clear URLs provide your audience with an intuitive way to understand the structure of your website, provide an easy method to return to favourite topics or sections of your site and not least-importantly, provide search engines with easily keyword-associated URLs to log and serve to their customers.

With CakePHP, clean URLs are part of the normal process of building pages, rather than an imposition. A URL to a given resource on your CakePHP website will have a format of domain.com/:controller/:action/:param/:param…[1. If you’re not familiar with this format, have a look at this page, which is our topic of discussion, anyway.] Without knowing how your site will be organized, this is a fairly intuitive way of organizing URLs. Your controller name should be fairly descriptive of what it works with, “posts,” or “teeshirts”; your action will probably be fairly descriptive of what its meant to do, “edit,” “view,” “index,” and so forth.

But not in all cases does the standard formatting work. In the case of the site that I’m in the process of developing, PotholePatrol.org, I needed to make some changes to the way CakePHP structured it’s URLs to make things slightly more intuitive to the layout of the website. For example, since the organization of data revolves around Metro areas containing one or more Towns having many Potholes, using /metros, /towns/ and /potholes didn’t make sense and wouldn’t have accurately reflected that organization.

Rather, I opted to have Metros represented as subdomains of my website, with Town names as the first “subdirectory” where information pertains directly to a Town. Potholes would fall under the Town subdirectories, with their actions first followed by their titles. Hence the full path to a Pothole in the Town of East Rochester, which is in the Rochester Metro, will appear as follows:
[code lang=”html” light=”true”]http://rochester.potholepatrol.org/east-rochester/view/big-hole-on-washington-st/[/code]
Reflecting the standard CakePHP format, that would be:
[code lang=”html” light=”true”]:metro.potholepatrol.org/:town-name/:action/:id[/code]
Users and administrative functions for Metros will point to the root of the domain, not a Metro-based subdomain. They will use the conventional CakePHP format.
[code lang=”php” light=”true”]potholepatrol.org/:controller/:action/:param[/code]
Additionally, I wanted to add a route which directed any URL that pointed to something other than Users, Pages or Metros to an error controller. For now, at least. That format is:
[code lang=”php” light=”true”]potholepatrol.org/:error[/code]
And of course, there also has to be a route which specifies which controller to use for the home pages of potholepatrol.org and any (metro).potholepatrol.org sitelets.

For pages, I would like to cut down on some of the overhead by shortcutting the format just a bit:
[code lang=”php” light=”true”]potholepatrol.org/:controller/:param[/code]
Whoa! That’s a lot of stuff! But it’s a good exercise for dealing with many routes of many configurations, so let’s get started….

Editing routes.php

To make these changes, we need to edit one of the most versatile configuration files in CakePHP, the routes.php file. It is in this file that you would place any routing information which is not standard to the way CakePHP operates. The flexibility of this configuration file has managed to continuously surprise me. Therefore, while I publish my post for the sake of helping other coders, I make no claims whatsoever either that I’ve done things the way they should be done or that there isn’t a better way. What I’m showing you worked for me in my situation.

The first step was to setup the subdomains and their root controllers. To do this, I setup a quick conditional phrase which checks the first word in the URL, splitting my code between the main home page and each Metro’s home page:
[code lang=”php”]
// Triaging routes based on whether or not they have a valid subdomain
$subdomain = substr($_SERVER["HTTP_HOST"], 0, strpos($_SERVER["HTTP_HOST"], "."));
// define values that should NOT be affected by this test:
$donot = array(
‘potholepatrol’,
‘holisticnetworking’,
‘cake’);
if (!in_array($subdomain, $donot)) {
// We have a metro specified in the subdomain (subdomains handled by Apache)
Router::connect(‘/’, array(‘controller’ => ‘metros’, ‘action’ => ‘view’, $subdomain));
} else {
// There is no subdomain being used.
Router::connect(‘/’, array(‘controller’ => ‘pages’, ‘action’ => ‘home’));
}
[/code]
As you can see, I make a simple check to see if the first word in the URL is in an array of words to avoid. Because my subdomains are setup in my Apache VirtualHosts file[3. For a discussion of VirtualHosts configuration in Apache2, see this article], there is no need to test for extraneous values. Either the incoming URL is a valid subdomain or it’s the home page. The other two values, ‘holisticnetworking’ and ‘cake’ are there for the sake of my development environment. In each case, I’ve setup a Router::connect statement to associate the root locations with the proper controllers: the home page will use the Pages controller and the ‘home’ action I’ve setup in it. The metro home pages will be routed to the Metros controller and launch the ‘view’ action, handing it the $subdomain as its only argument. We’re about to get way deeper into these arguments to the connect() method. In three, two, one…

Building custom routes

OK, so far, so good and everything works OK. But now we need to setup the specialized routing for potholes and towns, which brings us to a discussion of routing priority in the routes.php file. Like all PHP processing, the first instinct of the server will be to read the routes.php as though it was a scripting language: left to right, top to bottom. That’s because PHP began it’s life as a scripting language and later developed its OOP capabilities.

In our case, that’s important because CakePHP’s routing system will apply the first routing rule that matches a given URL. With that being the case, it is important that the rules you insert start with the most specific and work their way down to the most general. Our example requires us to route for the formats /:town-name and /:town-name/:action/:id. Thus we want to insert our ../:pothole rule before our /:town rule:
[code lang=”php”]
// Custom routing element: view, add, modify or delete a pothole:
Router::connect(
‘/:town-name/:action/:id’,
array(‘controller’ => ‘potholes’),
array(
‘town-name’ => ‘^[a-z][a-z/-]*[a-z]$’,
‘id’ => ‘^[a-z0-9][a-z0-9/-]*[a-z0-9]$’));
// Custom routing element: view town:
Router::connect(
‘/:town-name’,
array(‘controller’ => ‘towns’,
‘action’ => ‘view’),
array(‘town-name’ => ‘^[a-z][a-z/-]*[a-z]$’));
[/code]
As you can see, setting up custom routing is a bit more complex than a basic route which uses CakePHP conventions. Let’s walk through it a bit so we understand what’s happening. There are three arguments to the connect() method of Router: a string identifying the format to match; an array of configuration options; and one last optional argument, which is an array of regular expressions that define acceptable values for custom routes. We’ll get into that last bit a little later.

Setting the format

In the first argument of the first example, we have our format /:town-name/:action/:id. The “:something” format basically tells CakePHP what we would like to call a variable at a given position. “:controller” and “:action” are standard pseudonyms CakePHP already knows what to do with, but “:town-name” and “:id” mean that in our code, we can access these values as $this->params[‘town-name’] and $this->params[‘id’]. In the second router entry, you can see that the first argument is basically the same. Nothing special here.

Specifying the route

The second argument defines how the page information will be passed off. The controller, the action within that controller and if required, the flag that sets this route as an Admin route all go here. If your action requires arguments – and you don’t wish to pass them through the URL – those arguments are also specified here.

If we go back to our first set of routes as simple examples, we see the basics of routing. The controller, action and one parameter, $subdomain, are passed to the action:
[code lang=”php” light=”true”]Router::connect(‘/’, array(‘controller’ => ‘metros’, ‘action’ => ‘view’, $subdomain));[/code]
Normal parameter passing using CakePHP conventions would be “/:controller/:action/:para1/:para2” but in this case, we obviously can’t pass the $subdomain value that way. The Defining Routes section of the CakePHP book uses the example of having a subdirectory “/government” point to an entire category of objects by passing the category ID as follows:
[code lang=”php” light=”true”]
Router::connect(
‘/government’,
array(‘controller’ => ‘products’, ‘action’ => ‘display’, 5)
);
[/code]
You can see clearly that, without specifying the controller, action or parameter in the URL, routing can allow us to specify all these things manually. Thus domain.com/government is equivalent to domain.com/products/display/5. This is just one more example of the neat-o power of routing in CakePHP.

Returning to our example, note that in the first entry, ‘:action’ is defined in the format and no action is specified in the second argument. One explains the other. If somewhere in your format, you specify one of the default routing keywords, that keyword will not need to be explained in the second argument. Hence in this case, if the URL is /east-rochester/edit/some-pothole, CakePHP will automatically load the “edit” action of the Potholes controller without being told to do so. Because the second entry in our example does not specify an action to take, it must contain an action in the second parameter.

Finally, for those sections of the site which you would prefer only administrators access, there are Admin Routes. Specifying that a route is an admin route means that only a logged-in administrator of the site can access that section. Setting up this specification could not be simpler, as in the following example:
[code lang=”php” light=”true”]Router::connect(‘/metros/add’, array(‘controller’ => ‘metros’, ‘action’ => ‘add’, ‘admin’ => ‘true’));[/code]

Verifying the parameters

The third argument is only required if you have custom routing setup, which of course in this case, we do. This argument is an array of regexes that specifies what values are considered valid for your custom routing arguments. Here, I’ve used a simple regex that restricts results to a minimum of two lower case alpha-numeric characters with dashes separating any words. This is the standard post “slug” found in many platforms, such as “this-is-a-post.” It will not accept This-is-a-post, -this-is-a-post or this-is-a-post!

Putting it all together

So now that I’ve walked through the breakdown of my routes.php file and all the various concepts about working with routing that I know so far, what does the final product look like? Well, here it is below. I found that, for reasons I’m not yet aware of, I needed to actually spell out routes which are based on the default layout once I’d added in my other routes. So, this routes.php file ends up being quite large.

I hope those of you who read this find the information I’ve provided illuminating and helpful. I’ve done my best over the last two days to distill what I think I’ve learned about CakePHP routing down to what I hope is a coherent document. The comments section is open, of course, to your suggestions and questions. Thanks for reading!
[code lang=”php” wraplines=”false”]
// Edited July 20th, 2009
// Triaging routes based on whether or not they have a valid subdomain
$subdomain = substr($_SERVER["HTTP_HOST"], 0, strpos($_SERVER["HTTP_HOST"], "."));
// define values that should NOT be affected by this test:
$donot = array(
‘potholepatrol’,
‘holisticnetworking’,
‘cake’);
if (!in_array($subdomain, $donot)) {
// We have a metro specified in the subdomain (subdomains handled by Apache)
// Route all traffic that should go to metro-based URLS
// Administrative functions for towns
Router::connect(‘/towns/add’, array(‘controller’ => ‘towns’, ‘action’ => ‘add’));
Router::connect(‘/towns/modify/*’, array(‘controller’ => ‘towns’, ‘action’ => ‘modify’));
Router::connect(‘/towns/delete/*’, array(‘controller’ => ‘towns’, ‘action’ => ‘delete’));
// Custom routing element: view, add, modify or delete a pothole:
Router::connect(
‘/:town-name/:action/:id’,
array(‘controller’ => ‘potholes’),
array(
‘town-name’ => ‘^[a-z][a-z/-]*[a-z]$’,
‘id’ => ‘^[a-z0-9][a-z0-9/-]*[a-z0-9]$’));
// Custom routing element: view town:
Router::connect(
‘/:town-name’,
array(‘controller’ => ‘towns’,
‘action’ => ‘view’),
array(‘town-name’ => ‘^[a-z][a-z/-]*[a-z]$’));
// Finally, if none of the above conditions are met, load the Metro home page:
Router::connect(‘/’, array(‘controller’ => ‘metros’, ‘action’ => ‘view’, $subdomain));
} else {
// There is no subdomain being used.
// Users
Router::connect(‘/users/add’, array(‘controller’ => ‘users’, ‘action’ => ‘add’));
Router::connect(‘/users/view/*’, array(‘controller’ => ‘users’, ‘action’ => ‘view’));
Router::connect(‘/users/edit/*’, array(‘controller’ => ‘users’, ‘action’ => ‘edit’));
Router::connect(‘/users/delete/*’, array(‘controller’ => ‘users’, ‘action’ => ‘delete’));
Router::connect(‘/users/register/*’, array(‘controller’ => ‘users’, ‘action’ => ‘register’));
Router::connect(‘/users’, array(‘controller’ => ‘users’, ‘action’ => ‘index’));
// Pages
Router::connect(‘/pages/add’, array(‘controller’ => ‘pages’, ‘action’ => ‘add’));
Router::connect(‘/pages/edit/*’, array(‘controller’ => ‘pages’, ‘action’ => ‘edit’));
Router::connect(‘/pages/delete/*’, array(‘controller’ => ‘pages’, ‘action’ => ‘delete’));
Router::connect(‘/pages/*’, array(‘controller’ => ‘pages’, ‘action’ => ‘view’));
Router::connect(‘/pages’, array(‘controller’ => ‘pages’, ‘action’ => ‘index’));
// Metros: Admin only!
Router::connect(‘/metros/add’, array(‘controller’ => ‘metros’, ‘action’ => ‘add’, ‘admin’ => ‘true’));
Router::connect(‘/metros/modify/*’, array(‘controller’ => ‘metros’, ‘action’ => ‘modify’, ‘admin’ => ‘true’));
Router::connect(‘/metros/delete/*’, array(‘controller’ => ‘metros’, ‘action’ => ‘delete’, ‘admin’ => ‘true’));
Router::connect(‘/metros/*’, array(‘controller’ => ‘metros’, ‘action’ => ‘viewAdmin’, ‘admin’ => ‘true’));
Router::connect(‘/metros’, array(‘controller’ => ‘metros’, ‘action’ => ‘indexAdmin’, ‘admin’ => ‘true’));
/* Errors: anything that doesn’t conform to the above.*/
Router::connect(‘/:error/*’,
array(‘controller’ => ‘pages’, ‘action’ => ‘error’),
array(‘error’ => ‘[^"rn]+’));
// Finally, if nothing else is added to the domain name, let’s go home:
Router::connect(‘/’, array(‘controller’ => ‘pages’, ‘action’ => ‘home’));
}
[/code]