Using custom Route classes in CakePHP

New for CakePHP 1.3 is the ability to create and use custom route classes for your application’s routing. In the past the router did double duty, managing route collections and routes were just arrays. In 1.3 Router underwent some surgery and CakeRoute was extracted as an object to represent a single route. While Router was left as a manager of routes. This also opened up new space for creating custom application specific route classes. Which in turn allows you to do a variety of interesting things with routing, that were previously harder to do.

Making route classes

For the following example we are going to make what looks like a very greedy /:slug/* route and use a custom route class to make it less greedy. Making and using route classes is pretty simple, I recommend sticking them in app/libs/routes so they stay organized. The one we’ll be making today is called SlugRoute and will go into app/libs/routes/slug_route.php.

Route classes need to extend CakeRoute – the default built-in route class. They usually re-implement one or both of the match() and parse() methods. The parse() method is used when parsing string urls into request parameters. While match() is used for reverse routing array parameters into string urls. SlugRoute will focus on the parse() method. Now in the past slug routes were a bit tricky as ensuring you had a valid slug either had to be done at the controller/model level, or you had to connect several hundred routes, one for each article slug. Both implementations leave much to be desired. With SlugRoute we are going to replace the parse method with one that checks for a valid slug, and only ‘matches’ the request if the requested slug exists. So assuming we have a posts table that contains a slug field our SlugRoute class will end up looking like:

Show Plain Text
  1. class SlugRoute extends CakeRoute {
  2.  
  3.     function parse($url) {
  4.         $params = parent::parse($url);
  5.         if (empty($params)) {
  6.             return false;
  7.         }
  8.         App::import('Model', 'Post');
  9.         $Post = new Post();
  10.         $count = $Post->find('count', array(
  11.             'conditions' => array('Post.slug LIKE ?' => $params['slug'] .'%'),
  12.             'recursive' => -1
  13.         ));
  14.         if ($count) {
  15.             return $params;
  16.         }
  17.         return false;
  18.     }
  19.  
  20. }

SlugRoute’s parse method is pretty straightforward. First we call the parent::parse() to get the regular expression route parsing that CakeRoute does. We fail any empty results in our parse method, as returning false signals that the route did not match. Once we have what seems to be a valid route we check for a post with a matching slug. Since CakeRoute::parse() will have already create the slug parameter we don’t need to modify the parameters, just return them or return false if no slug exists.

Using our new route class

Using our route class is as simple as connecting some routes using that classname. To do that we use a new option in Router::connect() that allows us to choose which class the route object will use. In our app/config/routes.php

Show Plain Text
  1. App::import('Lib', 'routes/SlugRoute');
  2.  
  3. Router::connect('/:slug', array('controller' => 'posts', 'action' => 'view'), array('routeClass' => 'SlugRoute'));

We’ve connected our route to match specific patterns, and with our custom parse method we will only capture requests that match a slug in our database.

Further improvements

Of course the current implementation is quite rough, and could benefit greatly from some caching, to increase the performance of our routing checks. We could also generalize the model used to do the slug check. But its a decent start at a clean implementation to a previously tricky problem. Adding caching is also pretty easy and could be implemented like

Show Plain Text
  1. function parse($url) {
  2.     $params = parent::parse($url);
  3.     if (empty($params)) {
  4.         return false;
  5.     }
  6.     $slugs = Cache::read('post_slugs');
  7.     if (empty($slugs)) {
  8.         App::import('Model', 'Post');
  9.         $Post = new Post();
  10.         $posts = $Post->find('all', array(
  11.             'fields' => array('Post.slug'),
  12.             'recursive' => -1
  13.         ));
  14.         $slugs = array_flip(Set::extract('/Post/slug', $posts));
  15.         Cache::write('post_slugs', $slugs);
  16.     }
  17.     if (isset($slugs[$params['slug']])) {
  18.         return $params;
  19.     }
  20.     return false;
  21. }

SlugRoute now re-generates its own cached index of all slugs and uses that cache to save a query on the database. I hope you can use custom route classes to simplify part of your application.

Comments

Hello Mark,
The implementation of slug on the routes.php reminded me of ruby mixins. This feature is cool, will ease lots of pain of the developers.

Rajib Ahmed on 1/28/10

Thanks for this article, it is something I wanted to test when I saw the changes in 1.3. It looks quite interesting!

Just a remark as I’m not sure I understand it well: in the last example L12, why is the condition still here? Is it a line you forgot to delete or is there a reason?

Pierre Martin on 1/28/10

Hi Mark,

Great example!

Say if I wanted to do something a bit different, like have all requests go through a find in the DB and on match, go to a specific plugin/controller/action combo, matching the url – do you have an idea for a clean implementation of that?

Currently I have a custom class that searches a model “Sitetem” which have url, plugin, controller and action fields, if there’s a match I do something like :

$result = $this->SiteItem->findByUrl($url);
Router::connect($url, array(‘controller’ => $result[‘SiteItem’][‘controller’], ‘action …. etc

It works, but not the cleanest method I guess.

Kim Biesbjerg on 1/28/10

This is really cool, thanks for the Update!
Just came across the same problem, doing it in the controller at the moment, meh…

@Kim: Guess you could just set $params[‘controller’] to the value you got from your query and then return $params, as in the example.

leo on 1/28/10

Pierre: the extra conditions were a mistake, and have been removed, thanks :)

Kim: If the other url parameters are stored in the db, you could just retrieve them, modify the request params and return the modified results.

mark story on 1/28/10

Interesting. I hadn’t gotten a chance to look into the new 1.3 features, but I think now I will have to. Thanks!

Dave on 2/6/10

Great feature, i’m trying to make some free time for try new features.

Thanks for nice explained article

Furkan Tunalı on 2/8/10

Greate tutorial! I will try it with new cake experience.

sophy on 2/10/10

There’s one small problem that could be a big performance hit. A new class is created every time even if the same route class is used.

For example, if I have routes like this (pseudo):

/:category => CategoryRoute
/:category/:slug => CategoryRoute
/:language/:category => CategoryRoute
/:language/:category/:slug => CategoryRoute

In each of those I’m going to be checking the category and sometimes the available languages for the site. Since there’s a new class created each time if I open that category list from a cache file or persist file, I’ve got to reopen and unserialize the data for every route check.

Barry on 2/24/10

Barry: An object is created for every route connected regardless of whether or not you define a routeClass. While checking a database could be slow, checking a cache system like apc or memcache is generally quite fast. You could even do things like implementing simple memory storage through a static class property, this would save you access time, but consume more memory. I haven’t done any tests, but I’m guessing that reading from memcache is not an overly expensive process :)

mark story on 2/24/10

Looks excellent and didn’t know of this new addition!

Good work

zeen on 4/2/10

What’s the best way to make the Router go to CONTROLLER_PREFIX.php instead of CONTROLLER.php whenever a PREFIX is used ?

I like to keep my PREFIX actions packed in individual files rather than a single file.

Valentin on 4/16/10

Someone just pointed this to me when I explained what I was trying to do. It looks like just what I need.

For a webshop I’m building I have the following URL structure: /url1/url2/url3/url4

Possible route matches for this URL structure are:
url1 = static page, url2 = subpage
url1 = category, url2 = product, url3 = product option
url1 = category, url2 = subcategory, url3 = product, url4 = product option
url1 = brand, url2 = product, url3 = product option

I’m already making sure URLs are always unique, and since they have the same structure it’s impossible to use regular expressions to find out what each part is referring to. I was planning to build an interpreter function to check that, but from the looks of things I can use a Route class for that when 1.3.0 gets released.

Thanks for making this functionality part of Cake!

Rick on 4/23/10

I dont know if i dont understand the propossal of this very well.. but you dont should pass at least the id of the post? what you earn passing the same think that the standard route pass?

I know this may can be a goffy cuestion but.. can you please explain me a little what this can be use for?

Manolo on 4/25/10

Thanks a lot!
Really useful for my project!

Munkey on 4/30/10

Hi. Looks great but how do I pass the ID? For me it only routes to the controller and action, not the id.

Erik on 6/10/10

Got it to work by adding a array(‘pass’ => array(‘slug’)) in the route and checking slug in the controller instead of ID. Don’t know if that was correct.

Erik on 6/10/10

Ok, I figured that was probably not correct. So how do I pass the slug? As previous it only goes to the controller and action but without passing the slug to the controller.

Erik on 6/10/10

LMAO, what a life saver! dude, I was hacking my WWW.ROOT.‘index.php’ file like so:

$Dispatcher = new Dispatcher();
$url = (empty($_GET[‘url’])) ? ‘’ : $_GET[‘url’];
$url = ClassRegistry::init(‘Contents.Content’)->translateUri($url);
$Dispatcher->dispatch($url);

Puts my hack to shame. You are a genius! Much cleaner and I don’t fight with route::connect. I’ll be replacing my hack with this.

Angel S. Moreno on 6/24/10

I mentioned this on http://cakeqs.org/questions/view/how_do_i_create_routes_from_database

Angel S. Moreno on 6/30/10

Comments are not open at this time.