Skip to content
Caution: You are browsing the legacy symfony 1.x part of this website.

Advanced Routing

Language

by Ryan Weaver

At its core, the routing framework is the map that links each URL to a specific location inside a symfony project and vice versa. It can easily create beautiful URLs while staying completely independent of the application logic. With advances made for in recent symfony versions, the routing framework now goes much further.

This chapter will illustrate how to create a simple web application where each client uses a separate subdomain (e.g. client1.mydomain.com and client2.mydomain.com). By extending the routing framework, this becomes quite easy.

note

This chapter requires that you use Doctrine as an ORM for your project.

Project Setup: A CMS for Many Clients

In this project, an imaginary company - Sympal Builder - wants to create a CMS so that their clients can build websites as subdomains of sympalbuilder.com. Specifically, client XXX can view its site at xxx.sympalbuilder.com and use the admin area at xxx.sympalbuilder.com/backend.php.

note

The Sympal name was borrowed from Jonathan Wage's Sympal, a content management framework (CMF) built with symfony.

This project has two basic requirements:

  • Users should be able to create pages and specify the title, content, and URL for those pages.

  • The entire application should be built inside one symfony project that handles the frontend and backend of all client sites by determining the client and loading the correct data based on the subdomain.

note

To create this application, the server will need to be setup to route all *.sympalbuilder.com subdomains to the same document root - the web directory of the symfony project.

The Schema and Data

The database for the project consists of Client and Page objects. Each Client represents one subdomain site and consists of many Page objects.

# config/doctrine/schema.yml
Client:
  columns:
    name:       string(255)
    subdomain:  string(50)
  indexes:
    subdomain_index:
      fields:   [subdomain]
      type:     unique
 
Page:
  columns:
    title:      string(255)
    slug:       string(255)
    content:    clob
    client_id:  integer
  relations:
    Client:
      alias:        Client
      foreignAlias: Pages
      onDelete:     CASCADE
  indexes:
    slug_index:
      fields:   [slug, client_id]
      type:     unique

note

While the indexes on each table are not necessary, they are a good idea as the application will be querying frequently based on these columns.

To bring the project to life, place the following test data into the data/fixtures/fixtures.yml file:

# data/fixtures/fixtures.yml
Client:
  client_pete:
    name:      Pete's Pet Shop
    subdomain: pete
  client_pub:
    name:      City Pub and Grill
    subdomain: citypub
 
Page:
  page_pete_location_hours:
    title:     Location and Hours | Pete's Pet Shop
    content:   We're open Mon - Sat, 8 am - 7pm
    slug:      location
    Client:    client_pete
  page_pub_menu:
    title:     City Pub And Grill | Menu
    content:   Our menu consists of fish, Steak, salads, and more.
    slug:      menu
    Client:    client_pub

The test data introduce two websites initially, each with one page. The full URL of each page is defined by both the subdomain column of the Client and the slug column of the Page object.

http://pete.sympalbuilder.com/location
http://citypub.sympalbuilder.com/menu

The Routing

Each page of a Sympal Builder website corresponds directly to a Page model object, which defines the title and content of its output. To link each URL specifically to a Page object, create an object route of type sfDoctrineRoute that uses the slug field. The following code will automatically look for a Page object in the database with a slug field that matches the url:

# apps/frontend/config/routing.yml
page_show:
  url:        /:slug
  class:      sfDoctrineRoute
  options:
    model:    Page
    type:     object
  params:
    module:   page
    action:   show

The above route will correctly match the http://pete.sympalbuilder.com/location page with the correct Page object. Unfortunately, the above route would also match the URL http://pete.sympalbuilder.com/menu, meaning that the restaurant's menu will be displayed on Pete's web site! At this point, the route is unaware of the importance of the client subdomains.

To bring the application to life, the route needs to be smarter. It should match the correct Page based on both the slug and the client_id, which can be determined by matching the host (e.g. pete.sympalbuilder.com) to the subdomain column on the Client model. To accomplish this, we'll leverage the routing framework by creating a custom routing class.

First, however, we need some background on how the routing system works.

How the Routing System Works

