2852232536_6cbb8820e1_b

A Request needs a routing system to find its way

This is the third post of the Symfony2 components series, and this time we are going to cover the Routing component, which maps an HTTP request to a set of configuration variables, which can then be used to execute code using PHP callables. The component simplifies the task of defining friendly URLs, without having to worry about complex and cryptic .htaccess files – hurray!.

The Routing component

The component provides a routing mechanism split in four main parts:

  • RouteCollection: A collection of routes, which map URLs to a set of variables. A RouteCollection represents a set of Route instances.
  • RequestContext: The component maps URLs, so it needs the request information to match a route. The RequestContext holds information about the current request.
  • UrlMatcher: The UrlMatcher matches a URL based on a collection of routes – RouteCollection – and a RequestContext.
  • UrlGenerator: Generates URLs based on a collection of routes, so instead of using /about in our routes, we generate the path using the route name ‘about’. That way, if the path changes tomorrow, all links will be still valid.

Simple example

As always, the best way to understand how the component works is through an example.  I’ll define a collection with two routes, create a RequestContext – basically is the current Request – and finally execute the match expecting an array of values.

use Symfony\Component\Routing\Matcher\UrlMatcher;
use Symfony\Component\Routing\RequestContext;
use Symfony\Component\Routing\RouteCollection;
use Symfony\Component\Routing\Route;
use Symfony\Component\HttpFoundation\Request;

$collection = new RouteCollection();

$collection->add('help', new Route('/help', array(
    'controller' => 'HelpController',
    'action' => 'indexAction'
)));
$collection->add('about', new Route('/about', array(
    'controller' => 'AboutController',
    'action' => 'indexAction'
)));
$context = new RequestContext();
$context->fromRequest(Request::createFromGlobals());
$matcher = new UrlMatcher($collection, $context);
print_r($matcher->match('/help'));

As a result, we get an array containing the default parameter values of the matched route:

Array
(
    [controller] => HelpController
    [action] => indexAction
    [_route] => help
)

Then, the controller and the action parameters can be used to execute the method indexAction() located in the HelpController class:

call_user_func_array(array($controller, $action), array());

But, what if we want to have placeholders in the URL to match both /users/john and /users/peter? That’s easy:

$collection->add('user_profile', new Route('/users/{username}', array(
    'controller' => 'UserController',
    'action' => 'profileAction'
)));
...
print_r($matcher->match('/users/peter'));

Will return:

Array
(
    [controller] => UserController
    [action] => profileAction
    [username] => peter
    [_route] => user_profile
)

Then, parameters can be passed to the required action:

$params = $matcher->match('/users/peter'));
call_user_func_array(array($params['controller'], $params['action']), array($params['username']));

Routes accept extra parameters to add constraints, for example, to validate usernames. If your application allows usernames with a length between 3 and 15 characters, the restriction can be added in the routing:

$collection->add('user_profile', new Route('/users/{username}', array(
    'controller' => 'UserController',
    'action' => 'profileAction'
), array('username' => '.{1,10}')));

Other restrictions can be applied to enforce an schema (http, https…), HTTP method (GET, POST, PUT…) or even enforce a host matching. Finally, generating a user’s profile URL is as simple as this:

use Symfony\Component\Routing\Generator\UrlGenerator;

$generator = new UrlGenerator($collection, $context);

echo $generator->generate('user_profile', array(
    'username' => 'peter'
)); // prints '/users/peter'

Performance

Okay, this is cool, but… what price do we have to pay? Obviously, having some code parsing every single request coming to our application adds an overhead. Looking carefully at the method that actually does the match, makes it clear that as number of routes increase, the overhead can be quite considerable:

// Symfony\Component\Routing\Matcher\UrlMatcher

protected function matchCollection($pathinfo, RouteCollection $routes)
{
    foreach ($routes as $name => $route) {
        $compiledRoute = $route->compile();

        // check the static prefix of the URL first. Only use the more expensive preg_match when it matches
        if ('' !== $compiledRoute->getStaticPrefix() && 0 !== strpos($pathinfo, $compiledRoute->getStaticPrefix())) {
            continue;
        }

        if (!preg_match($compiledRoute->getRegex(), $pathinfo, $matches)) {
            continue;
        }

        $hostMatches = array();
        if ($compiledRoute->getHostRegex() && !preg_match($compiledRoute->getHostRegex(), $this->context->getHost(), $hostMatches)) {
            continue;
        }

        // check HTTP method requirement
        if ($req = $route->getRequirement('_method')) {
            // HEAD and GET are equivalent as per RFC
            if ('HEAD' === $method = $this->context->getMethod()) {
                $method = 'GET';
            }

            if (!in_array($method, $req = explode('|', strtoupper($req)))) {
                $this->allow = array_merge($this->allow, $req);

                continue;
            }
        }

        $status = $this->handleRouteRequirements($pathinfo, $name, $route);

        if (self::ROUTE_MATCH === $status[0]) {
            return $status[1];
        }

        if (self::REQUIREMENT_MISMATCH === $status[0]) {
            continue;
        }

        return $this->getAttributes($route, $name, array_replace($matches, $hostMatches));
    }
}

The matcher iterates over all the routes of the collection, compile them and then start matching the URL. The compile process convert a Route into a CompiledRoute, which is optimized to perform matches. Basically, generates a single regular expression based on the route requirements.

use Symfony\Component\Routing\Matcher\Dumper\PhpMatcherDumper;

$route = new Route('/users/{username}', array(
    'controller' => 'UserController',
    'action' => 'profileAction'
), array('username' => '.{1,10}'));

$compiledRoute = $route->compile();

echo $compiledRoute->getRegex(); // prints '#^/users/(?P.{1,10})$#s'

The problem is that this compilation has to be done in every single request for every single route. To overcome this limitation, the component provides dumpers, which are able to dump optimized matchers/generators. For example, the PhpMatcherDumper generates a PHP class – extending from UrlMatcher – with an optimized match() method:

public function match($pathinfo)
{
    $allow = array();
    $pathinfo = rawurldecode($pathinfo);

    // user_profile
    if (0 === strpos($pathinfo, '/users') && preg_match('#^/users/(?P.{1,10})$#s', $pathinfo, $matches)) {
        return $this->mergeDefaults(array_replace($matches, array('_route' => 'user_profile')), array (  'controller' => 'UserController',  'action' => 'profileAction',));
    }

    throw 0 < count($allow) ? new MethodNotAllowedException(array_unique($allow)) : new ResourceNotFoundException();
}

And if you are really concerned about performance and don’t want to have the routing system in PHP, routes can also be dumped into an .htaccess file:

# skip "real" requests
RewriteCond %{REQUEST_FILENAME} -f
RewriteRule .* - [QSA,L]

# user_profile
RewriteCond %{REQUEST_URI} ^/users/(.{1,10})$
RewriteRule .* app.php [QSA,L,E=_ROUTING_route:user_profile,E=_ROUTING_param_username:%1,E=_ROUTING_default_controller:UserController,E=_ROUTING_default_action:profileAction]

More info

Photo: The Road AheadMichael Krigsman