Day 5: The Routing
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, 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 determine the action to call based on this URL? Why is the id
of
the job retrieved with $request->getParameter('id')
? Here, 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 as 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 indexSuccess.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')
in the action. 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 |
note
The module
and action
variables 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?module=job&action=show&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:
<!-- apps/frontend/templates/layout.php --> <h1> <a href="<?php echo url_for('homepage') ?>"> <img src="/legacy/images/logo.jpg" alt="Jobeet Job Board" /> </a> </h1>
That was easy!
tip
When you update the routing configuration, the changes are immediately
taken into account in the development environment. But to make them
also work in the production environment, you need to clear the cache by
calling the cache:clear
task.
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.
note
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_show_user
route at the beginning
of the file:
job_show_user: 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
At the beginning of the book, 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_show_user: 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
, HEAD
, 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_show_user: url: /job/:company/:location/:id/:position class: sfRequestRoute param: { module: job, action: show } requirements: id: \d+ sf_method: [get]
note
Requiring a route to only match for some HTTP methods is not
totally equivalent to using sfWebRequest::isMethod()
in your actions.
That's because the routing will continue to look for a matching route if the
method does not match the expected one.
Object Route Class
The new internal URI for a job is quite long and tedious to write
(url_for('job/show?id='.$job->getId().'&company='.$job->getCompany().'&location='.$job->getLocation().'&position='.$job->getPosition())
),
but as we have just learned in the previous section, the route class can be
changed. For the job_show_user
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()
|url_for()
helper call to:
url_for(array('sf_route' => 'job_show_user', 'sf_subject' => $job))
or just:
url_for('job_show_user', $job)
note
The first example is useful 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://www.jobeet.com.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; } }
note
In this tutorial, we never show the opening <?php
statement in the
code examples that only contain pure PHP code to optimize space and
save some trees. You should obviously remember to add it whenever you
create a new PHP file. Just remember to not add it to template files.
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]
You will now have the expected URLs:
http://www.jobeet.com.localhost/frontend_dev.php/job/sensio-labs/paris-france/1/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, change the executeShow()
method to
use the route object to retrieve the Jobeet
object:
class jobActions extends sfActions { public function executeShow(sfWebRequest $request) { $this->job = $this->getRoute()->getObject(); $this->forward404Unless($this->job); } // ... }
If you try to get a job for an unknown id
, you will see a 404 error page but
the error message has changed:
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(); } // ... }
tip
If you don't want the route to generate a 404 error, you can set the
allow_empty
routing option to true
.
note
The related object of a route is lazy loaded. It is only retrieved from the
database if you call the getRoute()
method.
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 take 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));
sidebar
The "redirect" Methods Family
Yesterday, we talked about the "forward" methods. These methods forward the current request to another action without a round-trip with the browser.
The "redirect" methods redirect the user to another URL. As with forward,
you can use the redirect()
method, or the redirectIf()
and
redirectUnless()
shortcut methods.
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. Open the routing.yml
file and modify it to read as
follows:
# apps/frontend/config/routing.yml job: class: sfPropelRouteCollection options: { model: JobeetJob } 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] # default rules homepage: url: / param: { module: job, action: index } default_index: url: /:module param: { action: index } default: url: /:module/:action/*
The job
route above is really just a shortcut that automatically generates 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 }
note
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/modules/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.
note
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 further in this book.
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. As the job
route defines all the routes needed to describe the Jobeet application, 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/*
The Jobeet application must still work as before.
Final Thoughts
Today was packed with 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.
Tomorrow, we won't introduce any new concept, but rather spend time going deeper into what we've covered so far.
This work is licensed under the Creative Commons Attribution-Share Alike 3.0 Unported License license.