A "route", in symfony, is an object of type sfRoute that has two important jobs:

  • Generate a URL: For example, if you pass the page_show method a slug parameter, it should be able to generate a real URL (e.g. /location).

  • Match an incoming URL: Given the URL from an incoming request, each route must be able to determine if the URL "matches" the requirements of the route.

The information for individual routes is most commonly setup inside each application's config directory located at app/yourappname/config/routing.yml. Recall that each route is "an object of type sfRoute". So how do these simple YAML entries become sfRoute objects?

Routing Cache Config Handler

Despite the fact most routes are defined in a YAML file, each entry in this file is transformed into an actual object at request time via a special type of class called a cache config handler. The final result is PHP code representing each and every route in the application. While the specifics of this process are beyond the scope of this chapter, let's peak at the final, compiled version of the page_show route. The compiled file is located at cache/yourappname/envname/config/config_routing.yml.php for the specific application and environment. Below is a shortened version of what the page_show route looks like:

new sfDoctrineRoute('/:slug', array (
  'module' => 'page',
  'action' => 'show',
), array (
  'slug' => '[^/\\.]+',
), array (
  'model' => 'Page',
  'type' => 'object',
));

tip

The class name of each route is defined by the class key inside the routing.yml file. If no class key is specified, the route will default to be a class of sfRoute. Another common route class is sfRequestRoute which allows the developer to create RESTful routes. A full list of route classes and available options is available via The symfony Reference Book

Matching an Incoming Request to a Specific Route

One of the main jobs of the routing framework is to match each incoming URL with the correct route object. The sfPatternRouting class represents the core routing engine and is tasked with this exact task. Despite its importance, a developer will rarely interact directly with sfPatternRouting.

To match the correct route, sfPatternRouting iterates through each sfRoute and "asks" the route if it matches the incoming url. Internally, this means that sfPatternRouting calls the sfRoute::matchesUrl() method on each route object. This method simply returns false if the route doesn't match the incoming url.

However, if the route does match the incoming URL, sfRoute::matchesUrl() does more than simply return true. Instead, the route returns an array of parameters that are merged into the request object. For example, the url http://pete.sympalbuilder.com/location matches the page_show route, whose matchesUrl() method would return the following array:

array('slug' => 'location')

This information is then merged into the request object, which is why it's possible to access route variables (e.g. slug) from the actions file and other places.

$this->slug = $request->getParameter('slug');

As you may have guessed, overriding the sfRoute::matchesUrl() method is a great way to extend and customize a route to do almost anything.

Creating a Custom Route Class

In order to extend the page_show route to match based on the subdomain of the Client objects, we will create a new custom route class. Create a file named acClientObjectRoute.class.php and place it in the project's lib/routing directory (you'll need to create this directory):

// lib/routing/acClientObjectRoute.class.php
class acClientObjectRoute extends sfDoctrineRoute
{
  public function matchesUrl($url, $context = array())
  {
    if (false === $parameters = parent::matchesUrl($url, $context))
    {
      return false;
    }
 
    return $parameters;
  }
}

The only other step is to instruct the page_show route to use this route class. In routing.yml, update the class key on the route:

# apps/fo/config/routing.yml
page_show:
  url:        /:slug
  class:      acClientObjectRoute
  options:
    model:    Page
    type:     object
  params:
    module:   page
    action:   show

So far, acClientObjectRoute adds no additional functionality, but all the pieces are in place. The matchesUrl() method has two specific jobs.

Adding Logic to the Custom Route

To give the custom route the needed functionality, replace the contents of the acClientObjectRoute.class.php file with the following.

class acClientObjectRoute extends sfDoctrineRoute
{
  protected $baseHost = '.sympalbuilder.com';
 
  public function matchesUrl($url, $context = array())
  {
    if (false === $parameters = parent::matchesUrl($url, $context))
    {
      return false;
    }
 
    // return false if the baseHost isn't found
    if (strpos($context['host'], $this->baseHost) === false)
    {
      return false;
    }
 
    $subdomain = str_replace($this->baseHost, '', $context['host']);
 
    $client = Doctrine_Core::getTable('Client')
      ->findOneBySubdomain($subdomain)
    ;
 
    if (!$client)
    {
      return false;
    }
 
    return array_merge(array('client_id' => $client->id), $parameters);
  }
}

