We always preach (in the IRC channel #cakephp) to add routing when you are done developing your application. In reality though, it isn’t always that easy if you are following the book. Routing isn’t just changing the routes, sometimes it is also changing the links across your site. It could even be worse if a value is needed in the URL that isn’t yet available. You will end up changing your model code as well to get it from the database. You know routes and links like these:

<?php
Router::connect(
    '/post/:slug',
    array('controller' => 'posts', 'action' => 'show'),
    array('pass' => array('slug'))
);
?>
<?php
echo $this->Html->link('Routing isolation in CakePHP', array(
    'action' => 'show', 
    'slug' => $slug
));
?>

Another problem with this is that you have values in your links that when removed from the route turn into named parameters. You will get something like /post/slug:routing-isolation-in-cakephp. So you end up changing your views again. The problems don’t end here though.

If you have actions setup like the next example, you will break your application when having the slug as a named parameter or when you remove it entirely:

<?php
public function show($slug = null) {
    // Get post for slug.
}
?>

All these problems can be avoided by isolating your routing. Routing isolation is just a simple concept and is fully supported by the core since CakePHP 1.3. The idea is to extract all routing logic from the application and centralize it in routes and custom route classes.

For this to work we need to go back to the basics. Forget about routes, slugs and SEO completely. The way we made URLs before we applied routes is with the IDs of the objects we were requesting. For example: /posts/show/45. These URLs are supported by the CakePHP core routes. Let’s look at our link example how that would look:

<?php
echo $this->Html->link('Routing isolation in CakePHP', array(
    'action' => 'show', 
    $id
));
?>

Our action would look like this:

<?php
public function show($id = null) {
    // Get post for ID.
}
?>

Now you are ready to make your URL prettier. For this we are going to use a route and a custom route class. If you don’t know about custom route classes, you should. The route is simple, it will mostly rely on the route class:

<?php
Router::connect(
    '/post/:slug',
    array('controller' => 'posts', 'action' => 'show'),
    array('routeClass' => 'PostRoute')
);
?>

In Mark Story’s example CakeRoute::parse() is used check if a post exists. We are going a bit further by not only checking if it exists, but we are also going use it to translate the slug into an ID. See:

<?php
public function parse($url) {

    // Use the CakePHP core to parse the URL.
    $parsed = parent::parse($url);

    // Get the Post model.
    $post = ClassRegistry::init('Post');

    // Get the ID for the post identified by the slug.
    $id = $post->field('id', array(
        'slug' => $parsed['slug']
    ));

    // If there is no post, it doesn't match.
    if (!$id) {
        return false;
    }

    // Get rid of the slug.
    unset($parsed['slug']);

    // Set the ID as the first passed parameter.
    array_unshift($parsed['pass'], $id);

    // Return the parsed URL with the ID in it.
    return $parsed;

}
?>

You should be able to access your post as /post/routing-isolation-in-cakephp now. However, reverse routing is still broken. When you generate a link with the HTML helper it will still look like /posts/show/45. This is where CakeRoute::match() comes in. We are going to use that to translate the ID to the slug:

<?php
public function match($url) {

    // Skip if not matching PostsController::show().
    if (
        $url['controller'] != 'posts' || 
        $url['action'] != 'show'
    ) {
        return false;
    }

    // Skip if ID is missing or wrong.
    $pattern = '/^' . Router::ID . '$/';
    if (
        empty($url[0]) || 
        !preg_match($pattern, $url[0])
    ) {
        return false;
    }

    // Get the Post model.
    $post = ClassRegistry::init('Post');
    
    // Translate the ID into a slug.
    $slug = $post->field('slug', array(
        'id' => $url[0]
    ));
    
    // If there is no slug or post, skip again.
    if (!$slug) {
        return false;
    }

    // Merge the slug with the rest of the parameters.
    $url = array_merge($url, array(
        'slug' => $slug
    ));
    
    // Unset the ID so it won't end up in the URL.
    unset($url[0]);

    // Use the core routing to make a proper URL.
    return parent::match($url);
}
?>

With that your generated URL of the link will look like /post/routing-isolation-in-cakephp. All that without touching your views and controllers. I urge you to refactor the model logic to the models though, because that is easier with testing. For the sake of this article I did them in the Route class itself. Don’t forget to include the ClassRegistry or you will get errors.

<?php
App::uses('ClassRegistry', 'Utility');
?>

There is a downside to this as well though. Because every generated link will do a query to find the slug, it is going to slow down a lot. So please always use caching with this. For example I use for this blog request caches in a property and APC across requests. I store them almost indefinitely until a new post. That will take away all the queries and you don’t even notice all this magic.

The possibilities are endless. As you look at the URL of this article you see the category and the date too. All this is done with the approach above. The route class uses model methods that check if the posts exist, if they are published and even if the date is right. I can change the URLs at any time without messing with the rest of the application. Mind blowing, isn’t it?