Previously on symfony
During day eight, we added AJAX interactions to askeet without pain. The application is now quite usable, but could use a lot of little improvements. Rich text should be allowed in the questions body
, and primary keys should not appear in the URIs. All that is not difficult to put in place with symfony: today will be a good occasion to practice what you already learned, and to check that you know how to manipulate all the layers of the MVC architecture.
Allow rich text formatting on questions and answers
Markdown
The question and answer bodies only accept plain text for now. To allow basic formatting - bold, italic, hyperlinks, images, etc. - we will use an external library rather than reinvent the wheel.
If you have taken a look at the symfony documentation in text format, you probably know that we are big Markdown fans. Markdown is a text-to-HTML conversion tool, and a syntax for text formatting. The great advantage of Markdown over, for instance, Wiki or forum syntax, is that a plain text markdown file is still very readable:
Test Markdown text ------------------ This is a **very simple** example of [Markdown][1]. The best thing about markdown is its _auto-escape_ feature for code chunks: <a href="http://www.symfony-project.com">link to symfony</a> >The `<` and `>` are properly escaped as `<` and `>`, >and are not interpreted by any browser [1]: http://daringfireball.net/projects/markdown/ "Markdown"
This Markdown renders as follow:
Test Markdown text
This is a very simple example of Markdown. The best thing about markdown is its auto-escape feature for code chunks:
<a href="http://www.symfony-project.com">link to symfony</a>The
<
and>
are properly escaped as<
and>
, and are not interpreted by any browser
Markdown library
Although originally written in Perl, Markdown is available as a PHP library at PHP Markdown. That's the one we will use. Download the markdown.php
file and put it in the lib
folder of the askeet project (askeet/lib/
). That's all: It is now available to all the classes of the askeet applications, provided that you require it first:
require_once('markdown.php');
We could call the Markdown converter each time we display the body of a message, but that would require too high a load on our servers. We'd rather convert the text body to an HTML body when the question is created, and store the HTML version of the body in the Question
table. You are probably getting used to this, so the model extension won't be a surprise.
Extend the model
First, add a colomn to the Question
table in the schema.xml
:
<column name="html_body" type="longvarchar" />
Then, regenerate the model and update the database:
$ symfony propel-build-model $ symfony propel-build-sql $ symfony propel-insert-sql
Override the setBody
method
When the ->setBody()
method of the Question
class is called, the html_body
column must also be updated with the Markdown conversion of the text body. Open the askeet/lib/model/Question.php
model file, and create:
public function setBody($v) { parent::setBody($v); require_once('markdown.php'); // strip all HTML tags $v = htmlentities($v, ENT_QUOTES, 'UTF-8'); $this->setHtmlBody(markdown($v)); }
Applying the htmlentities()
function before setting the HTML body protects askeet from cross-site-scripting (XSS) attacks since all <script>
tags are escaped.
Update the test data
We will add some Markdown formatting to some of the questions of the test data (in askeet/data/fixtures/test_data.yml
), to be able to check that the conversion works properly:
Question: q1: title: What shall I do tonight with my girlfriend? user_id: fabien body: | We shall meet in front of the __Dunkin'Donuts__ before dinner, and I haven't the slightest idea of what I can do with her. She's not interested in _programming_, _space opera movies_ nor _insects_. She's kinda cute, so I __really__ need to find something that will keep her to my side for another evening. q2: title: What can I offer to my step mother? user_id: anonymous body: | My stepmother has everything a stepmother is usually offered (watch, vacuum cleaner, earrings, [del.icio.us](http://del.icio.us) account). Her birthday comes next week, I am broke, and I know that if I don't offer her something *sweet*, my girlfriend won't look at me in the eyes for another month.
You can now repopulate the database:
$ php batch/load_data.php
Modify the templates
The showSuccess.php
template of the question
module can be sightly modified:
... <div class="question_body"> <?php echo $question->getHtmlBody() ?> </div> ...
The list template fragment (_list.php
) also shows the body, but in a truncated version:
<div class="question_body"> <?php echo truncate_text(strip_tags($question->getHtmlBody()), 200) ?> </div>
Everything is now ready for the final test: display the three pages that were modified, and observe the formatted text coming from the test data:
http://askeet/question/list http://askeet/recent http://askeet/question/show/stripped_title/what-shall-i-do-tonight-with-my-girlfriend
The same goes for the Answer
body
: An alternate html_body
column has to be created in the model, the ->setBody()
method needs to be overridden, and the answers displayed in question/show
have to use the ->getHtmlBody()
method instead of the ->getBody()
. As the code is exactly the same as above, we won't decribe it here, but you will find it in today's SVN code.
Hide all id
s
Another good practice in symfony actions is to avoid as much as possible to pass primary keys as request parameters. This is because our primary keys are mainly auto-incremental, and this gives hackers too much information about the records of the database. Plus, the displayed URI doesn't mean anything, and that's bad for the search engines.
Take the user profile page, for instance. For now, it uses the user id
as a parameter. But if we make sure that the nickname
is unique, it could as well be the parameter for the request. Let's do it.
Change the action
Edit the user/show
action:
public function executeShow() { $this->subscriber = UserPeer::retrieveByNickname($this->getRequestParameter('nickname')); $this->forward404Unless($this->subscriber); $this->interests = $this->subscriber->getInterestsJoinQuestion(); $this->answers = $this->subscriber->getAnswersJoinQuestion(); $this->questions = $this->subscriber->getQuestions(); }
Change the model
Add the following method to the UserPeer
class in the askeet/lib/model/
directory.
public static function retrieveByNickname($nickname) { $c = new Criteria(); $c->add(self::NICKNAME, $nickname); return self::doSelectOne($c); }
Change the template
The pages that display a link to the user profile must now mention the user's nickname
instead of his/her id
.
In the question/showSuccess.php
, question/_list.php
templates, replace:
<?php echo link_to($question->getUser(), 'user/show?id='.$question->getUserId()) ?>
by:
<?php echo link_to($question->getUser(), 'user/show?nickname='.$question->getUser()->getNickname()) ?>
The same kind of modification goes for the answer/_answer.php
template.
Add the routing rule
Add a new rule in the routing configuration for this action so that the url
pattern shows a nickname
request parameter:
user_profile: url: /user/:nickname param: { module: user, action: show }
After a symfony clear-cache
, the last thing to do is to test your modifications.
Routing
Apart from today's additions, many of the actions written until now use the default routing, so the module name and the action name are often displayed in the address bar of the browser. You already learned how to fix it, so let's define URL patterns for all the actions. Edit the askeet/apps/frontend/config/routing.yml
:
# question question: url: /question/:stripped_title param: { module: question, action: show } popular_questions: url: /index/:page param: { module: question, action: list, page: 1 } recent_questions: url: /recent/:page param: { module: question, action: recent, page: 1 } add_question: url: /add_question param: { module: question, action: add } # answer recent_answers: url: /recent/answers/:page param: { module: answer, action: recent, page: 1 } # user login: url: /login param: { module: user, action: login } logout: url: /logout param: { module: user, action: logout } user_profile: url: /user/:nickname param: { module: user, action: show } # default rules homepage: url: / param: { module: question, action: list } default_symfony: url: /symfony/:action/* param: { module: default } default_index: url: /:module param: { action: index } default: url: /:module/:action/*
If you navigate in the production environment, you are strongly advised to clear the cache before testing this configuration modification.
One good practice of symfony routing is to use the rule names in a link_to()
helper instead of the module/action
. Not only is it faster (the routing engine doesn't need to parse the routing configuration to find the rule to apply), but it also allows you to modify the action behind a rule name later. The routing chapter of the symfony book explains that more in detail.
<?php link_to('@user_profile?id='.$user->getId()) ?> // is better than <?php link_to('user/show?id='.$user->getId()) ?>
Askeet follows the symfony good practices, so the code that you will download at the end of this day's tutorial contains only rule names in the link helpers. Replacing action/module
by @rule
in all the templates and custom helper is not very fun to do, so the last advice concerning routing is: Write the routing rules as you create actions, and use rule names in the link helpers from the beginning.
See you Tomorrow
Today's changes were longer to read than to understand. In addition, the modifications described in the tutorial were repeated for similar cases in the overall code. Although no real new feature was added today, the code changed a lot.
If you feel that you didn't learn much about symfony today, it means that you are getting ready to start your own project. The process of creating an action, modifying the model to have it serve the action as needed, write a simple template to output the action and edit the configuration to integrate the new action into the logic of the application are the basics of symfony development.
All the good practices exposed here (using external libraries instead of rewriting it in symfony, not showing primary keys in the application, using routing rule names instead of module/action
) will keep your application clean, safe, fast and maintainable.
But the askeet application is far from finished! The functionnality that lacks the most is the ability to add a new question and to add a new answer. That's what we will develop tomorrow.
Do you have a suggestion about the additional feature of the 21st day? Make sure you send it to the askeet mailing-list. Stay tuned!
This work is licensed under the Creative Commons Attribution-Noncommercial-No Derivative Works 3.0 Unported License license.