Re-Routeing the Zend Framework
An apple a day keeps the doctor away, but it seems lately I barely have time to eat one! Apparently, while I was buried in work this month, Zend finally released some of the Zend Framework they’ve been touting for the past few months. I say finally because I’ve been waiting for some sort of release since the web cast so I could actually have something to play with. And play I have. Overall I’m excited about the framework but it’s far from complete. Using PostGreSQL, I’ve encountered several problems with missing or incorrect methods in the Zend_Db layer (though I fixed them and submitted back my changes to the mailing list) but the framework is still in it’s infancy so glitches are to be expected. That aside, one thing I’m most excited about is the MVC controller. At work, we’re nearing the point in a long development cycle where we need to reconsider several aspects of our current framework, and implement something a little more up to date. So far, the Zend Framework is shaping up to fit nicely into place, but I’m a little unhappy about the routing scheme it has in place.
Currently, the Zend_Controller_Front’s router requires URI’s in the following format:
http://example.com/controller/action/key/val/key2/val2
I can live with the key/value part (there have been other methods suggested) but from experience, I know that restricting the controller to the first place in the URL isn’t going to cut it with my day to day clients. Also, I’d really, really like to be able to build a single ‘app’ and use multiple instances of it without modifying the existing code. If a client wants two catalogues, how would I do that in the existing framework without re-writing my code?
My solution
To illustrate the problems and solutions clearly, let’s look at a quick example. I’ll start with the same directory structure that Chris uses in his Zend Framework CRUD tutorial:
/app
/controller
/views
/lib
/Zend
/www
/index.php
Now let’s say a client has, for whatever reason, requested a nested URL for their site resembling:
http://example.com/brochure/springsummer/products/view/item/sku
They’ve already printed mailers with the URL so changing it isn’t an option. They want the ‘brochure’ page (http://example.com/brochure/) to be an editable HTML page and the ‘springsummer’ page (http://example.com/brochure/springsummer/) to be a ‘catalogue’ identical to the one they already have at http://example.com/catalogue/ but in this case it will be special and will have different items listed in it (so it’s a copy in functionality, not content).
With the current Zend Framework (0.1.2), the URL would break down as:
- controller: brochure
- action: catalogue
- params: products=view, item=sku
This breakdown isn’t very useful for this situation. In order to achieve what the client wants, I’d have to have the brochureController.php handle the call to the catalogue as well as the simple HTML page. As well, my controller file must to be named brochureController.php but I really want to be able to re-use my app modules in multiple instances so that http://example.com/brochure/springsummer/ and http://example.com/catalogue/ can use the same code base but with different database tables & content.
What I’d really like is:
http://example.com/brochure/ to map to:
- app: staticpage
- controller: index
- action: index
while at the same time have http://example.com/brochure/springsummer/products/view/item/sku map to:
- app: catalogue
- controller: products
- action: view
- params: item=sku
The Code
Before I go further I must note that Zend has said they will be implementing a new map based routing method in the future. With that in mind, be sure to use the Zend_Controller_Front::setRouter(); method to invoke your own router. Don’t mess with the Zend libraries directly. There’s nothing stopping you from just modifying Zend_Controller_Router, but then when the framework is updated it’s going to be a big hassle for you to re-integrate Zend’s changes into your own. If you use the supplied setRouter() method, you can just switch your router off and use Zend’s, or you can continue to use your own with the updated framework intact.
To add the new features, first I’ll need a database to store my routes and we’ll create a few entries for testing (I’ll use SQLite because it’s quick):
<?php
$db = new SQLiteDatabase('/path/to/db.sqlite');
$db->query("
CREATE TABLE routes (
id INTEGER PRIMARY KEY,
uri VARCHAR(255),
app VARCHAR(255),
instance VARCHAR(255)
);");
$db->query("INSERT INTO routes (uri,app,instance) VALUES ('/','staticpage','root');");
$db->query("INSERT INTO routes (uri,app,instance) VALUES ('/brochure','staticpage','brochure_page');");
$db->query("INSERT INTO routes (uri,app,instance) VALUES ('/brochure/springsummer','catalogue','springsummer_catalogue');");
?>
The table consists of four fields: the primary id, the uri where the instance of the app is installed, the app which defines which of the apps to use, and finally the instance which is a simple string that can be used within the aps to determine which database/table/content to retrieve. I won’t be explaining the use of instance any further in this post other than to say you could use instance as a prefix for tables in your DB or folder names so that the app could access tables by $instance.’_name’ and thus be able to pull and use different content based on the instance of installation.
Next, the bootstrap index.php file in the web root should be modified to add the new router and to only instantiate the controller, not the view.
<?php
//use the Zend Framwork
require_once 'Zend.php';
require_once 'My_Router.php';
Zend::register('db') = new SQLiteDatabase('/path/to/db.sqlite');
$controller = Zend_Controller_Front::getInstance();
$controller->setRouter(new My_Router());
$controller->dispatch();
?>
Next, I want to re-use instances of my apps, so they are created in their own sub directories within the app folder:
/app
/staticpage
/controller
/views
/catalogue
/controller
/views
/lib
/My_Router.php
/Zend.php
/Zend
/www
/index.php
Finally, my new router (My_Router.php) is set up using the same API as the Zend_Controller_Router. In the example below, I’ve chosen to include the original Zend_Controller_Router::route() source and only modify/add information above it. For your router, you can modify the code below to parse urls in a different format if you wish:
<?php
//require once the classes to extend etc.
require_once 'Zend/Controller/Router/Interface.php';
require_once 'Zend/Controller/Dispatcher/Interface.php';
require_once 'Zend/Controller/Router/Exception.php';
require_once 'Zend/Controller/Dispatcher/Token.php';
class My_Router implements Zend_Controller_Router_Interface {
public function route(Zend_Controller_Dispatcher_Interface $dispatcher) {
//retrieve the URI of the request
$uri = $_SERVER['REQUEST_URI'];
if(strlen($uri) > 1) {
//clean the url
$url = '/'.trim(preg_replace('|[^a-zA-Z0-9\-_\./#=]*|','',$uri),'/');
} else {
//they are requesting the home page (/).
$url ='/';
}
/*
* TRICKY:
* We want apps to be embeded into the url deep in the site.
# for example in http://www.example.com/a/b/c/d
* if we want (c) to ba an app, then (d) is assumed to be a controller
* for that app. In that case, then a and b MUST be a non-existent page or
* a staticpage app only, as all other modules assume everything past the
* instance are input to the app.
*
* Get the furthest out instance in the route table from the supplied url!
*/
$db = Zend::registry('db');
$result = $db->query("SELECT * FROM router WHERE position(lower(uri) in lower('$url'))=1 ORDER BY uri DESC LIMIT 1;");
$num = $result->numRows();
if($num <= 0) throw new Exception('404 Local Route not found and no home route (/) exists.');
//fix up the uri incase it was entered incorrectly in the DB
$obj = (object)$result->fetch();
$obj->uri = trim($obj->uri,'/');
if(strlen($obj->uri)>0) $obj->uri = "/{$obj->uri}/";
else $obj->uri = '/';
//load the controllers for this app
$dispatcher->setControllerDirectory("../app/$obj->app/controllers/");
//setup the view
$view = new Zend_View;
//views for this app
$view->addScriptPath("../app/{$obj->app}/views");
//pass in the route to the view so that we can use the instance
$view->route = $obj;
//register the view
Zend::register('view', $view);
//register the route for the controllers and views
Zend::register('route', $obj);
/*
* HACK
* Mess with the $_SERVER['REQUEST_URI'] so that the rest of the script is identical to Zend_Router
* Zend Uses: http://www.zend.com/controller-name/action-name/param-1/3/param-2/7
*/
$_SERVER['REQUEST_URI'] = preg_replace('#^'.rtrim($obj->uri,'/').'#i','',$_SERVER['REQUEST_URI']);
/*
* TRICKY
* Below here is right out of Zend/Controller/Route.php
* DO NOT MODIFY unless you want to really change things up
*/
$path = $_SERVER['REQUEST_URI'];
if (strstr($path, '?')) {
$path = substr($path, 0, strpos($path, '?'));
}
$path = explode('/', trim($path, '/'));
$controller = $path[0];
$action = isset($path[1]) ? $path[1] : null;
if (!strlen($controller)) {
$controller = 'index';
$action = 'index';
}
$params = array();
for ($i=2; $i<sizeof ($path); $i=$i+2) {
$params[$path[$i]] = isset($path[$i+1]) ? $path[$i+1] : null;
}
$actionObj = new Zend_Controller_Dispatcher_Token($controller, $action, $params);
if (!$dispatcher->isDispatchable($actionObj)) {
/**
* @todo error handling for 404's
*/
throw new Zend_Controller_Router_Exception('Request could not be mapped to a route.');
} else {
return $actionObj;
}
}
}
?>
Tada! Now the following will format properly:
http://example.com/brochure will map to:
- app: staticpage
- controller: index
- action: index
adn http://example.com/brochure/springsummer/products/view/item/sku will map to:
- app: catalogue
- controller: products
- action: view
- params: item=sku
Drawbacks
Obviously there is the possibility of collisions if the urls of nested apps overlap but that’s another discussion. For now, the staticpage app has no controllers and uses the default to pull a single HTML page based on the instance from the database. Feel free to modify the above code and have fun. If you come up with a better idea, let me know or submit it to the Zend Framework Mailing List!