Yesterday you expanded your knowledge of symfony in a lot of different areas: querying with Doctrine, fixtures, routing, debugging, and custom configuration. And we finished with a little challenge for today.
I hope you worked on the Jobeet category page as today's tutorial will then be much more valuable for you.
Ready? Let's talk about a possible implementation.
The Category Route
First, we need to add a route to define a pretty URL for the category page. Add it at the beginning of the routing file:
# apps/frontend/config/routing.yml category: url: /category/:slug class: sfDoctrineRoute param: { module: category, action: show } options: { model: JobeetCategory, type: object }
tip
Whenever you start implementing a new feature, it is a good practice to first think about the URL and create the associated route. And it is mandatory if you removed the default routing rules.
A route can use any column from its related object as a parameter. It can also
use any other value if there is a related accessor defined in the object
class. Because the slug
parameter has no corresponding column in the
category
table, we need to add a virtual accessor in JobeetCategory
to
make the route works:
// lib/model/doctrine/JobeetCategory.class.php public function getSlug() { return Jobeet::slugify($this->getName()); }
The Category Link
Now, edit the indexSuccess.php
template of the job
module to add the link
to the category page:
<!-- some HTML code --> <h1> <?php echo link_to($category, 'category', $category) ?> </h1> <!-- some HTML code --> </table> <?php if (($count = $category->countActiveJobs() - sfConfig::get('app_max_jobs_on_homepage')) > 0): ?> <div class="more_jobs"> and <?php echo link_to($count, 'category', $category) ?> more... </div> <?php endif; ?> </div> <?php endforeach; ?> </div>
We only add the link if there are more than 10 jobs to display for the current
category. The link contains the number of jobs not displayed. For this
template to work, we need to add the countActiveJobs()
method to
JobeetCategory
:
// lib/model/doctrine/JobeetCategory.class.php public function countActiveJobs() { $q = Doctrine_Query::create() ->from('JobeetJob j') ->where('j.category_id = ?', $this->getId()); return Doctrine::getTable('JobeetJob')->countActiveJobs($q); }
The countActiveJobs()
method uses a countActiveJobs()
method that does not
exist yet in JobeetJobTable
. Replace the content of the JobeetJobTable.php
file with the following code:
// lib/model/doctrine/JobeetJobTable.class.php class JobeetJobTable extends Doctrine_Table { public function retrieveActiveJob(Doctrine_Query $q) { return $this->addActiveJobsQuery($q)->fetchOne(); } public function getActiveJobs(Doctrine_Query $q = null) { return $this->addActiveJobsQuery($q)->execute(); } public function countActiveJobs(Doctrine_Query $q = null) { return $this->addActiveJobsQuery($q)->count(); } public function addActiveJobsQuery(Doctrine_Query $q = null) { if (is_null($q)) { $q = Doctrine_Query::create() ->from('JobeetJob j'); } $alias = $q->getRootAlias(); $q->andWhere($alias . '.expires_at > ?', date('Y-m-d H:i:s', time())) ->addOrderBy($alias . '.created_at DESC'); return $q; } }
As you can see for yourself, we have refactored the whole code of
JobeetJobTable
to introduce a new shared addActiveJobsQuery()
method to
make the code more DRY (Don't Repeat Yourself).
tip
The first time a piece of code is re-used, copying the code may be sufficient. But if you find another use for it, you need to refactor all uses to a shared function or a method, as we have done here.
In the countActiveJobs()
method, instead of using execute()
and then
count the number of results, we have used the much faster count()
method.
We have changed a lot of files, just for this simple feature. But each time we
have added some code, we have tried to put it in the right layer of the
application and we have also tried to make the code reusable. In the process,
we have also refactored some existing code. That's a typical workflow when
working on a symfony project. In the following screenshot we are showing 5 jobs
to keep it short, you should see 10 (the max_jobs_on_homepage
setting):
Job Category Module Creation
It's time to create the category
module:
$ php symfony generate:module frontend category
If you have created a module, you have probably used the
doctrine:generate-module
. That's fine but as we won't need 90% of the
generated code, I have used the generate:module
which creates an empty module.
tip
Why not add a category
action to the job
module? We could, but as the
main subject of the category page is a category, it feels more natural to
create a dedicated category
module.
When accessing the category page, the category
route will have to find the
category associated with the request slug
variable. But as the slug is not
stored in the database, and because we cannot deduce the category name from
the slug, there is no way to find the category associated with the slug.
Update the Database
We need to add a slug
column for the category
table:
This slug
column can be taken care of by a Doctrine behavior named Sluggable
.
We simply need to enable the behavior on our JobeetCategory
model and it will
take care of everything for you.
# config/doctrine/schema.yml JobeetCategory: actAs: Timestampable: ~ Sluggable: fields: [name] columns: name: type: string(255) notnull: true
Now that slug
is a real column, you need to remove the getSlug()
method
from JobeetCategory
.
note
The setting of the slug column is taken care of automatically when you save a
record. The slug is built using the value of the name
field and set to the
object.
Use the doctrine:build-all-reload
task to update the database tables, and
repopulate the database with our fixtures:
$ php symfony doctrine:build-all-reload --no-confirmation
We have now everything in place to create the executeShow()
method. Replace
the content of the category
actions file with the following code:
// apps/frontend/modules/category/actions/actions.class.php class categoryActions extends sfActions { public function executeShow(sfWebRequest $request) { $this->category = $this->getRoute()->getObject(); } }
note
Because we have removed the generated executeIndex()
method, you can also
remove the automatically generated indexSuccess.php
template
(apps/frontend/modules/category/templates/indexSuccess.php
).
The last step is to create the showSuccess.php
template:
// apps/frontend/modules/category/templates/showSuccess.php <?php use_stylesheet('jobs.css') ?> <?php slot('title', sprintf('Jobs in the %s category', $category->getName())) ?> <div class="category"> <div class="feed"> <a href="">Feed</a> </div> <h1><?php echo $category ?></h1> </div> <table class="jobs"> <?php foreach ($category->getActiveJobs() as $i => $job): ?> <tr class="<?php echo fmod($i, 2) ? 'even' : 'odd' ?>"> <td class="location"> <?php echo $job->getLocation() ?> </td> <td class="position"> <?php echo link_to($job->getPosition(), 'job_show_user', $job) ?> </td> <td class="company"> <?php echo $job->getCompany() ?> </td> </tr> <?php endforeach; ?> </table>
Partials
Notice that we have copied and pasted the <table>
tag that create a list of
jobs from the job indexSuccess.php
template. That's bad. Time to learn a new
trick. When you need to reuse some portion of a template, you need to create a
partial. A partial is a snippet of template
code that can be shared among several templates. A partial is just another template
that starts with an underscore (_
).
Create the _list.php
file:
// apps/frontend/modules/job/templates/_list.php <table class="jobs"> <?php foreach ($jobs as $i => $job): ?> <tr class="<?php echo fmod($i, 2) ? 'even' : 'odd' ?>"> <td class="location"> <?php echo $job->getLocation() ?> </td> <td class="position"> <?php echo link_to($job->getPosition(), 'job_show_user', $job) ?> </td> <td class="company"> <?php echo $job->getCompany() ?> </td> </tr> <?php endforeach; ?> </table>
You can include a partial by using the ~include_partial()~
helper:
<?php include_partial('job/list', array('jobs' => $jobs)) ?>
The first argument of include_partial()
is the partial name (made of the
module name, a /
, and the partial name without the leading _
). The second
argument is an array of variables to pass to the partial.
note
Why not use the PHP built-in include()
method instead of the
include_partial()
helper? The main difference between the two is the
built-in cache support of the include_partial()
helper.
Replace the <table>
HTML code from both templates with the call to
include_partial()
:
// in apps/frontend/modules/job/templates/indexSuccess.php <?php include_partial('job/list', array('jobs' => $category->getActiveJobs(sfConfig::get('app_max_jobs_on_homepage')))) ?> // in apps/frontend/modules/category/templates/showSuccess.php <?php include_partial('job/list', array('jobs' => $category->getActiveJobs())) ?>
List Pagination
From day 2 requirements:
"The list is paginated with 20 jobs per page."
To paginate a list of Doctrine objects, symfony provides a dedicated class:
sfDoctrinePager
.
In the category
action, instead of passing the job objects to the
showSuccess
template, we pass a pager:
// apps/frontend/modules/category/actions/actions.class.php public function executeShow(sfWebRequest $request) { $this->category = $this->getRoute()->getObject(); $this->pager = new sfDoctrinePager( 'JobeetJob', sfConfig::get('app_max_jobs_on_category') ); $this->pager->setQuery($this->category->getActiveJobsQuery()); $this->pager->setPage($request->getParameter('page', 1)); $this->pager->init(); }
tip
The sfRequest::getParameter()
method takes a default value as a second
argument. In the action above, if the page
request parameter does not
exist, then getParameter()
will return 1
.
The sfDoctrinePager
constructor takes a model class and the maximum number of
items to return per page. Add the latter value to your configuration file:
# apps/frontend/config/app.yml all: active_days: 30 max_jobs_on_homepage: 10 max_jobs_on_category: 20
The sfDoctrinePager::setQuery()
method takes a Doctrine_Query
object to use when
selecting the items from the database.
Add the getActiveJobsQuery()
method:
// lib/model/doctrine/JobeetCategory.class.php public function getActiveJobsQuery() { $q = Doctrine_Query::create() ->from('JobeetJob j') ->where('j.category_id = ?', $this->getId()); return Doctrine::getTable('JobeetJob')->addActiveJobsQuery($q); }
Now that we have defined the getActiveJobsQuery()
method, we can refactor
other JobeetCategory
methods to use it:
// lib/model/doctrine/JobeetCategory.class.php public function getActiveJobs($max = 10) { $q = $this->getActiveJobsQuery() ->limit($max); return $q->execute(); } public function countActiveJobs() { return $this->getActiveJobsQuery()->count(); }
Finally, let's update the template:
<!-- apps/frontend/modules/category/templates/showSuccess.php --> <?php use_stylesheet('jobs.css') ?> <?php slot('title', sprintf('Jobs in the %s category', $category->getName())) ?> <div class="category"> <div class="feed"> <a href="">Feed</a> </div> <h1><?php echo $category ?></h1> </div> <?php include_partial('job/list', array('jobs' => $pager->getResults())) ?> <?php if ($pager->haveToPaginate()): ?> <div class="pagination"> <a href="<?php echo url_for('category', $category) ?>?page=1"> <img src="/legacy/images/first.png" alt="First page" /> </a> <a href="<?php echo url_for('category', $category) ?>?page=<?php echo $pager->getPreviousPage() ?>"> <img src="/legacy/images/previous.png" alt="Previous page" title="Previous page" /> </a> <?php foreach ($pager->getLinks() as $page): ?> <?php if ($page == $pager->getPage()): ?> <?php echo $page ?> <?php else: ?> <a href="<?php echo url_for('category', $category) ?>?page=<?php echo $page ?>"><?php echo $page ?></a> <?php endif; ?> <?php endforeach; ?> <a href="<?php echo url_for('category', $category) ?>?page=<?php echo $pager->getNextPage() ?>"> <img src="/legacy/images/next.png" alt="Next page" title="Next page" /> </a> <a href="<?php echo url_for('category', $category) ?>?page=<?php echo $pager->getLastPage() ?>"> <img src="/legacy/images/last.png" alt="Last page" title="Last page" /> </a> </div> <?php endif; ?> <div class="pagination_desc"> <strong><?php echo $pager->getNbResults() ?></strong> jobs in this category <?php if ($pager->haveToPaginate()): ?> - page <strong><?php echo $pager->getPage() ?>/<?php echo $pager->getLastPage() ?></strong> <?php endif; ?> </div>
Most of this code deals with the links to other pages. Here are the list of
sfDoctrinePager
methods used in this template:
getResults()
: Returns an array of Doctrine objects for the current pagegetNbResults()
: Returns the total number of resultshaveToPaginate()
: Returnstrue
if there is more than one pagegetLinks()
: Returns a list of page links to displaygetPage()
: Returns the current page numbergetPreviousPage()
: Returns the previous page numbergetNextPage()
: Returns the next page numbergetLastPage()
: Returns the last page number
See you Tomorrow
If you worked on your own implementation yesterday and feel that you didn't learn much today, it means that you are getting used to the symfony philosophy. The process to add a new feature to a symfony website is always the same: think about the URLs, create some actions, update the model, and write some templates. And, if you can apply some good development practices to the mix, you will become a symfony master very fast.
Tomorrow will be the start of a new week for Jobeet. To celebrate, we will talk about a brand new topic: tests.
This work is licensed under the Creative Commons Attribution-Share Alike 3.0 Unported License license.