

Day 13: Tags


Previously on symfony




The QuestionTag class

There are several ways to implement tags. We chose to add a QuestionTag table with the following structure:


When a user tags a question, it creates a new record in the question_tag table, linked to both the user table and the question table. There are two versions of the tag recorded: The one entered by the user, and a normalized version (all lower case, without any special character) used for indexing.

Schema update

As usual, adding a table to a symfony project is done by appending its Propel definition to the schema.xml file:

<table name="ask_question_tag" phpName="QuestionTag">
  <column name="question_id" type="integer" primaryKey="true" />
  <foreign-key foreignTable="ask_question">
    <reference local="question_id" foreign="id" />
  <column name="user_id" type="integer" primaryKey="true" />
  <foreign-key foreignTable="ask_user">
    <reference local="user_id" foreign="id" />
  <column name="tag" type="varchar" size="100" />
  <column name="normalized_tag" type="varchar" size="100" primaryKey="true" />
  <index name="normalized_tag_index">
    <index-column name="normalized_tag" />

Rebuild the object model:

$ symfony propel-build-model

Custom class

Add a new Tag.class.php in the askeet/lib/ directory with the following methods:

class Tag
  public static function normalize($tag)
    $n_tag = strtolower($tag);
    // remove all unwanted chars
    $n_tag = preg_replace('/[^a-zA-Z0-9]/', '', $n_tag);
    return trim($n_tag);
  public static function splitPhrase($phrase)
    $tags = array();
    $phrase = trim($phrase);
    $words = preg_split('/(")/', $phrase, -1, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE);
    $delim = 0;
    foreach ($words as $key => $word)
      if ($word == '"')
        $tags[] = trim($word);
    return $tags;


The interest of adding the class in the lib/ directory is that it will be loaded automatically and only when needed, without needing to require it. It's called autoloading.

Extend the model

In the new askeet/lib/model/QuestionTag.php, add the following method to set the normalized_tag when the tag is set:

public function setTag($v)


Add some test data

Append a file to the askeet/data/fixtures/ directory with some tag test data in it:


Make sure this file comes after the other files of the directory in the alphabetical order, so that the sfPropelData object can link these new records with the related records of the Question and User tables. You can now repopulate your database by calling:

$ php batch/load_data.php

We are now ready to work on tags in the actions. But first, let us extend the model for the Question class.

Display the tags of a question

Before adding anything to the controller layer, let's add a new tag module so that things keep organized:

$ symfony init-module frontend tag

Extend model

We will need to display the whole list of words tagged by all users for a given question. As the ability to retrieve the related tags should be a method of the Question class, we will extend it (in askeet/lib/model/Question.php). The trick here is to group double entries to avoid double tags (two identical tags should only appear once in the result). The new method has to return a tag array:

public function getTags()
  $c = new Criteria();
  $tags = array();
  $rs = QuestionTagPeer::doSelectRS($c);
  while ($rs->next())
  return $tags;

This time, as we need only one column (the normalized_tag), there is no point to ask Propel to return an array of Tag objects populated from the database (this process, by the way, is called hydrating). So we do a simple query that we parse into an array, which is much faster.

Modify the view

The question detail page should now display the list of tags for a given question. We will use the sidebar for that. As it has been built as a component slot during the seventh day, we can set a specific component for this bar in the question module only.

So in askeet/apps/frontend/modules/question/config/view.yml, add the following configuration:

    sidebar: [sidebar, question]

This component of the sidebar module is not yet created, but it is quite simple (in modules/sidebar/actions/components.class.php):

public function executeQuestion()

The longest part to write is the fragment (modules/sidebar/templates/_question.php):

<?php include_partial('sidebar/default') ?>
<h2>question tags</h2>
<ul id="question_tags">
  <?php include_partial('tag/question_tags', array('question' => $question, 'tags' => $question->getTags())) ?> 


This partial has to be created in modules/tag/templates/_question_tags.php:

  <li><?php echo link_to($tag, '@tag?tag='.$tag, 'rel=tag') ?></li>
<?php endforeach; ?>

The rel=tag attribute is a MicroFormat. It is by no means compulsory, but as it costs nothing to add it here, we'll let it stay.

Add the @tag routing rule in the routing.yml:

  url:   /tag/:tag
  param: { module: tag, action: show }

Test it



tag list for a question

Display a short list of popular tags for a question


