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.
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
.
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.
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
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: /
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', ));
The class name of each route is defined by the
class
key inside therouting.yml
file. If noclass
key is specified, the route will default to be a class ofsfRoute
. Another common route class issfRequestRoute
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: /
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.
The
$context
array passed tomatchesUrl()
is prepopulated with lot's of useful information about the current request, including thehost
, anis_secure
boolean, therequest_uri
, the HTTPmethod
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); }
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.
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 nopage
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.
You can override the
doConvertObjectToArray()
process entirely and handle it yourself by adding atoParams()
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
.
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.
インデックス
Document Index
関連ページリスト
Related Pages
- Introduction
- Advanced Routing
- Enhance your Productivity
- Emails
- Custom Widgets and Validators
- Advanced Forms
- Extending the Web Debug Toolbar
- Advanced Doctrine Usage
- Taking Advantage of Doctrine Table Inheritance
- Symfony Internals
- Windows and symfony
- Developing for Facebook
- Leveraging the Power of the Command Line
- Playing with symfony's Config Cache
- Working with the symfony Community
- Appendix A - JavaScript code for sfWidgetFormGMapAddress
- About the Authors
- Appendix B - Custom Installer Example
- Appendix C - License
日本語ドキュメント
Japanese Documents
- 2011/01/18 Chapter 17 - Extending Symfony
- 2011/01/18 The generator.yml Configuration File
- 2011/01/18 Les tâches
- 2011/01/18 Emails
- 2010/11/26 blogチュートリアル(8) ビューの作成