The initial call to parent::matchesUrl() is important as it runs through the normal route-matching process. In this example, since the URL /location matches the page_show route, parent::matchesUrl() would return an array containing the matched slug parameter.

array('slug' => 'location')

In other words, all the hard-work of route matching is done for us, which allows the remainder of the method to focus on matching based on the correct Client subdomain.

public function matchesUrl($url, $context = array())
{
  // ...
 
  $subdomain = str_replace($this->baseHost, '', $context['host']);
 
  $client = Doctrine_Core::getTable('Client')
    ->findOneBySubdomain($subdomain)
  ;
 
  if (!$client)
  {
    return false;
  }
 
  return array_merge(array('client_id' => $client->id), $parameters);
}

By performing a simple string replace, we can isolate the subdomain portion of the host and then query the database to see if any of the Client objects have this subdomain. If no Client objects match the subdomain, then we return false indicating that the incoming request does not match the route. However, if there is a Client object with the current subdomain, we merge an extra parameter, client_id into the returned array.

tip

The $context array passed to matchesUrl() is prepopulated with lot's of useful information about the current request, including the host, an is_secure boolean, the request_uri, the HTTP method and more.

But, what has the custom route really accomplished? The acClientObjectRoute class now does the following:

  • The incoming $url will only match if the host contains a subdomain belonging to one of the Client objects.

  • If the route matches, an additional client_id parameter for the matched Client object is returned and ultimately merged into the request parameters.

Leveraging the Custom Route

Now that the correct client_id parameter is being returned by acClientObjectRoute, we have access to it via the request object. For example, the page/show action could use the client_id to find the correct Page object:

public function executeShow(sfWebRequest $request)
{
  $this->page = Doctrine_Core::getTable('Page')->findOneBySlugAndClientId(
    $request->getParameter('slug'),
    $request->getParameter('client_id')
  );
 
  $this->forward404Unless($this->page);
}

note

The findOneBySlugAndClientId() method is a type of magic finder new in Doctrine 1.2 that queries for objects based on multiple fields.

As nice as this is, the routing framework allows for an even more elegant solution. First, add the following method to the acClientObjectRoute class:

protected function getRealVariables()
{
  return array_merge(array('client_id'), parent::getRealVariables());
}

With this final piece, the action can rely completely on the route to return the correct Page object. The page/show action can be reduced to a single line.

public function executeShow(sfWebRequest $request)
{
  $this->page = $this->getRoute()->getObject();
}

Without any additional work, the above code will query for a Page object based on both the slug and client_id columns. Additionally, like all object routes, the action will automatically forward to a 404 page if no corresponding object is found.

But how does this work? Object routes, like sfDoctrineRoute, which the acClientObjectRoute class extends, automatically query for the related object based on the variables in the url key of the route. For example, the page_show route, which contains the :slug variable in its url, queries for the Page object via the slug column.

In this application, however, the page_show route must also query for Page objects based on the client_id column. To do this, we've overridden the sfObjectRoute::getRealVariables(), which is called internally to determine which columns to use for the object query. By adding the client_id field to this array, the acClientObjectRoute will query based on both the slug and client_id columns.

note

Objects routes automatically ignore any variables that don't correspond to a real column. For example, if the URL key contains a :page variable, but no page column exists on the relevant table, the variable will be ignored.

At this point, the custom route class accomplishes everything needed with very little effort. In the next sections, we'll reuse the new route to create a client-specific admin area.

Generating the Correct Route

One small problem remains with how the route is generated. Suppose create a link to a page with the following code:

<?php echo link_to('Locations', 'page_show', $page) ?>
Generated url: /location?client_id=1

As you can see, the client_id was automatically appended to the url. This occurs because the route tries to use all its available variables to generate the url. Since the route is aware of both a slug parameter and a client_id parameter, it uses both when generating the route.

To fix this, add the following method to the acClientObjectRoute class:

protected function doConvertObjectToArray($object)
{
  $parameters = parent::doConvertObjectToArray($object);
 
  unset($parameters['client_id']);
 
  return $parameters;
}

