Vanity URLs in Zend Framework

Posted

A question I've seen pop up in a few places is that of vanity URLs, and how to achieve them using frameworks. What I mean by a 'vanity URL' is one where you have a URL structure such as http://example.com/<something>, where something is some kind of user-generated string stored in the database, perhaps to give users a more memorable profile URL. Twitter is an obvious example of this, e.g. my twitter page is http://twitter.com/tfountain.

How would you setup routing for this in ZF? Easy, you might think, you just create a standard route containing a 'username' variable:

$route = new Zend_Controller_Router_Route(
    ':username',
    array(
        'controller' => 'users',
        'action' => 'profile'
    )
);

But what if you also wanted the URL http://twitter.com/about which routed to a different place? Your router has no way of knowing whether 'about' is a username or not, so you end up having to add a load of routes for your static pages, with your username route as the default one checked last.

What if you then want a top level vanity URL for another feature? Say you introduced a groups system, where groups could be setup with URLs like http://twitter.com/<group name>. You then have two identical URL structures which need to route to different places.

This is something that can be quite difficult to do with a lot of frameworks. Most follow the Rails-style approach where you define a load of URL patterns in a routes file along with details of where each should map to. This is easy to use and will cover 90% of routing cases. But for the other 10% you're screwed - you're left with a handful of messy options which either involve bypassing the routing process or compromising on your desired URL structure.

In my opinion the biggest strength of Zend Framework is its flexibility, and the routing is a great example of this. You can just load all your routes in from a file in ZF, but you can also create route objects and assign them to the router directly. Or define your own route types and assign these. Or extend the router class with some customisations. Or define your own router. or some combination of these options. There's a bigger learning curve, but you're able to setup your routing to match your application's needs, rather than having to change your application to work within the limitations of the framework.

In ZF a custom route class is the way to go to for vanity URLs. Remember how I said the router has no way of knowing whether 'about' is a username or not? If you create a 'username' route class that checks the database to see whether a particular string is a user or not, then suddenly your router can handle these routes like any other. You are also able to add additional route classes for any other vanity URL structures you need, and you can still use all the other route-related features of the framework such as the URL helper.

A custom route class at its most basic just needs to implement the Zend_Controller_Router_Route_Interface. This defines three methods:

interface Zend_Controller_Router_Route_Interface {
    public function match($path);
    public function assemble($data = array(), $reset = false, $encode = false);
    public static function getInstance(Zend_Config $config);
}

the match function is where the action is - this takes the path from the URL as a parameter, and should return an array of params to be passed to the controller if the route matched, false if it did not. The params array should also contain the module, controller and action name that tell the router which part of your application to route the request to.

I'd recommend extending the Zend_Controller_Router_Route_Abstract class, as this is used by all of the standard ZF route classes and will allow you to follow some of their conventions.

Here's an basic example for a username route:

<?php

class Application_Route_User extends Zend_Controller_Router_Route
{
    public static function getInstance(Zend_Config $config)
    {
        $defs = ($config->defaults instanceof Zend_Config) ? $config->defaults->toArray() : array();
        return new self($config->route, $defs);    
    }

    public function __construct($route, $defaults = array())
    {
        $this->_route = trim($route, $this->_urlDelimiter);
        $this->_defaults = (array)$defaults;
    }

    public function match($path, $partial = false)
    {
        if ($path instanceof Zend_Controller_Request_Http) {
            $path = $path->getPathInfo();
        }

        $path = trim($path, $this->_urlDelimiter);
        $pathBits = explode($this->_urlDelimiter, $path);

        if (count($pathBits) != 1) {
            return false;
        }

        // check database for this user
        $result = Zend_Registry::get('db')->fetchRow('SELECT userID, username FROM users WHERE username = ?', $pathBits[0]);
        if ($result) {
            // user found
            $values = $this->_defaults + $result;

            return $values;
        }

        return false;
    }

    public function assemble($data = array(), $reset = false, $encode = false)
    {
        return $data['username'];
    }
}

