Advanced Routing
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 aslug
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 thehost
contains a subdomain belonging to one of theClient
objects.If the route matches, an additional
client_id
parameter for the matchedClient
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.