When an object route is generated, it attempts to retrieve all of the necessary information by calling doConvertObjectToArray(). By default, the client_id is returned in the $parameters array. By unsetting it, however, we prevent it from being included in the generated url. Remember that we have this luxury since the Client information is held in the subdomain itself.

tip

You can override the doConvertObjectToArray() process entirely and handle it yourself by adding a toParams() method to the model class. This method should return an array of the parameters that you want to be used during route generation.

Route Collections

To finish the Sympal Builder application, we need to create an admin area where each individual Client can manage its Pages. To do this, we will need a set of actions that allows us to list, create, update, and delete the Page objects. As these types of modules are fairly common, symfony can generate the module automatically. Execute the following task from the command line to generate a pageAdmin module inside an application called backend:

$ php symfony doctrine:generate-module backend pageAdmin Page --with-doctrine-route --with-show

The above task generates a module with an actions file and related templates capable of making all the modifications necessary to any Page object. Lot's of customizations could be made to this generated CRUD, but that falls outside the scope of this chapter.

While the above task prepares the module for us, we still need to create a route for each action. By passing the --with-doctrine-route option to the task, each action was generated to work with an object route. This decreases the amount of code in each action. For example, the edit action contains one simple line:

public function executeEdit(sfWebRequest $request)
{
  $this->form = new PageForm($this->getRoute()->getObject());
}

In total, we need routes for the index, new, create, edit, update, and delete actions. Normally, creating these routes in a RESTful manner would require significant setup in routing.yml.

pageAdmin:
  url:         /pages
  class:       sfDoctrineRoute
  options:     { model: Page, type: list }
  params:      { module: page, action: index }
  requirements:
    sf_method: [get]
pageAdmin_new:
  url:        /pages/new
  class:      sfDoctrineRoute
  options:    { model: Page, type: object }
  params:     { module: page, action: new }
  requirements:
    sf_method: [get]
pageAdmin_create:
  url:        /pages
  class:      sfDoctrineRoute
  options:    { model: Page, type: object }
  params:     { module: page, action: create }
  requirements:
    sf_method: [post]
pageAdmin_edit:
  url:        /pages/:id/edit
  class:      sfDoctrineRoute
  options:    { model: Page, type: object }
  params:     { module: page, action: edit }
  requirements:
    sf_method: [get]
pageAdmin_update:
  url:        /pages/:id
  class:      sfDoctrineRoute
  options:    { model: Page, type: object }
  params:     { module: page, action: update }
  requirements:
    sf_method: [put]
pageAdmin_delete:
  url:        /pages/:id
  class:      sfDoctrineRoute
  options:    { model: Page, type: object }
  params:     { module: page, action: delete }
  requirements:
    sf_method: [delete]
pageAdmin_show:
  url:        /pages/:id
  class:      sfDoctrineRoute
  options:    { model: Page, type: object }
  params:     { module: page, action: show }
  requirements:
    sf_method: [get]

To visualize these routes, use the app:routes task, which displays a summary of every route for a specific application:

$ php symfony app:routes backend

>> app       Current routes for application "backend"
Name             Method Pattern
pageAdmin        GET    /pages
pageAdmin_new    GET    /pages/new
pageAdmin_create POST   /pages
pageAdmin_edit   GET    /pages/:id/edit
pageAdmin_update PUT    /pages/:id
pageAdmin_delete DELETE /pages/:id
pageAdmin_show   GET    /pages/:id

Replacing the Routes with a Route Collection

Fortunately, symfony provides a much easier way to specify all of the routes that belong to a traditional CRUD. Replace the entire content of routing.yml with one simple route.

pageAdmin:
  class:   sfDoctrineRouteCollection
  options:
    model:        Page
    prefix_path:  /pages
    module:       pageAdmin

Once again, execute the app:routes task to visualize all of the routes. As you'll see, all seven of the previous routes still exist.

$ php symfony app:routes backend

>> app       Current routes for application "backend"
Name             Method Pattern
pageAdmin        GET    /pages.:sf_format
pageAdmin_new    GET    /pages/new.:sf_format
pageAdmin_create POST   /pages.:sf_format
pageAdmin_edit   GET    /pages/:id/edit.:sf_format
pageAdmin_update PUT    /pages/:id.:sf_format
pageAdmin_delete DELETE /pages/:id.:sf_format
pageAdmin_show   GET    /pages/:id.:sf_format

