Jobeet - Day 5: The Routing

This post was published as part of the symfony 2008 advent calendar. As this tutorial might have been updated since then, you are advised to read the last version from the symfony 1.2 documentation (for Propel or Doctrine).

Before we start

Yesterday, we launched the Jobeet design contest. If you want to participate, we have prepared an archive with the main pages we will develop during the tutorial (the archive contains the static HTML files, the stylesheets, and the images). As the 21st day will be dedicated to the vote, you should send me (fabien.potencier [.at.] symfony-project.org) the mockups with your design before this date. Good luck!

Previously on Jobeet

If you've completed day 4, you should now be familiar with the MVC pattern and it should be feeling like a more and more natural way of coding. Spend a bit more time with it and you won't look back. To practice a bit yesterday, we customized the Jobeet pages and in the process, also reviewed several symfony concepts, like the layout, helpers, and slots.

Today we will dive into the wonderful world of the symfony routing framework.

URLs

If you click on a job on the Jobeet homepage, the URL looks like this: /job/show/id/1. If you have already developed PHP websites, you are probably more accustomed to URLs like /job.php?id=1. How does symfony make it work? How does symfony determines the action to call based on this URL? Why is the id of the job retrieved with $request->getParameter('id')? Today, we will answer all these questions.

But first, let's talk about URLs and what exactly they are. In a web context, a URL is the unique identifier of a web resource. When you go to a URL, you ask the browser to fetch a resource identified by that URL. So, as the URL is the interface between the website and the user, it must convey some meaningful information about the resource it references. But "traditional" URLs do not really describe the resource, they expose the internal structure of the application. The user does not care that your website is developed with the PHP language or that the job has a certain identifier in the database. Exposing the internal workings of your application is also quite bad as far security is concerned: What if the user tries to guess the URL for resources he does not have access to? Sure, the developer must secure them the proper way, but you'd better hide sensitive information.

URLs are so important in symfony that it has an entire framework dedicated to their management: the routing framework. The routing manages internal URIs and external URLs. When a request comes in, the routing parses the URL and converts it to an internal URI.

You have already seen the internal URI of the job page in the showSuccess.php template:

'job/show?id='.$job->getId()

The url_for() helper converts this internal URI to a proper URL:

/job/show/id/1

The internal URI is made of several parts: job is the module, show is the action and the query string adds parameters to pass to the action. The generic pattern for internal URIs is:

MODULE/ACTION?key=value&key_1=value_1&...

As the symfony routing is a two-way process, you can change the URLs without changing the technical implementation. This is one of the main advantages of the front-controller design pattern.

Routing Configuration

The mapping between internal URIs and external URLs is done in the routing.yml configuration file:

# apps/frontend/config/routing.yml
homepage:
  url:   /
  param: { module: default, action: index }
 
default_index:
  url:   /:module
  param: { action: index }
 
