Monday, November 24, 2008

Advanced Cake Routing: Dynamic Routes...

I've been working on integrating Wildflower CMS with a number of my sites, however there has been one issue that I've had with how cake routing was setup.
Router::connect('(?!' . $admin . '|' . $prefix . '|login|contact)(.*)', array('controller' => 'wild_pages', 'action' => 'view', 'plugin' => 'wildflower'), array('$2'));
If you notice this route will basically connect everything that is not admin, prefix, login, or contact with the generic Wildflower view controller.

Advantage:

The advantage of having a connect string like the one above is so that we can have dynamic content directly after the URL in the site. Normally the dynamic content would point to something like /pages/name-of-the-article, so the link would look like:
http://localhost/pages/name-of-the-article
However with the route above, everything that is not defined will point to the pages controller, allowing you to skip the /pages/ in the URL. For example, the url above can now be written:
http://localhost/name-of-the-article
This makes it nicer when dealing with dynamic content...

The Problem:

Since Wildflower is a plugin, it is an addition to my normal application. Let's say my normal application has 2 controllers: foo_controller and bar_controller.

Normally in CakePHP, the routes for foo and bar would be automagic. This means that by default if I point my browser to http://localhost/foo/index it will automatically load the controllers/foo_controller.php, and look for the function index() definition. Otherwise it will throw an error.

With the route above, my "foo" controller will not load unless I specifically define a "foo" route to point to the foo controller before the Wildflower route:

Router::connect('/foo/*', array('controller'=>'foo', 'action'=>'index');

Router::connect('(?!' . $admin . '|' . $prefix . '|login|contact)(.*)', array('controller' => 'wild_pages', 'action' => 'view', 'plugin' => 'wildflower'), array('$2'));
Well I don't want to have to define 1 or more routes for every controller I build in my application. I want the automagic to still work.

Solution:

There is a solution for this problem. It is a bit tricky, but Cake PHP is flexable enough to handle it. The solution is to grab only a list of the pages that you want available for the routing, and only assign those routes. This will remove the "catch-all" route from above and allow us to create the normal automagic routes for my application.

But how do you get a list of the pages from the database when the routes have no access to the databse when they're called?

The answer is the object method: requestAction(). requestAction can be called anywhere in the system and it can call any current route. The results of the requestAction call is an array of returned results. Since the router extends object, it has access to call it, and therefore can gain access to dynamic data.

Here's an example:
// Set a temporary route
Router::connect('/pages/get_root_pages', array('controller' => 'wild_pages', 'action' => 'get_root_pages', 'plugin' => 'wildflower'));
// Now request that temporary route.
$root_pages = Router::requestAction('/pages/get_root_pages');
// Loop through the root pages and manually define the pages.
foreach($root_pages as $i => $page) {
Router::connect('/' . $page['WildPage']['slug'], array('controller' => 'wild_pages', 'action' => 'view', 'plugin' => 'wildflower'));
}
The next step is simply to add function get_root_pages() in your pages controller. It would simply find all the active pages with a parent == 0.

The disadvantage of this method is that the Cake system is called 2 times for every request, however this can be alieviated with Caching. If you store the results of root_pages in the /tmp folder, you can build a caching system that will check to see if the file is outdated, and only load the requestAction() on such an occasion. Of course then you would store the file again.

Summary:

With this solution, you will not only have root level slugs (i.e. http://localhost/this-is-my-page) but also you will not need to modify routes every time you add a new controller to your system. The only disadvantage would be if a slug is called the same thing as your controller. In that case you simply decide ahead of time which has the priority, and the route will hit the first one that is decalred.

Links:

Learn more about Wildflower CMS.

4 comments:

Reuben said...

Where did put the code for that routing?

I tried putting it in app/config/routes.php, but it quickly became apparent that there would be a recursive problem, as requestAction called Dispatcher->dispatch, which would make a call to Dispatcher->parseParams, which would include the config/routes.php, and so on.

However, if you pass the URL to requestAction in array form, you can avoid the call to parseParams and the recursive issue.

This is possibly one of the best solutions for full SEF URLs, if using AppError makes you queesy.

D said...

Thanks for your comment & tip. You got the right place. app/config/routes.php is the right spot. And you're right about the recursive part of it.

Another way to solve the recursive issue would be simply to not load the second requestAction(). You could do this by defining and checking for a Constant for simplicity's sake.

I'll look into array formatted parameters. That may be a great direction as well.

Reuben said...

I ended up surrounding the block with a define() condition, to prevent the recursive action. Relying on array URLs seems more of a trick than a proper solution, and is liable to break for future releases.

I then used caching inside the block to prevent excessive calls to requestAction().

Kalt said...

Nice trick, but if you want to pass the slug to the action, you have to pass it to the second parameter of Router::connect().

And the requestAction with an array instead of a string is indeed working.

Example :
$slugs = Router::requestAction(array('controller' => 'pages', 'action' => 'get_slugs'));

foreach($slugs as $slug)
{
Router::connect('/' . $slug, array('controller' => 'pages', 'action' => 'view', $slug));
}