Extend the model

Anyway, this means that we have to add a ->getPopularTags() method to our Question object. But this time, the request to the database is not simple. Using Propel to do it would multiply the number of requests and take way too much time. Symfony allows you to use the power of SQL when it is the best solution, so we will just need a Creole connection to the database and execute a regular SQL query.

This query should be something like:

SELECT normalized_tag AS tag, COUNT(normalized_tag) AS count
FROM question_tag
WHERE question_id = $id
GROUP BY normalized_tag
LIMIT $max


public function getPopularTags($max = 5)
  $tags = array();
  $con = Propel::getConnection();
  $query = '
    FROM %s
    WHERE %s = ?
    GROUP BY %s
    ORDER BY count DESC
  $query = sprintf($query,
  $rs = $stmt->executeQuery();
  while ($rs->next())
  return $tags;

First, a connection to the database is opened in $con. The SQL query is built by replacing %s tokens in a string by the column and table names that come from the abstraction layer. A Statement object containing the query and a ResultSet object containing the result of the query are created. These are Creole objects, and their use is described in detail in the Creole documentation. The ->setInt() method of the Statement object replaces the first ? in the SQL query by the question id. The $max argument is used to limit the number of results returned with the ->setLimit() method.


Modify the view

Now we can add the list of tags for a question, which is formatted in a _list.php fragment in the modules/question/templates/ directory:

<?php use_helper('Text', 'Date', 'Global', 'Question') ?>
<?php foreach($question_pager->getResults() as $question): ?>
  <div class="question">
    <div class="interested_block" id="block_<?php echo $question->getId() ?>">
      <?php include_partial('question/interested_user', array('question' => $question)) ?>
    <h2><?php echo link_to($question->getTitle(), '@question?stripped_title='.$question->getStrippedTitle()) ?></h2>
    <div class="question_body">
      <div>asked by <?php echo link_to($question->getUser(), '@user_profile?nickname='.$question->getUser()->getNickname()) ?> on <?php echo format_date($question->getCreatedAt(), 'f') ?></div>
      <?php echo truncate_text(strip_tags($question->getHtmlBody()), 200) ?>
    tags: <?php echo tags_for_question($question) ?>
<?php endforeach; ?>
<div id="question_pager">
  <?php echo pager_navigation($question_pager, $rule) ?>

Because we want to separate the tags by a + sign, and to avoid too much code in the template to deal with the limits, we write a tags_for_question() helper function in a new lib/helper/QuestionHelper.php helper library:

function tags_for_question($question, $max = 5)
  $tags = array();
  return implode(' + ', $tags);




popular tags for the question list

Display the list of questions tagged with a word

Each time we displayed a tag, we added a link to a @tag routing rule. This is supposed to link to a page that displays the popular questions tagged with a given tag. It is simple to write, so we won't delay it anymore.

The tag/show action

Create a show action in the tag module:

public function executeShow()

Extend the model

As usual, the code that deals with the model is placed in the model, this time in the QuestionPeer class since it returns a set of Question objects. We want the popular question by interested users, so this time, there is no need for a complex request. Propel can do it with a single ->doSelect() call:

public static function getPopularByTag($tag, $page)
  $c = new Criteria();
  return $pager;

The method returns a pager of questions, ordered by popularity.

Create the template

The modules/tag/templates/showSuccess.php template is as simple as you expect it to be:

<h1>popular questions for tag "<?php echo $sf_params->get('tag') ?>"</h1>
<?php include_partial('question/list', array('question_pager' => $question_pager, 'rule' => '@tag?tag='.$sf_params->get('tag'))) ?>

Add the page parameter in the routing rule

In the routing.yml, add a :page parameter with a default value in the @tag routing rule:

  url:   /tag/:tag/:page

Test it

Navigate to the activities tag page to see all the questions tagged with this word:


list of questions tagged 'activities

See you Tomorrow


Some of you may worry about the important load that the above requests may put on the database. Optimizations are still possible - for instance, you could create a popular_tags column in the Question table, updated by a transaction each time a related QuestionTag is created. The list of questions would then be much less heavy. But the benefits of the cache system - which we will discuss in a few days - make this optimization useless.


The full code of the askeet application as of today can be grabbed from the askeet SVN repository, tagged /tags/release_day_13/. If you have any questions about today's tutorial, feel free to ask them in the askeet forum.