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
andaction
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:
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 totrue
.
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));
The "redirect" Methods Family
In yesterday's tutorial, 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 theredirectIf()
andredirectUnless()
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:
// 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 thesf_
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/
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
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:
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:
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.