(This example assumes that there's a Zend_Db instance in the registry under the key 'db'.)

All it's doing is extracting the string from the path and looking this up in the database. If a match is found, it adds the user ID and username to an array that already contains the module, controller and action names and passes this back to the router.

To use this route you assign it to the router during your bootstrap:

    protected function _initRoutes()
    {
        $router = Zend_Controller_Front::getInstance()->getRouter();

        // general page route
        $router->addRoute('about', new Zend_Controller_Router_Route(
            ':page',
            array(
                'module' => 'default',
                'controller' => 'pages',
                'action' => 'show'
            )
        ));

        // username route
        $router->addRoute('user', new Application_Route_User(
            'user',
            array(
                'module' => 'default',
                'controller' => 'users',
                'action' => 'show'
            )
        ));
    }

You'll see I've included a page route here as well, and note that I can now use a more conventional :page variable route instead of a static route, as there's no longer any ambiguity. This way you don't have to modify your routes whenever you add a new page.

I've only scratched the surface of what you can do with ZF's routing. I've used a similar approach (custom route class) for hierarchical routes, where you have /category/sub-category/sub-sub-category type structure, but that is a topic for another blog post.

Comments (14) Tags: zend framework

Comments

Mario Affonso
12th Sep, 2010

This post is extraordinarily helpful to me. Cheers to the author!

Joseph
28th Oct, 2010

Hello,

I have tried to use your route but there is something wrong

The default route is working www.something.com/fr/controller/action the route for vanity url is ok too: www.something.com/fr/one-url-in-data-base

But if there are params (www.something.com/fr/one-url-in-data-base/key1/value1/key2/value2) is the default route that was used => I don't understand why.

Can you help me?

The routes:

$router = $this->_front_controller->getRouter();

// general purpose page route
$arr_cond_lang['route_str'] ='';
$arr_default['module']  ='default'; 
if(count($this->_arrLangAvailable)>1){
    $arr_cond_lang['route_str'] =':language/';
    $arr_default['language']    = NULL ;
}
$arr_default[ 'controller'] ='index';
$arr_default['action']      ='index';

//mvc route
$route = new Zend_Controller_Router_Route( $arr_cond_lang['route_str'].':@controller/:@action/*',$arr_default );

// pages route
$arr_default_page[ 'controller']='page';
$arr_default_page['action'] ='show';
$arr_default_page['page']   ='pagevar';

$routePage = new Bewaw_Route_Page(':page',$arr_default); //$arr_cond_lang['route_str'].'/:page'

$router->removeDefaultRoutes();

$router->addRoute('page', $routePage);

The class "Page":

class Bewaw_Route_Page extends Zend_Controller_Router_Route
{
    public $db = null;
    public $lang = null;
    
    public static function getInstance(Zend_Config $config)
    {
        $defs = ($config->defaults instanceof Zend_Config) ? $config->defaults->toArray() : array();
        return new self($config->route, $defs);    
    }

    public function __construct($route, $defaults = array())
    {
        $this->db       = Zend_Db_Table::getDefaultAdapter();
        $this->lang         = Zend_registry::get('Zend_Translate')->getLocale();
        $this->_route       = trim($route, $this->_urlDelimiter);
        $this->_defaults    = (array)$defaults;
    }

    public function match($path, $partial = false)
    {
        if ($path instanceof Zend_Controller_Request_Http) {
            $path = $path->getPathInfo();
        }

        $path = trim($path, $this->_urlDelimiter);
        $pathBits = explode($this->_urlDelimiter, $path);

        if (count($pathBits) != 2) {
            return false;
        }
            
        // check database for this user
        $result = $this->db->fetchRow('SELECT id_page, url, langs.id_lang FROM pages LEFT JOIN langs ON pages.id_lang = langs.id_lang WHERE url = ? AND lang_short_text=?', array($pathBits[1],$this->lang));

        if ($result) {
            // user found
            $values = $this->_defaults + $result;
            return $values;
        }

        return false;
    }
    
    //not used
    public function assemble($data = array(), $reset = false, $encode = false)
    {   
        return $data['page'];
    }
}

Joseph

Dave
8th Feb, 2011

Thanks Tim, you're a genius :)

Zimzat
21st Apr, 2011

The biggest problem I see with putting the username database route first is that we're now doing a database call for every request. Secondly, and a bigger one from a security standpoint, is that now users could name themselves the same as a system page, potentially phishing the official page. It would be much safer (and potentially faster) to have a dozen static routes enforced first and deal with the fallout of dynamic ones no longer working later.

It would seem trivial to set the username route as /u/:username, and groups as /g/:group, without requiring too much of the user.

Herbert Balagtas
3rd Feb, 2012

Querying the database is a concern for me too especially if you site gets a lot of traffic later on, but I guess this is where memcached or some other type of caching comes into play. Thanks for the tutorial it worked great, I was initially trying to use regex for routing.

Zend Framework
27th Mar, 2012

Works good to me. As Zend Framework runs under PHP5, so do this code runs under all PHP5 based frameworks.

Stephen
17th May, 2012

This is going to seem like a very basic question to many, but I'm very new to ZF. Bear with me!

In your 'match' function, you run a database query that returns stuff like 'id_page' and 'url'. This is added to the default variable (controller and action) before being returned back to the router.

So, now the router has got me to the correct controller and action, but how do I access the other parameters collected? How do I access the values for 'id_page' and 'url'?

Stephen
18th May, 2012

OK, looks like I solved it.
$this->getRequest()->getParam(0)->id_page

Varun Kumar Gautam
20th Oct, 2012

I want to use this but i dont know where to place this class for use in zend file structure. Please Help me for this.

Tim Fountain
6th Jan, 2013

@Stephen: Yes, all the request params are added to the request object (including the action, module and controller that were determined by the routing proceess). So you can access them with:

$this->getRequest()-getParam('id_page');

the syntax you used will work as well, but the above is the convention.

@Varun: The file just needs to be some where where it can be autoloaded. Again by convention in ZF1 these classes would sit in the library folder. The class name I used in my example was Application_Route_User, so this would live at library/Application/Router/User.php.

Derek
6th Jul, 2013

Does this work in ZF2?

Tim Fountain
7th Jul, 2013

The approach (custom route class) should work in ZF2, but the code will be a bit different. I'll probably do a blog post about this at some point in the future.

Omar
22nd Jul, 2013

Can you please add a ZF2 code version?

promtoe
31st Aug, 2013

I want to add :page and when use to :username and also :category:productname for SEO based URL but when use to one time use "addRoute()" function code working then not working for second url same code, any have idea how to code in zend framewrok 1.11 or 1.12

Comments are closed.