default:
  url:   /:module/:action/*
 

The routing.yml file describes routes. A route has a name (homepage), a pattern (/:module/:action/*), and some parameters (under the param key).

When a request comes in, the routing tries to match a pattern for the given URL. The first route that matches wins, so the order in routing.yml is important. Let's take a look at some examples to better understand how this works.

When you request the Jobeet homepage, which has the /job URL, the first route that matches is the default_index one. In a pattern, a word prefixed with a colon (:) is a variable, so the /:module pattern means: Match a / followed by something. In our example, the module variable will have job as a value. This value can then be retrieved with $request->getParameter('module'). This route also defines a default value for the action variable. So, for all URLs matching this route, the request will also have an action parameter with index as a value.

If you request the /job/show/id/1 page, symfony will match the last pattern: /:module/:action/*. In a pattern, a star (*) matches a collection of variable/value pairs separated by slashes (/):

Request parameter Value
module job
action show
id 1

The module and action variable are special as they are used by symfony to determine the action to execute.

The /job/show/id/1 URL can be created from a template by using the following call to the url_for() helper:

url_for('job/show?id='.$job->getId())
 

You can also use the route name by prefixing it by @:

url_for('@default?id='.$job->getId())
 

Both calls are equivalent but the latter is much faster as the routing does not have to parse all routes to find the best match, and it is less tied to the implementation (the module and action names are not present in the internal URI).

Route Customizations

For now, when you request the / URL in a browser, you have the default congratulations page of symfony. That's because this URL matches the homepage route. But it makes sense to change it to be the Jobeet homepage. To make the change, modify the module variable of the homepage route to job:

# apps/frontend/config/routing.yml
homepage:
  url:   /
  param: { module: job, action: index }
 

We can now change the link of the Jobeet logo in the layout to use the homepage route:

<h1>
  <a href="<?php echo url_for('@homepage') ?>">
    <img src="/images/jobeet.gif" alt="Jobeet Job Board" />
  </a>
</h1>
 

That was easy! For something a bit more involved, let's change the job page URL to something more meaningful:

/job/sensio-labs/paris-france/1/web-developer

Without knowing anything about Jobeet, and without looking at the page, you can understand from the URL that Sensio Labs is looking for a Web developer to work in Paris, France.

Pretty URLs are important because they convey information for the user. It is also useful when you copy and paste the URL in an email or to optimize your website for search engines.

The following pattern matches such a URL:

/job/:company/:location/:id/:position

Edit the routing.yml file and add the job route at the beginning of the file:

job:
  url:   /job/:company/:location/:id/:position
  param: { module: job, action: show }
 

If you refresh the Jobeet homepage, the links to jobs have not changed. That's because to generate a route, you need to pass all the required variables. So, you need to change the url_for() call in indexSuccess.php to:

url_for('job/show?id='.$job->getId().'&company='.$job->getCompany().
  '&location='.$job->getLocation().'&position='.$job->getPosition())
 

An internal URI can also be expressed as an array:

url_for(array(
  'module'   => 'job',
  'action'   => 'show',
  'id'       => $job->getId(),
  'company'  => $job->getCompany(),
  'location' => $job->getLocation(),
  'position' => $job->getPosition(),
))
 

Requirements

During the first day tutorial, we talked about validation and error handling for good reasons. The routing system has a built-in validation feature. Each pattern variable can be validated by a regular expression defined using the requirements entry of a route definition:

job:
  url:   /job/:company/:location/:id/:position
  param: { module: job, action: show }
  requirements:
    id: \d+
 

The above requirements entry forces the id to be a numeric value. If not, the route won't match.

Route Class

Each route defined in routing.yml is internally converted to an object of class sfRoute. This class can be changed by defining a class entry in the route definition. If you are familiar with the HTTP protocol, you know that it defines several "methods", like GET, POST, HEADER, DELETE, and PUT. The first three are supported by all browsers, while the other two are not.

To restrict a route to only match for certain request methods, you can change the route class to sfRequestRoute and add a requirement for the virtual sf_method variable:

job:
  url:   /job/:company/:location/:id/:position
  class: sfRequestRoute
  param: { module: job, action: show }
  requirements:
    id: \d+
    sf_method: [GET]
 

Requiring a route to only match for some HTTP methods is equivalent to using sfWebRequest::isMethod() in your actions.

Object Route Class

The new internal URI for a job is quite long and tedious to write, but as we have just learned in the previous section, the route class can be changed. For the job route, it is better to use sfPropelRoute as the class is optimized for routes that represent Propel objects or collections of Propel objects:

job_show_user:
  url:     /job/:company/:location/:id/:position
  class:   sfPropelRoute
  options: { model: JobeetJob, type: object }
  param:   { module: job, action: show }
  requirements:
    id: \d+
    sf_method: [GET]
 

The options entry customizes the behavior of the route. Here, the model option defines the Propel model class (JobeetJob) related to the route, and the type option defines that this route is tied to one object (you can also use list if a route represents a collection of objects).

The job_show_user route is now aware of its relation with JobeetJob and so we can simplify the url_for() call to:

url_for(array('sf_route' => 'job_show_user', 'sf_subject' => $job))
 

or:

url_for('job_show_user', $job)
 

The first example is better when you need to pass more arguments than just the object.

It works because all variables in the route have a corresponding accessor in the JobeetJob class (for instance, the company route variable is replaced with the value of getCompany()).

If you have a look at generated URLs, they are not quite yet as we want them to be:

http://jobeet.localhost/frontend_dev.php/job/Sensio+Labs/Paris%2C+France/1/Web+Developer

We need to "slugify" the column values by replacing all non ASCII characters by a -. Open the JobeetJob file and add the following methods to the class:

// lib/model/JobeetJob.php
public function getCompanySlug()
{
  return Jobeet::slugify($this->getCompany());
}
 
public function getPositionSlug()
{
  return Jobeet::slugify($this->getPosition());
}
 
public function getLocationSlug()
{
  return Jobeet::slugify($this->getLocation());
}
 

Then, create the lib/Jobeet.class.php file and add the slugify method in it:

// lib/Jobeet.class.php
class Jobeet
{
  static public function slugify($text)
  {
    // replace all non letters or digits by -
    $text = preg_replace('/\W+/', '-', $text);
 
    // trim and lowercase
    $text = strtolower(trim($text, '-'));
 
    return $text;
  }
}
 

We have defined three new "virtual" accessors: getCompanySlug(), getPositionSlug(), and getLocationSlug(). They return their corresponding column value after applying it the slugify() method. Now, you can replace the real column names by these virtual ones in the job_show_user route:

job_show_user:
  url:     /job/:company_slug/:location_slug/:id/:position_slug
  class:   sfPropelRoute
  options: { model: JobeetJob, type: object }
  param:   { module: job, action: show }
  requirements:
    id: \d+
    sf_method: [GET]
 

Before refreshing the Jobeet homepage, you need to clear your cache as we have added a new class (Jobeet):

$ php symfony cc

You will now have the expected URLs:

http://jobeet.localhost/frontend_dev.php/job/sensio-labs/paris-france/4/web-developer

But that's only half the story. The route is able to generate a URL based on an object, but it is also able to find the object related to a given URL. The related object can be retrieved with the getObject() method of the route object. When parsing an incoming request, the routing stores the matching route object for you to use in the actions. So, you can change the executeShow() method to use this method:

class jobActions extends sfActions
{
  public function executeShow(sfWebRequest $request)
  {
    $this->job = $this->getRoute()->getObject();
    $this->forward404Unless($this->getRoute()->getObject());
  }
 
  // ...
}
 

If you try to get a job for an unknown id, you will see a 404 error page but the error message has changed:

404 with sfPropelRoute

That's because the 404 error has been thrown for you automatically by the getRoute() method. So, we can simplify the executeShow method even more:

class jobActions extends sfActions
{
  public function executeShow(sfWebRequest $request)
  {
    $this->job = $this->getRoute()->getObject();
  }
 
  // ...
}
 

If you don't want the route to generate a 404 error, you can set the allow_empty routing option to true.

Routing in Actions and Templates

In a template, the url_for() helper converts an internal URI to an external URL. Some other symfony helpers also takes an internal URI as an argument, like the link_to() helper which generates an <a> tag:

<?php echo link_to($job->getPosition(), 'job_show_user', $job) ?>
 

It generates the following HTML code:

<a href="/job/sensio-labs/paris-france/1/web-developer">Web Developer</a>
 

Both url_for() and link_to() can also generate absolute URLs:

url_for('job_show_user', $job, true);
 
link_to($job->getPosition(), 'job_show_user', $job, true);
 

If you want to generate a URL from an action, you can use the generateUrl() method:

$this->redirect($this->generateUrl('job_show_user', $job));
 

Collection Route Class

For the job module, we have already customized the show action route, but the URLs for the others methods (index, new, edit, create, update, and delete) are still managed by the default route:

default:
  url: /:module/:action/*
 

The default route is a great way to start coding without defining too many routes. But as the route acts as a "catch-all", it cannot be configured for specific needs.

As all job actions are related to the JobeetJob model class, we can easily define a custom sfPropelRoute route for each as we have already done for the show action. But as the job module defines the classic seven actions possible for a model, we can also use the sfPropelRouteCollection class:

// apps/frontend/config/routing.yml
 
# put this definition just before the job_show_user one
job:
  class:   sfPropelRouteCollection
  options: { model: JobeetJob }
 

The job route above is really just a shortcut that automatically generate the following seven sfPropelRoute routes:

job:
  url:     /job.:sf_format
  class:   sfPropelRoute
  options: { model: JobeetJob, type: list }
  param:   { module: job, action: index, sf_format: html }
  requirements: { sf_method: GET }
 
job_new:
  url:     /job/new.:sf_format
  class:   sfPropelRoute
  options: { model: JobeetJob, type: object }
  param:   { module: job, action: new, sf_format: html }
  requirements: { sf_method: GET }
 
job_create:
  url:     /job.:sf_format
  class:   sfPropelRoute
  options: { model: JobeetJob, type: object }
  param:   { module: job, action: create, sf_format: html }
  requirements: { sf_method: POST }
 
job_edit:
  url:     /job/:id/edit.:sf_format
  class:   sfPropelRoute
  options: { model: JobeetJob, type: object }
  param:   { module: job, action: edit, sf_format: html }
  requirements: { sf_method: GET }
 
job_update:
  url:     /job/:id.:sf_format
  class:   sfPropelRoute
  options: { model: JobeetJob, type: object }
  param:   { module: job, action: update, sf_format: html }
  requirements: { sf_method: PUT }
 
job_delete:
  url:     /job/:id.:sf_format
  class:   sfPropelRoute
  options: { model: JobeetJob, type: object }
  param:   { module: job, action: delete, sf_format: html }
  requirements: { sf_method: DELETE }
 
job_show:
  url:     /job/:id.:sf_format
  class:   sfPropelRoute
  options: { model: JobeetJob, type: object }
  param:   { module: job, action: show, sf_format: html }
  requirements: { sf_method: GET }
 

Some routes generated by sfPropelRouteCollection have the same URL. The routing is still able to use them because they all have different HTTP method requirements.

The job_delete and job_update routes requires HTTP methods that are not supported by browsers (DELETE and PUT respectively). This works because symfony simulates them. Open the _form.php template to see an example:

// apps/frontend/job/templates/_form.php
<form action="..." ...>
<?php if (!$form->getObject()->isNew()): ?>
  <input type="hidden" name="sf_method" value="PUT" />
<?php endif; ?>
 
<?php echo link_to(
  'Delete',
  'job/delete?id='.$form->getObject()->getId(),
  array('method' => 'delete', 'confirm' => 'Are you sure?')
) ?>
 

All the symfony helpers can be told to simulate whatever HTTP method you want by passing the special sf_method parameter.

symfony has other special parameters like sf_method, all starting with the sf_ prefix. In the generated routes above, you can see another one: sf_format, which will be explained in a coming day.

Route Debugging

When you use collection routes, it is sometimes useful to list the generated routes. The app:routes task outputs all the routes for a given application:

$ php symfony app:routes frontend

You can also have a lot of debugging information for a route by passing its name as an additional argument:

$ php symfony app:routes frontend job_edit

Default Routes

It is a good practice to define routes for all your URLs. If you do so, go ahead and remove or comment the default routes from the routing.yml configuration file:

// apps/frontend/config/routing.yml
#default_index:
#  url:   /:module
#  param: { action: index }
#
#default:
#  url:   /:module/:action/*
 

See you Tomorrow

Today was packed will a lot of new information. You have learned how to use the routing framework of symfony and how to decouple your URLs from the technical implementation.

We will publish a new tutorial tomorrow, event though it is Saturday. We won't introduce any new concepts, but rather spend time going deeper into what we've covered so far.

The source code of Jobeet for today is available in the SVN repository:

http://svn.jobeet.org/tags/release_day_05/

Comments

How does the routing system make the correspondance between a route and an object once you have "slugified" the informations ? I mean, it is possible to slugify a name but not to get the original name from a slug.

What if you have two companies with different names but same slug (e.g : just one special character that makes the difference, so the slug will be the same) ? Here it works because we have the id (which is really unique and sufficient to determine the object to retrieve), but if we don't have ?
It's not a good practice to show primary keys in the URL. I'd like to see how to use sfRoutes to generate and read routes that don't include the id.
Thank you for your great document as always.

When a job information includes multibyte characters, the Jobeet::slugify() method will delete them and a generated url doen't work.

I don't have a smart solution, but I made a draft for that in my blog : http://blog.tic-toc.info/2008/12/06/try_to_do_symfony_jobeet_day_05/.
@Toc: You are going too fast ;) This will be fixed when we will learn how to create unit tests.
@Fabien: Thank you for your quick response. I'm looking forward to the way :)
In Windows I received a 500 error after changing the executeShow for this

$this->job = $this->getRoute()->getObject();
$this->forward404Unless($this->getRoute()->getObject());

When I click refresh it works. I tried everything and got the same result. What am I doing wrong ?
Sorry I forgot to mention that my PHP log show this :

Call to undefined method sfRequestRoute::getObject()
Still have the SVN access problem...
It all works, but when I visit the jobeet homepage my links look like:

http://jobeet/job/1?company=Sensio+Labs&location=Paris%2C+France&position=Web+Developer

And this is the code in the template:



I followed the routing correctly in the tutorial, any idea?
I have the SVN problem here, too. It is still asking for credentials.
@Jocelyn: Maybe you don't have replace first job route with new 'job_show_user' route in your routing.yml.

like this:


# default rules
job_show_user:
url: /job/:company_slug/:location_slug/:id/:position_slug
class: sfPropelRoute
options: { model: JobeetJob, type: object }
param: { module: job, action: show }
requirements:
id: \d+
sf_method: [GET]


homepage:
url: /
param: { module: job, action: index }

default_index:
url: /:module
param: { action: index }

default:
url: /:module/:action/*

Concerning the "slugify" stuff, i think that defining 3 specific new methods for one model object, only used to make "pretty" urls is quite uneficient in term of separation of concerns and code verbosity...

these "slug" methods do not add functionnal business value to the model object layer but just deals with representation of data, i think these methods should not figure in the model code.

wouldn't it have been more practical to enable the routing class to apply a filter on url strings ? ie : replace any non ascii characters with "-"...

Finally, the shortcut of using the routing mapping to directly retrieve an object ($this->getRoute()->getObject()) is quite scary... by reading the controler code, you have no idea of how the database is used (which critera) to retrieve the object... it may be quite tricky to optimize or to debug such code when you get an unexpected 404 error

i think i pretty much understand the concept of the "route class" but i think that coupling the routing logic with a specific orm is too dangerous and tie the application to much with propel.

I would be happy to discuss these subjects...
@Hadrien Boyé: You don't need to use $this->getRoute()->getObject(), it is just a shortcut. If not, there will be no performance penalty whatsoever. And everything is configurable. So, you know which method is used to retrieve the object or the collection of objects.
i try to use sfDoctrineRoute
job_show_user:
url: /job/:company_slug/:location_slug/:id/:position_slug
class: sfDoctrineRoute
options: { model: JobeetJob, type: object }
param: { module: job, action: show }
requirements:
id: \d+
sf_method: [GET]

but on show page: http://localhost/jobeet/web/frontend_dev.php/job/sensio-labs/paris-france/1/web-developer
have error 404 like:
Action "job/sensiolabs" does not exist.

how to use sfDoctrineRoute ?
I have

Fatal error: Call to undefined method sfRequestRoute::getObject() in C:\homserv\home\jobeet\apps\frontend\modules\job\actions\actions.class.php on line 20

after changing the executeShow for this

$this->job = $this->getRoute()->getObject();
Had the same fatal error.
Try to change to routing for job to:

job:
class: sfPropelRouteCollection
options: { model: JobeetJob }
DaneeL, thank you.
@Fabien : what about the "slug" methods in the model classes ?
instead of
$this->job = $this->getRoute()->getObject();
$this->forward404Unless($this->getRoute()->getObject());

its better to use the already received value, so:

$this->job = $this->getRoute()->getObject();
$this->forward404Unless($this->job);


Another little Typo:
The well known HTTP Method is called HEAD not HEADER.
I still have this fatal error:

Fatal error: Call to undefined method sfRoute::getObject() in /home/sfprojects/jobeet/apps/frontend/modules/job/actions/actions.class.php on line 20

I made the settings in routing.yml and in the showSuccess() method. In routing.yml, the first entry is

job:
class: sfPropelRouteCollection
options: { model: JobeetJob }

and thus should be taken over the
"job_show_user" route which worked without problem.

Can anyone help?
I also got the problem with:
Fatal error: Call to undefined method sfRequestRoute::getObject() in /.../jobeet/apps/frontend/modules/job/actions/actions.class.php on line 20
Remove this from routing.yml
job:
class: sfPropelRouteCollection
options: { model: JobeetJob }

then it will work.

Comments are closed.

To ensure that comments stay relevant, they are closed for old posts.