CakePHP: Prettier URLs for Plugins
Posted in CakePHP on May 28th, 2009 by Jamie – 2 CommentsWell, I’m oficially a CakePHP convert – so much so that I’m in the middle of writing a general purpose CMS based on the excellent framework (I’ll probably convert this blog to Cake if I ever get the time!). One of the most convenient aspects of Cake is its plugin feature, which allows for easy code drop-in from project to project (a feature that my CMS is based on). One shortcoming, however, is that – in an Interwebs world full of ‘pretty’ URLs – plugin URLs are unncessarily long, since by default they include the plugin name as well as the controller and action. I would much rather get rid of the plugin name in the URL and just have the Router figure things out, you know, automagically.
For example, if we have a “calendar” plugin with a “CalendarEvents” controller that has a “view” method, the URL will look something like this:
http://mydomain.com/calendar/CalendarEvents/view
That’s not terrible (certainly better than ?plugin=calendar&page=events&action=view blahblah), but a little lengthy for my liking. We could just rename CalendarEventsController to EventsController, but Events is a pretty general controller name so we could have a future conflict. It’s always best to prefix any plugin controller names with the name of the plugin itself.
So what to do? I’d love to have this:
http://mydomain.com/calendar/events/view
More intuitive and concise, I think. And, since I couldn’t find anything in the Cookbook, Bakery, Forge or Google group that implemented this to my liking, I gave it a go myself. The process is actually quite simple, and only involves editing a couple of files.
The first thing we need to do is find a way to gather up all of our plugin controllers. Tucked away in one of the Bakery’s many tutorials, I found a method called, aptly enough, “_get_plugin_controller_names()”. The tutorial on the Bakery puts this method inside of the AppController, but I’m going to put it in app/config/bootstrap. I’ve also modified it slightly so that instead of returning a flat list, it returns a multi-level array organized by plugin. Here’s the code:
/**
* Get the names of plugin controllers
* Adapted from: http://book.cakephp.org/view/647/An-Automated-tool-for-creating-ACOs
* /app/config/boostrap.php
*
* This function will get an array of the plugin controller names, and
* also makes sure the controllers are available for us to get the
* method names by doing an App::import for each plugin controller.
*
* @return array of plugin names.
*
*/
function _get_plugin_controller_names(){
App::import('Core', 'File', 'Folder');
$paths = Configure::getInstance();
$folder =& new Folder();
// Change directory to the plugins
$folder->cd(APP.'plugins');
// Get a list of the files that have a file name that ends
// with controller.php
$files = $folder->findRecursive('.*_controller.php');
// Get the list of plugins
$Plugins = Configure::listObjects('plugin');
// Loop through the controllers we found in the plugins directory
$names = array();
foreach($files as $f => $fileName)
{
// Get the base file name
$file = basename($fileName);
// Get the controller name
$file = Inflector::camelize(substr($file, 0, strlen($file)-strlen('_controller.php')));
// Loop through the plugins
foreach($Plugins as $pluginName){
if (preg_match('/^'.$pluginName.'/', $file)){
// First get rid of the App controller for the plugin
// We do this because the app controller is never called
// directly ...
if (preg_match('/^'.$pluginName.'App/', $file)){
unset($files[$f]);
} else {
if (!App::import('Controller', $pluginName.'.'.$file))
{
debug('Error importing '.$file.' for plugin '.$pluginName);
}
/// Now prepend the Plugin name ...
// This is required to allow us to fetch the method names.
$names[$pluginName][] = $file;
}
break;
}
}
}
return $names;
}
When called, it’ll look something like this:
$plugins = _get_plugin_controller_names();
debug($plugins);
Array
(
[Calendar] => Array
(
[0] => CalendarEvents
)
[Faq] => Array
(
[0] => FaqFaqs
)
)
[/sourcecode]
Great! Now we just need to put it in our router and add some code to interpret the findings. So, somewhere in /app/config/routes.php (before the last catch-all route of course), add something like this:
foreach ($pluginControllers as $plugin => $controllers) {
foreach ($controllers as $controller) {
if (substr($controller, 0, strlen($plugin)) == $plugin) {
$controller = substr($controller, strlen($plugin));
Router::connect('/(?i)'.$plugin.'/'.$controller.'/:action/*', array('plugin' => $plugin, 'controller' => $plugin.'_'.$controller));
Router::connect('/(?i)admin/'.$plugin.'/'.$controller.'/:action/*', array('plugin' => $plugin, 'controller' => $plugin.'_'.$controller, 'admin' => true));
}
}
}
Basically, what I'm doing is looping through the array of plugins, and, within each plugin, looping through the array of controllers. I do a check to make sure the controller name is prefixed with the plugin name (so "CalendarEvents" is in the "Calendar" plugin), and, if so, I remove the plugin name from the name of the controller and pass along all of the information to the Router. I've also thrown in an extra line to ensure that my admin actions are properly routed.
And that's it! I'm not sure if it's the ideal solution, or if it's 'Cakey' enough for the established users out there, but it works for me and is simple enough. I'd love to hear comments, improvements, etc.