Route collections are a special type of route object that internally represent more than one route. The sfDoctrineRouteCollection route, for example automatically generates the seven most common routes needed for a CRUD. Behind the scenes, sfDoctrineRouteCollection is doing nothing more than creating the same seven routes previously specified in routing.yml. Route collections basically exist as a shortcut to creating a common group of routes.

Creating a Custom Route Collection

At this point, each Client will be able to modify its Page objects inside a functioning crud via the URL /pages. Unfortunately, each Client can currently see and modify all Page objects - those both belonging and not belonging to the Client. For example, http://pete.sympalbuilder.com/backend.php/pages will render a list of both pages in the fixtures - the location page from Pete's Pet Shop and the menu page from City Pub.

To fix this, we'll reuse the acClientObjectRoute that was created for the frontend. The sfDoctrineRouteCollection class generates a group of sfDoctrineRoute objects. In this application, we'll need to generate a group of acClientObjectRoute objects instead.

To accomplish this, we'll need to use a custom route collection class. Create a new file named acClientObjectRouteCollection.class.php and place it in the lib/routing directory. Its content is incredibly straightforward:

// lib/routing/acClientObjectRouteCollection.class.php
class acClientObjectRouteCollection extends sfObjectRouteCollection
{
  protected
    $routeClass = 'acClientObjectRoute';
}

The $routeClass property defines the class that will be used when creating each underlying route. Now that each underlying routing is an acClientObjectRoute route, the job is actually done. For example, http://pete.sympalbuilder.com/backend.php/pages will now list only one page: the location page from Pete's Pet Shop. Thanks to the custom route class, the index action returns only Page objects related to the correct Client, based on the subdomain of the request. With just a few lines of code, we've created an entire backend module that can be safely used by multiple clients.

Missing Piece: Creating New Pages

Currently, a Client select box displays on the backend when creating or editing Page objects. Instead of allowing users to choose the Client (which would be a security risk), let's set the Client automatically based on the current subdomain of the request.

First, update the PageForm object in lib/form/PageForm.class.php.

public function configure()
{
  $this->useFields(array(
    'title',
    'content',
  ));
}

The select box is now missing from the Page forms as needed. However, when new Page objects are created, the client_id is never set. To fix this, manually set the related Client in both the new and create actions.

public function executeNew(sfWebRequest $request)
{
  $page = new Page();
  $page->Client = $this->getRoute()->getClient();
  $this->form = new PageForm($page);
}

This introduces a new function, getClient() which doesn't currently exist in the acClientObjectRoute class. Let's add it to the class by making a few simple modifications:

// lib/routing/acClientObjectRoute.class.php
class acClientObjectRoute extends sfDoctrineRoute
{
  // ...
 
  protected $client = null;
 
  public function matchesUrl($url, $context = array())
  {
    // ...
 
    $this->client = $client;
 
    return array_merge(array('client_id' => $client->id), $parameters);
  }
 
  public function getClient()
  {
    return $this->client;
  }
}

By adding a $client class property and setting it in the matchesUrl() function, we can easily make the Client object available via the route. The client_id column of new Page objects will now be automatically and correctly set based on the subdomain of the current host.

Customizing an Object Route Collection

By using the routing framework, we have now easily solved the problems posed by creating the Sympal Builder application. As the application grows, the developer will be able to reuse the custom routes for other modules in the backend area (e.g., so each Client can manage their photo galleries).

Another common reason to create a custom route collection is to add additional, commonly used routes. For example, suppose a project employs many models, each with an is_active column. In the admin area, there needs to be an easy way to toggle the is_active value for any particular object. First, modify acClientObjectRouteCollection and instruct it to add a new route to the collection:

// lib/routing/acClientObjectRouteCollection.class.php
protected function generateRoutes()
{
  parent::generateRoutes();
 
  if (isset($this->options['with_is_active']) && $this->options['with_is_active'])
  {
    $routeName = $this->options['name'].'_toggleActive';
 
    $this->routes[$routeName] = $this->getRouteForToggleActive();
  }
}

The sfObjectRouteCollection::generateRoutes() method is called when the collection object is instantiated and is responsible for creating all the needed routes and adding them to the $routes class property array. In this case, we offload the actual creation of the route to a new protected method called getRouteForToggleActive():

protected function getRouteForToggleActive()
{
  $url = sprintf(
    '%s/:%s/toggleActive.:sf_format',
    $this->options['prefix_path'],
    $this->options['column']
  );
 
  $params = array(
    'module' => $this->options['module'],
    'action' => 'toggleActive',
    'sf_format' => 'html'
  );
 
  $requirements = array('sf_method' => 'put');
 
  $options = array(
    'model' => $this->options['model'],
    'type' => 'object',
    'method' => $this->options['model_methods']['object']
  );
 
  return new $this->routeClass(
    $url,
    $params,
    $requirements,
    $options
  );
}

The only remaining step is to setup the route collection in routing.yml. Notice that generateRoutes() looks for an option named with_is_active before adding the new route. Adding this logic gives us more control in case we want to use the acClientObjectRouteCollection somewhere later that doesn't need the toggleActive route:

# apps/frontend/config/routing.yml
pageAdmin:
  class:   acClientObjectRouteCollection
  options:
    model:          Page
    prefix_path:    /pages
    module:         pageAdmin
    with_is_active: true

Check the app:routes task and verify that the new toggleActive route is present. The only remaining piece is to create the action that will do that actual work. Since you may want to use this route collection and corresponding action across several modules, create a new backendActions.class.php file in the apps/backend/lib/action directory (you'll need to create this directory):

# apps/backend/lib/action/backendActions.class.php
class backendActions extends sfActions
{
  public function executeToggleActive(sfWebRequest $request)
  {
    $obj = $this->getRoute()->getObject();
 
    $obj->is_active = !$obj->is_active;
 
    $obj->save();
 
    $this->redirect($this->getModuleName().'/index');
  }
}

Finally, change the base class of the pageAdminActions class to extend this new backendActions class.

class pageAdminActions extends backendActions
{
  // ...
}

What have we just accomplished? By adding a route to the route collection and an associated action in a base actions file, any new module can automatically use this functionality simply by using the acClientObjectRouteCollection and extending the backendActions class. In this way, common functionality can be easily shared across many modules.

Options on a Route Collection

Object route collections contain a series of options that allow it to be highly customized. In many cases, a developer can use these options to configure the collection without needing to create a new custom route collection class. A detailed list of route collection options is available via The symfony Reference Book.

Action Routes

Each object route collection accepts three different options which determine the exact routes generated in the collection. Without going into great detail, the following collection would generate all seven of the default routes along with an additional collection route and object route:

pageAdmin:
  class:   acClientObjectRouteCollection
  options:
    # ...
    actions:      [list, new, create, edit, update, delete, show]
    collection_actions:
      indexAlt:   [get]
    object_actions:
      toggle:     [put]

Column

By default, the primary key of the model is used in all of the generated urls and is used to query for the objects. This, of course, can easily be changed. For example, the following code would use the slug column instead of the primary key:

pageAdmin:
  class:   acClientObjectRouteCollection
  options:
    # ...
    column: slug

Model Methods

By default, the route retrieves all related objects for a collection route and queries on the specified column for object routes. If you need to override this, add the model_methods option to the route. In this example, the fetchAll() and findForRoute() methods would need to be added to the PageTable class. Both methods will receive an array of request parameters as an argument:

pageAdmin:
  class:   acClientObjectRouteCollection
  options:
    # ...
    model_methods:
      list:       fetchAll
      object:     findForRoute

Default Parameters

Finally, suppose that you need to make a specific request parameter available in the request for each route in the collection. This is easily done with the default_params option:

pageAdmin:
  class:   acClientObjectRouteCollection
  options:
    # ...
    default_params:
      foo:   bar

Final Thoughts

The traditional job of the routing framework - to match and generate urls - has evolved into a fully customizable system capable of catering to the most complex URL requirements of a project. By taking control of the route objects, the special URL structure can be abstracted away from the business logic and kept entirely inside the route where it belongs. The end result is more control, more flexibility and more manageable code.

This work is licensed under the Creative Commons Attribution-Share Alike 3.0 Unported License license.