Practical symfony

w5xp

贡献于2014-11-18

字数:0 关键词: Web框架

Practical symfony symfony 1.3 & 1.4 | Doctrine This PDF is brought to you by License: Creative Commons Attribution-Share Alike 3.0 Unported License Version: jobeet-1.4-doctrine-en-2012-07-01 Table of Contents About the Author............................................................................................... 9 About Sensio Labs........................................................................................... 10 Which symfony Version? ................................................................................. 11 Day 1: Starting up the Project ........................................................................ 12 Introduction ............................................................................................................. 12 This Book is different............................................................................................... 12 What for Today?....................................................................................................... 13 Prerequisites............................................................................................................ 13 Third-Party Software .......................................................................................................... 13 Command Line Interface.................................................................................................... 13 PHP Configuration.............................................................................................................. 14 Symfony Installation ................................................................................................ 14 Initializing the Project Directory........................................................................................ 14 Choosing the Symfony Version........................................................................................... 15 Choosing the Symfony Installation Location...................................................................... 15 Installing Symfony.............................................................................................................. 15 Project Setup ........................................................................................................... 17 Project Creation ................................................................................................................. 17 Application Creation........................................................................................................... 17 Directory Structure Rights................................................................................................. 18 Web Server Configuration: The ugly Way ............................................................... 19 Web Server Configuration: The secure Way ........................................................... 19 Web Server Configuration.................................................................................................. 19 Test the New Configuration ............................................................................................... 20 The Environments.................................................................................................... 22 Subversion ............................................................................................................... 24 Final Thoughts......................................................................................................... 25 Day 2: The Project........................................................................................... 26 The Project Pitch ..................................................................................................... 26 The Project User Stories.......................................................................................... 27 Story F1: On the homepage, the user sees the latest active jobs ...................................... 27 Story F2: A user can ask for all the jobs in a given category............................................. 28 Story F3: A user refines the list with some keywords........................................................ 29 Story F4: A user clicks on a job to see more detailed information .................................... 29 Story F5: A user posts a job ............................................................................................... 30 Story F6: A user applies to become an affiliate ................................................................. 31 Story F7: An affiliate retrieves the current active job list.................................................. 31 Story B1: An admin configures the website ....................................................................... 32 Story B2: An admin manages the jobs................................................................................ 32 Story B3: An admin manages the affiliates ........................................................................ 32 Table of Contents ii ----------------- Brought to you by Final Thoughts......................................................................................................... 32 Day 3: The Data Model.................................................................................... 33 The Relational Model............................................................................................... 33 The Schema ............................................................................................................. 33 The Database ........................................................................................................... 36 The ORM.................................................................................................................. 36 The Initial Data ........................................................................................................ 38 See it in Action in the Browser ................................................................................ 40 Final Thoughts......................................................................................................... 42 Day 4: The Controller and the View................................................................ 43 The MVC Architecture............................................................................................. 43 The Layout ............................................................................................................... 44 The Stylesheets, Images, and JavaScripts ............................................................... 47 The Job Homepage................................................................................................... 50 The Action .......................................................................................................................... 50 The Template...................................................................................................................... 51 The Job Page Template............................................................................................ 52 Slots ......................................................................................................................... 54 The Job Page Action................................................................................................. 55 The Request and the Response................................................................................ 57 The Request........................................................................................................................ 57 The Response ..................................................................................................................... 58 Final Thoughts......................................................................................................... 59 Day 5: The Routing ......................................................................................... 60 URLs ........................................................................................................................ 60 Routing Configuration ............................................................................................. 61 Route Customizations .............................................................................................. 62 Requirements........................................................................................................... 63 Route Class .............................................................................................................. 63 Object Route Class................................................................................................... 64 Routing in Actions and Templates ........................................................................... 67 Collection Route Class............................................................................................. 67 Route Debugging ..................................................................................................... 69 Default Routes ......................................................................................................... 70 Final Thoughts......................................................................................................... 70 Day 6: More with the Model............................................................................ 71 The Doctrine Query Object...................................................................................... 71 Debugging Doctrine generated SQL ....................................................................... 72 Object Serialization ................................................................................................. 72 More with Fixtures .................................................................................................. 73 Custom Configuration.............................................................................................. 74 Refactoring .............................................................................................................. 75 Categories on the Homepage .................................................................................. 76 Limit the Results...................................................................................................... 78 Dynamic Fixtures..................................................................................................... 79 Secure the Job Page................................................................................................. 80 Link to the Category Page ....................................................................................... 81 Final Thoughts......................................................................................................... 81 Table of Contents iii ----------------- Brought to you by Day 7: Playing with the Category Page........................................................... 82 The Category Route................................................................................................. 82 The Category Link ................................................................................................... 83 Job Category Module Creation ................................................................................ 85 Update the Database ............................................................................................... 85 Partials..................................................................................................................... 87 List Pagination......................................................................................................... 88 Final Thoughts......................................................................................................... 91 Day 8: The Unit Tests...................................................................................... 92 Tests in symfony ...................................................................................................... 92 Unit Tests................................................................................................................. 92 The lime Testing Framework ................................................................................. 93 Running Unit Tests .................................................................................................. 94 Testing slugify ..................................................................................................... 94 Adding Tests for new Features................................................................................ 96 Adding Tests because of a Bug................................................................................ 97 Doctrine Unit Tests................................................................................................ 100 Database Configuration.................................................................................................... 100 Test Data .......................................................................................................................... 101 Testing JobeetJob.......................................................................................................... 101 Test other Doctrine Classes ............................................................................................. 103 Unit Tests Harness ................................................................................................ 103 Final Thoughts....................................................................................................... 104 Day 9: The Functional Tests ......................................................................... 105 Functional Tests .................................................................................................... 105 The sfBrowser class ............................................................................................ 105 The sfTestFunctional class ............................................................................. 106 The Request Tester .......................................................................................................... 108 The Response Tester ........................................................................................................ 108 Running Functional Tests...................................................................................... 108 Test Data................................................................................................................ 109 Writing Functional Tests ....................................................................................... 109 Expired jobs are not listed................................................................................................ 109 Only n jobs are listed for a category ................................................................................ 110 A category has a link to the category page only if too many jobs .................................... 110 Jobs are sorted by date..................................................................................................... 111 Each job on the homepage is clickable ............................................................................ 112 Learn by the Example............................................................................................ 112 Debugging Functional Tests.................................................................................. 115 Functional Tests Harness ...................................................................................... 115 Tests Harness ........................................................................................................ 115 Final Thoughts....................................................................................................... 116 Day 10: The Forms ........................................................................................ 117 The Form Framework ............................................................................................ 117 Forms..................................................................................................................... 117 Doctrine Forms...................................................................................................... 118 Customizing the Job Form................................................................................................ 119 The Form Template .......................................................................................................... 123 The Form Action............................................................................................................... 125 Protecting the Job Form with a Token ............................................................................. 128 Table of Contents iv ----------------- Brought to you by The Preview Page .................................................................................................. 129 Job Activation and Publication............................................................................... 131 Final Thoughts....................................................................................................... 132 Day 11: Testing your Forms .......................................................................... 133 Submitting a Form................................................................................................. 133 The Form Tester .................................................................................................... 135 Redirection Test..................................................................................................... 135 The Doctrine Tester............................................................................................... 135 Testing for Errors .................................................................................................. 136 Forcing the HTTP Method of a link ....................................................................... 137 Tests as a SafeGuard ............................................................................................. 138 Back to the Future in a Test .................................................................................. 139 Forms Security ...................................................................................................... 141 Form Serialization Magic!................................................................................................ 141 Built-in Security Features ................................................................................................ 141 XSS and CSRF Protection................................................................................................. 142 Maintenance Tasks ................................................................................................ 143 Final Thoughts....................................................................................................... 144 Day 12: The Admin Generator....................................................................... 145 Backend Creation .................................................................................................. 145 Backend Modules................................................................................................... 146 Backend Look and Feel.......................................................................................... 146 The symfony Cache................................................................................................ 148 Backend Configuration .......................................................................................... 150 Title Configuration................................................................................................. 150 Fields Configuration .............................................................................................. 151 List View Configuration ......................................................................................... 152 display........................................................................................................................... 152 layout ............................................................................................................................. 152 “Virtual” columns ............................................................................................................. 153 sort ................................................................................................................................. 153 max_per_page ................................................................................................................ 153 batch_actions .............................................................................................................. 154 object_actions ............................................................................................................ 156 actions........................................................................................................................... 157 table_method ................................................................................................................ 158 Form Views Configuration..................................................................................... 159 display........................................................................................................................... 152 “Virtual” columns ............................................................................................................. 153 class ............................................................................................................................... 161 Filters Configuration ............................................................................................. 163 Actions Customization ........................................................................................... 164 Templates Customization ...................................................................................... 165 Final Configuration................................................................................................ 166 Final Thoughts....................................................................................................... 167 Day 13: The User........................................................................................... 168 User Flashes .......................................................................................................... 168 User Attributes ...................................................................................................... 169 getAttribute(), setAttribute()............................................................................. 170 The myUser class ............................................................................................................. 170 sfParameterHolder...................................................................................................... 172 Table of Contents v ----------------- Brought to you by Application Security .............................................................................................. 172 Authentication .................................................................................................................. 172 Authorization.................................................................................................................... 174 Plugins ................................................................................................................... 175 Backend Security................................................................................................... 176 User Testing .......................................................................................................... 178 Final Thoughts....................................................................................................... 179 Day 14: Feeds ................................................................................................ 180 Formats.................................................................................................................. 180 Feeds ..................................................................................................................... 181 Latest Jobs Feed ............................................................................................................... 181 Latest Jobs in a Category Feed......................................................................................... 184 Final Thoughts....................................................................................................... 187 Day 15: Web Services .................................................................................... 188 Affiliates................................................................................................................. 188 The Fixtures ..................................................................................................................... 188 The Job Web Service ........................................................................................................ 189 The Action ........................................................................................................................ 190 The xml Format................................................................................................................ 191 The json Format.............................................................................................................. 191 The yaml Format.............................................................................................................. 192 Web Service Tests ................................................................................................. 193 The Affiliate Application Form............................................................................... 194 Routing ............................................................................................................................. 195 Bootstrapping................................................................................................................... 195 Templates ......................................................................................................................... 195 Actions.............................................................................................................................. 196 Tests ................................................................................................................................. 197 The Affiliate Backend............................................................................................. 198 Final Thoughts....................................................................................................... 201 Day 16: The Mailer........................................................................................ 202 Sending simple Emails........................................................................................... 202 Configuration......................................................................................................... 203 Factories........................................................................................................................... 203 Delivery Strategy.............................................................................................................. 204 Mail Transport.................................................................................................................. 205 Testing Emails ....................................................................................................... 205 Final Thoughts....................................................................................................... 207 Day 17: Search .............................................................................................. 208 The Technology...................................................................................................... 208 Installing and Configuring the Zend Framework .................................................. 209 Indexing ................................................................................................................. 209 The save() method ......................................................................................................... 210 Doctrine Transactions ...................................................................................................... 211 delete()......................................................................................................................... 212 Searching............................................................................................................... 212 Unit Tests............................................................................................................... 214 Tasks...................................................................................................................... 214 Final Thoughts....................................................................................................... 215 Day 18: AJAX ................................................................................................. 216 Table of Contents vi ----------------- Brought to you by Installing jQuery .................................................................................................... 216 Including jQuery .................................................................................................... 216 Adding Behaviors................................................................................................... 217 User Feedback....................................................................................................... 217 AJAX in an Action................................................................................................... 219 Testing AJAX .......................................................................................................... 220 Final Thoughts....................................................................................................... 220 Day 19: Internationalization and Localization ............................................. 222 User ....................................................................................................................... 222 The User Culture.............................................................................................................. 222 The Preferred Culture ...................................................................................................... 223 Culture in the URL................................................................................................. 223 Culture Testing...................................................................................................... 225 Language Switching .............................................................................................. 226 Internationalization ............................................................................................... 229 Languages, Charset, and Encoding.................................................................................. 229 Templates ......................................................................................................................... 229 i18n:extract ................................................................................................................ 231 Translations with Arguments ........................................................................................... 232 Forms................................................................................................................................ 234 Doctrine Objects............................................................................................................... 234 Admin Generator .............................................................................................................. 237 Tests ................................................................................................................................. 238 Localization............................................................................................................ 238 Templates ......................................................................................................................... 229 Forms (I18n)..................................................................................................................... 239 Final Thoughts....................................................................................................... 240 Day 20: The Plugins ...................................................................................... 241 Plugins ................................................................................................................... 241 A symfony Plugin.............................................................................................................. 241 Private Plugins ................................................................................................................. 241 Public Plugins................................................................................................................... 241 A Different Way to Organize Code ................................................................................... 242 Plugin File Structure ............................................................................................. 242 The Jobeet Plugin................................................................................................... 242 The Model......................................................................................................................... 243 The Controllers and the Views ......................................................................................... 245 The Tasks.......................................................................................................................... 248 The i18n Files ................................................................................................................... 248 The Routing ...................................................................................................................... 249 The Assets ........................................................................................................................ 249 The User ........................................................................................................................... 249 The Default Structure vs. the Plugin Architecture........................................................... 251 Using Plugins......................................................................................................... 251 Contributing a Plugin ............................................................................................ 252 Packaging a Plugin ........................................................................................................... 252 Hosting a Plugin on the symfony Website........................................................................ 255 Final Thoughts....................................................................................................... 255 Day 21: The Cache......................................................................................... 256 Creating a new Environment................................................................................. 256 Cache Configuration.............................................................................................. 257 Table of Contents vii ----------------- Brought to you by Page Cache ............................................................................................................ 258 Clearing the Cache ................................................................................................ 260 Action Cache.......................................................................................................... 260 Partial and Component Cache ............................................................................... 261 Forms in Cache...................................................................................................... 263 Removing the Cache.............................................................................................. 264 Testing the Cache .................................................................................................. 265 Final Thoughts....................................................................................................... 266 Day 22: The Deployment ............................................................................... 267 Preparing the Production Server........................................................................... 267 Server Configuration........................................................................................................ 267 PHP Accelerator ............................................................................................................... 268 The symfony Libraries ........................................................................................... 268 Embedding symfony ......................................................................................................... 268 Upgrading symfony .......................................................................................................... 268 Tweaking the Configuration .................................................................................. 269 Database Configuration.................................................................................................... 269 Assets ............................................................................................................................... 269 Customizing Error Pages.................................................................................................. 269 Customizing the Directory Structure .................................................................... 270 The Web Root Directory ................................................................................................... 270 The Cache and Log Directory........................................................................................... 270 Customizing symfony core Objects (aka factories)................................................ 271 Cookie Name .................................................................................................................... 271 Session Storage................................................................................................................ 271 Session Timeout ............................................................................................................... 271 Logging............................................................................................................................. 272 Deploying............................................................................................................... 272 What to deploy?................................................................................................................ 272 Deploying Strategies ........................................................................................................ 272 Final Thoughts....................................................................................................... 274 Day 23: Another Look at symfony ................................................................. 275 What is symfony? ................................................................................................... 275 The Model.............................................................................................................. 275 The View ................................................................................................................ 275 The Controller........................................................................................................ 276 Configuration......................................................................................................... 276 Debugging ............................................................................................................. 276 Main symfony Objects............................................................................................ 277 Security.................................................................................................................. 277 Forms..................................................................................................................... 277 Internationalization and Localization .................................................................... 277 Tests....................................................................................................................... 277 Plugins ................................................................................................................... 278 Tasks...................................................................................................................... 278 See you soon.......................................................................................................... 279 Learning by Practicing ..................................................................................................... 279 The community................................................................................................................. 279 Appendix A: License ...................................................................................... 281 Attribution-Share Alike 3.0 Unported License ...................................................... 281 Table of Contents viii ----------------- Brought to you by About the Author Fabien Potencier discovered the Web in 1994, at a time when connecting to the Internet was still associated with the harmful strident sounds of a modem. Being a developer by passion, he immediately started to build websites with Perl. But with the release of PHP 5, he decided to switch focus to PHP, and created the symfony framework project in 2004 to help his company leverage the power of PHP for its customers. Fabien is a serial-entrepreneur, and among other companies, he created Sensio, a services and consulting company specialized in web technologies and Internet marketing, in 1998. Fabien is also the creator of several other Open-Source projects, a writer, a blogger, a speaker at international conferences, and a happy father of two wonderful kids. His Website: http://fabien.potencier.org/ On Twitter: http://www.twitter.com/fabpot About the Author ix ----------------- Brought to you by About Sensio Labs Sensio Labs is a services and consulting company specialized in Open-Source Web technologies and Internet marketing. Founded in 1998 by Fabien Potencier, Gregory Pascal, and Samuel Potencier, Sensio benefited from the Internet growth of the late 1990s and situated itself as a major player for building complex web applications. It survived the Internet bubble burst by applying professional and industrial methods to a business where most players seemed to reinvent the wheel for each project. Most of Sensio’s clients are large corporations, who hire its teams to deal with small- to middle-scale projects with strong time-to-market and innovation constraints. Sensio Labs develops interactive web applications, both for dot-com and traditional companies. Sensio Labs also provides auditing, consulting, and training on Internet technologies and complex application deployment. It helps define the global Internet strategy of large-scale industrial players. Sensio Labs has projects in France and abroad. For its own needs, Sensio Labs develops the symfony framework and sponsors its deployment as an Open-Source project. This means that symfony is built from experience and is employed in many web applications, including those of large corporations. Since its beginnings eleven years ago, Sensio has always based its strategy on strong technical expertise. The company focuses on Open-Source technologies, and as for dynamic scripting languages, Sensio offers developments in all LAMP platforms. Sensio acquired strong experience on the best frameworks using these languages, and often develops web applications in Django, Rails, and, of course, symfony. Sensio Labs is always open to new business opportunities, so if you ever need help developing a web application, learning symfony, or evaluating a symfony development, feel free to contact us at fabien.potencier@sensio.com. The consultants, project managers, web designers, and developers of Sensio can handle projects from A to Z. About Sensio Labs x ----------------- Brought to you by Which symfony Version? This book has been written for both symfony 1.3 and symfony 1.4. As writing a single book for two different versions of a software is quite unusual, this section explains what the main differences are between the two versions, and how to make the best choice for your projects. Both the symfony 1.3 and symfony 1.4 versions have been released at about the same time (at the end of 2009). As a matter of fact, they both have the exact same feature set. The only difference between the two versions is how each supports backward compatibility with older symfony versions. Symfony 1.3 is the release you’ll want to use if you need to upgrade a legacy project that uses an older symfony version (1.0, 1.1, or 1.2). It has a backward compatibility layer and all the features that have been deprecated during the 1.3 development period are still available. It means that upgrading is easy, simple, and safe. If you start a new project today, however, you should use symfony 1.4. This version has the same feature set as symfony 1.3 but all the deprecated features, including the entire compatibility layer, have been removed. This version is cleaner and also a bit faster than symfony 1.3. Another big advantage of using symfony 1.4 is its longer support. Being a Long Term Support release, it will be maintained by the symfony core team for three years (until November 2012). Of course, you can migrate your projects to symfony 1.3 and then slowly update your code to remove the deprecated features and eventually move to symfony 1.4 in order to benefit from the long term support. You have plenty of time to plan the move as symfony 1.3 will be supported for a year (until November 2010). As this book does not describe deprecated features, all examples work equally well on both versions. Which symfony Version? xi ----------------- Brought to you by Day 1 Starting up the Project Introduction The symfony1 framework has been an Open-Source project for more than four years and has become one of the most popular PHP frameworks thanks to its great features and great documentation. This book describes the creation of a web application with the symfony framework, step-by- step from the specifications to the implementation. It is targeted at beginners who want to learn symfony, understand how it works, and also learn about the best web development practices. The application to be designed could have been yet another blog engine. But we want to use symfony on a useful project. The goal is to demonstrate that symfony can be used to develop professional applications with style and little effort. We will keep the content of the project secret for another day as we already have much for now. However, let’s give it a name: Jobeet. Each day of this book is meant to last between one and two hours, and will be the occasion to learn symfony by coding a real website, from start to finish. Every day, new features will be added to the application, and we’ll take advantage of this development to introduce you to new symfony functionalities as well as good practices in symfony web development. This Book is different Remember the early days of PHP4. Ah, la Belle Epoque! PHP was one of the first languages dedicated to the web and one of the easiest to learn. But as web technologies evolve at a very fast pace, web developers need to keep up with the latest best practices and tools. The best way to learn is of course by reading blogs, tutorials, and books. We have read a lot of these, be they written for PHP, Python, Java, Ruby, or Perl, and many of them fall short when the author starts giving snippets of codes as examples. You are probably used to reading warnings like: “For a real application, don’t forget to add validation and proper error handling.” or “Security is left as an exercise to the reader.” or 1. http://www.symfony-project.org/ Day 1: Starting up the Project 12 ----------------- Brought to you by “You will of course need to write tests.” What? These things are serious business. They are perhaps the most important part of any piece of code. And as a reader, you are left alone. Without these concerns taken into account, the examples are much less useful. You cannot use them as a good starting point. That’s bad! Why? Because security, validation, error handling, and tests, just to name a few, take care to code right. In this book, you will never see statements like those as we will write tests, error handling, validation code, and be sure we develop a secure application. That’s because symfony is about code, but also about best practices and how to develop professional applications for the enterprise. We will be able to afford this luxury because symfony provides all the tools needed to code these aspects easily without writing too much code. Validation, error handling, security, and tests are first-class citizens in symfony, so it won’t take us too long to explain. This is just one of many reasons why to use a framework for “real life” projects. All the code you will read in this book is code you could use for a real project. We encourage you to copy and paste snippets of code or steal whole chunks. What for Today? We won’t write PHP code. But even without writing a single line of code, you will start understanding the benefits of using a framework like symfony, just by bootstrapping a new project. The objective of this day is to setup the development environment and display a page of the application in a web browser. This includes installation of symfony, creation of an application, and web server configuration. As this book will mostly focus on the symfony framework, we will assume that you already have a solid knowledge of PHP 5 and Object Oriented programming. Prerequisites Before installing symfony, you need to check that your computer has everything installed and configured correctly. Take the time to conscientiously read this day and follow all the steps required to check your configuration, as it may save your day further down the road. Third-Party Software First of all, you need to check that your computer has a friendly working environment for web development. At a minimum, you need a web server (Apache, for instance), a database engine (MySQL, PostgreSQL, SQLite, or any PDO2-compatible database engine), and PHP 5.2.4 or later. Command Line Interface The symfony framework comes bundled with a command line tool that automates a lot of work for you. If you are a Unix-like OS user, you will feel right at home. If you run a Windows system, it will also work fine, but you will just have to type a few commands at the cmd prompt. 2. http://www.php.net/PDO Day 1: Starting up the Project 13 ----------------- Brought to you by Listing 1-1 Listing 1-2 Listing 1-3 Listing 1-4 Unix shell commands can come in handy in a Windows environment. If you would like to use tools like tar, gzip or grep on Windows, you can install Cygwin3. The adventurous may also like to try Microsoft’s Windows Services for Unix4. PHP Configuration As PHP configurations can vary a lot from one OS to another, or even between different Linux distributions, you need to check that your PHP configuration meets the symfony minimum requirements. First, ensure that you have PHP 5.2.4 at a minimum installed by using the phpinfo() built-in function or by running php -v on the command line. Be aware that on some configurations, you might have two different PHP versions installed: one for the command line, and another for the web. Then, download the symfony configuration checker script at the following URL: http://sf-to.org/1.4/check.php Save the script somewhere under your current web root directory. Launch the configuration checker script from the command line: $ php check_configuration.php If there is a problem with your PHP configuration, the output of the command will give you hints on what to fix and how to fix it. You should also execute the checker from a browser and fix the issues it might discover. That’s because PHP can have a distinct php.ini configuration file for these two environments, with different settings. Don’t forget to remove the file from your web root directory afterwards. Symfony Installation Initializing the Project Directory Before installing symfony, you first need to create a directory that will host all the files related to Jobeet: $ mkdir -p /home/sfprojects/jobeet $ cd /home/sfprojects/jobeet Or on Windows: c:\> mkdir c:\development\sfprojects\jobeet c:\> cd c:\development\sfprojects\jobeet 3. http://cygwin.com/ 4. http://technet.microsoft.com/en-gb/interopmigration/bb380242.aspx Day 1: Starting up the Project 14 ----------------- Brought to you by Listing 1-5 Listing 1-6 Windows users are advised to run symfony and to setup their new project in a path which contains no spaces. Avoid using the Documents and Settings directory, including anywhere under My Documents. If you create the symfony project directory under the web root directory, you won’t need to configure your web server. Of course, for production environments, we strongly advise you to configure your web server as explained in the web server configuration section. Choosing the Symfony Version Now, you need to install symfony. As the symfony framework has several stable versions, you need to choose the one you want to install by reading the installation page5 on the symfony website. This book assumes you want to install symfony 1.3 or symfony 1.4. Choosing the Symfony Installation Location You can install symfony globally on your machine, or embed it into each of your project. The latter is the recommended one as projects will then be totally independent from each others. Upgrading your locally installed symfony won’t break some of your projects unexpectedly. It means you will be able to have projects on different versions of symfony, and upgrade them one at a time as you see fit. As a best practice, many people install the symfony framework files in the lib/vendor project directory. So, first, create this directory: $ mkdir -p lib/vendor Installing Symfony Installing from an Archive The easiest way to install symfony is to download the archive for the version you choose from the symfony website. Go to the installation page for the version you have just chosen, symfony 1.46 for instance. Under the “Source Download” section, you will find the archive in .tgz or in .zip format. Download the archive, put it under the freshly created lib/vendor/ directory, un-archive it, and rename the directory to symfony: $ cd lib/vendor $ tar zxpf symfony-1.4.0.tgz $ mv symfony-1.4.0 symfony $ rm symfony-1.4.0.tgz Under Windows, unzipping the zip file can be achieved using Windows Explorer. After you rename the directory to symfony, there should be a directory structure similar to c:\dev\sfprojects\jobeet\lib\vendor\symfony. 5. http://www.symfony-project.org/installation 6. http://www.symfony-project.org/installation/1_4 Day 1: Starting up the Project 15 ----------------- Brought to you by Listing 1-7 Listing 1-8 Listing 1-9 Listing 1-10 Listing 1-11 Listing 1-12 Listing 1-13 Installing from Subversion (recommended) If you use Subversion, it is even better to use the svn:externals property to embed symfony into your project in the lib/vendor/ directory: $ svn pe svn:externals lib/vendor/ Importing your project in a new Subversion repository is explained at the end of this day. If everything goes well, this command will run your favorite editor to give you the opportunity to configure the external Subversion sources. On Windows, you can use tools like TortoiseSVN7 to do everything without the need to use the console. If you are conservative, tie your project to a specific release (a subversion tag): symfony http://svn.symfony-project.com/tags/RELEASE_1_4_0 Whenever a new release comes out (as announced on the symfony blog8), you will need to change the URL to the new version. If you want to go the bleeding-edge route, use the 1.4 branch: symfony http://svn.symfony-project.com/branches/1.4/ Using the branch makes your project benefits from the bug fixes automatically whenever you run a svn update. Installation Verification Now that symfony is installed, check that everything is working by using the symfony command line to display the symfony version (note the capital V): $ cd ../.. $ php lib/vendor/symfony/data/bin/symfony -V On Windows: c:\> cd ..\.. c:\> php lib\vendor\symfony\data\bin\symfony -V If you are curious about what this command line tool can do for you, type symfony to list the available options and tasks: $ php lib/vendor/symfony/data/bin/symfony On Windows: c:\> php lib\vendor\symfony\data\bin\symfony 7. http://tortoisesvn.net/ 8. http://www.symfony-project.org/blog/ Day 1: Starting up the Project 16 ----------------- Brought to you by Listing 1-14 Listing 1-15 The symfony command line is the developer’s best friend. It provides a lot of utilities that improve your productivity for day-to-day activities like cleaning the cache, generating code, and much more. Project Setup In symfony, applications sharing the same data model are regrouped into projects. For most projects, you will have two different applications: a frontend and a backend. Project Creation From the sfprojects/jobeet directory, run the symfony generate:project task to actually create the symfony project: $ php lib/vendor/symfony/data/bin/symfony generate:project jobeet On Windows: c:\> php lib\vendor\symfony\data\bin\symfony generate:project jobeet The generate:project task generates the default structure of directories and files needed for a symfony project: Directory Description apps/ Hosts all project applications cache/ The files cached by the framework config/ The project configuration files lib/ The project libraries and classes log/ The framework log files plugins/ The installed plugins test/ The unit and functional test files web/ The web root directory (see below) Why does symfony generate so many files? One of the main benefits of using a full-stack framework is to standardize your developments. Thanks to symfony’s default structure of files and directories, any developer with some symfony knowledge can take over the maintenance of any symfony project. In a matter of minutes, he will be able to dive into the code, fix bugs, and add new features. The generate:project task has also created a symfony shortcut in the project root directory to shorten the number of characters you have to write when running a task. So, from now on, instead of using the fully qualified path to the symfony program, you can use the symfony shortcut. Application Creation Now, create the frontend application by running the generate:app task: Day 1: Starting up the Project 17 ----------------- Brought to you by Listing 1-16 Listing 1-17 Listing 1-18 $ php symfony generate:app frontend Because the symfony shortcut file is executable, Unix users can replace all occurrences of ‘php symfony’ by ‘./symfony’ from now on. On Windows you can copy the ‘symfony.bat’ file to your project and use ‘symfony’ instead of ‘php symfony’: c:\> copy lib\vendor\symfony\data\bin\symfony.bat . Based on the application name given as an argument, the generate:app task creates the default directory structure needed for the application under the apps/frontend/ directory: Directory Description config/ The application configuration files lib/ The application libraries and classes modules/ The application code (MVC) templates/ The global template files Security By default, the generate:app task has secured our application from the two most widespread vulnerabilities found on the web. That’s right, symfony automatically takes security measures on our behalf. To prevent XSS attacks, output escaping has been enabled; and to prevent CSRF attacks, a random CSRF secret has been generated. Of course, you can tweak these settings thanks to the following options: • --escaping-strategy: Enables or disables output escaping • --csrf-secret: Enables session tokens in forms If you know nothing about XSS9 or CSRF10, take the time to learn more these security vulnerabilities. Directory Structure Rights Before trying to access your newly created project, you need to set the write permissions on the cache/ and log/ directories to the appropriate levels, so that your web server can write to them: $ chmod 777 cache/ log/ Tips for People using a SCM Tool symfony only ever writes in two directories of a symfony project, cache/ and log/. The content of these directories should be ignored by your SCM (by editing the svn:ignore property if you use Subversion for instance). 9. http://en.wikipedia.org/wiki/Cross-site_scripting 10. http://en.wikipedia.org/wiki/CSRF Day 1: Starting up the Project 18 ----------------- Brought to you by Listing 1-19 Web Server Configuration: The ugly Way If you have created the project directory it somewhere under the web root directory of your web server, you can already access the project in a web browser. Of course, as there is no configuration, it is very fast to set up, but try to access the config/ databases.yml file in your browser to understand the bad consequences of such a lazy attitude. If the user knows that your website is developed with symfony, he will have access to a lot of sensitive files. Never ever use this setup on a production server, and read the next section to learn how to configure your web server properly. Web Server Configuration: The secure Way A good web practice is to put under the web root directory only the files that need to be accessed by a web browser, like stylesheets, JavaScripts and images. By default, we recommend to store these files under the web/ sub-directory of a symfony project. If you have a look at this directory, you will find some sub-directories for web assets (css/ and images/) and the two front controller files. The front controllers are the only PHP files that need to be under the web root directory. All other PHP files can be hidden from the browser, which is a good idea as far as security is concerned. Web Server Configuration Now it is time to change your Apache configuration, to make the new project accessible to the world. Locate and open the httpd.conf configuration file and add the following configuration at the end: # Be sure to only have this line once in your configuration NameVirtualHost 127.0.0.1:8080 # This is the configuration for your project Listen 127.0.0.1:8080 DocumentRoot "/home/sfprojects/jobeet/web" DirectoryIndex index.php AllowOverride All Allow from All Alias /sf /home/sfprojects/jobeet/lib/vendor/symfony/data/web/sf AllowOverride All Allow from All The /sf alias gives you access to images and javascript files needed to properly display default symfony pages and the web debug toolbar|Web Debug Toolbar. Day 1: Starting up the Project 19 ----------------- Brought to you by Listing 1-20 Listing 1-21 Listing 1-22 Listing 1-23 Listing 1-24 On Windows, you need to replace the Alias line with something like: Alias /sf "c:\dev\sfprojects\jobeet\lib\vendor\symfony\data\web\sf" And /home/sfprojects/jobeet/web should be replaced with: c:\dev\sfprojects\jobeet\web This configuration makes Apache listen to port 8080 on your machine, so, after restarting apache, the website will be accessible at the following URL: http://~localhost~:8080/ You can change 8080 to any number, but favour numbers greater than 1024 as they do not require administrator rights. Configure a dedicated Domain Name If you are an administrator on your machine, it is better to setup virtual hosts instead of adding a new port each time you start a new project. Instead of choosing a port and add a Listen statement, choose a domain name (for instance the real domain name with .localhost added at the end) and add a ServerName statement: # This is the configuration for your project ServerName www.jobeet.com.localhost The domain name www.jobeet.com.localhost used in the Apache configuration has to be declared locally. If you run a Linux system, it has to be done in the /etc/hosts file. If you run Windows XP, this file is located in the C:\WINDOWS\system32\drivers\etc\ directory. Add in the following line: 127.0.0.1 www.jobeet.com.localhost Test the New Configuration Restart Apache, and check that you now have access to the new application by opening a browser and typing http://localhost:8080/index.php/, or http://www.jobeet.com.localhost/index.php/ depending on the Apache configuration you chose in the previous section. Day 1: Starting up the Project 20 ----------------- Brought to you by Listing 1-25 If you have the Apache mod_rewrite module installed, you can remove the index.php/ part of the URL. This is possible thanks to the rewriting rules configured in the web/ .htaccess file. You should also try to access the application in the development environment (see the next section for more information about environments). Type in the following URL: http://www.jobeet.com.localhost/frontend_dev.php/ The web debug toolbar should show in the top right corner, including small icons proving that your sf/ alias configuration is correct. Day 1: Starting up the Project 21 ----------------- Brought to you by The setup is a little different if you want to run symfony on an IIS server in a Windows environment. Find how to configure it in the related tutorial11. The Environments If you have a look at the web/ directory, you will find two PHP files: index.php and frontend_dev.php. These files are called front controllers; all requests to the application are made through them. But why do we have two front controllers for each application? Both files point to the same application but for different environments. When you develop an application, except if you develop directly on the production server, you need several environments: • The development environment: This is the environment used by web developers when they work on the application to add new features, fix bugs, … • The test environment: This environment is used to automatically test the application. • The staging environment: This environment is used by the customer to test the application and report bugs or missing features. • The production environment: This is the environment end users interact with. What makes an environment unique? In the development environment for instance, the application needs to log all the details of a request to ease debugging, but the cache system must be disabled as all changes made to the code must be taken into account right away. So, the development environment must be optimized for the developer. The best example is certainly when an exception|Exception Handling occurs. To help the developer debug the issue faster, symfony displays the exception with all the information it has about the current request right into the browser: 11. http://www.symfony-project.com/cookbook/1_0/web_server_iis Day 1: Starting up the Project 22 ----------------- Brought to you by Listing 1-26 But on the production environment, the cache layer must be activated and, of course, the application must display customized error messages instead of raw exceptions. So, the production environment must be optimized for performance and the user experience. If you open the front controller files, you will see that their content is the same except for the environment setting: // web/index.php dispatch(); Day 1: Starting up the Project 23 ----------------- Brought to you by Listing 1-27 Listing 1-28 Listing 1-29 Listing 1-30 Listing 1-31 The web debug toolbar is also a great example of the usage of environment. It is present on all pages in the development environment and gives you access to a lot of information by clicking on the different tabs: the current application configuration, the logs for the current request, the SQL statements executed on the database engine, memory information, and time information. Subversion It is a good practice to use source version control when developing a web application. Using a source version control allows us to: • work with confidence • revert to a previous version if a change breaks something • allow more than one person to work efficiently on the project • have access to all the successive versions of the application In this section, we will describe how to use Subversion12 with symfony. If you use another source code control tool, it must be quite easy to adapt what we describe for Subversion. We assume you have already access to a Subversion server and can access it via HTTP. If you don’t have a Subversion server at your disposal, you can create a repository for free on Google Code13 or just type “free subversion repository” in Google to have a lot more options. First, create a repository for the jobeet project on the repository server: $ svnadmin create /path/to/jobeet/repository On your machine, create the basic directory structure: $ svn mkdir -m "created default directory structure" http://svn.example.com/jobeet/trunk http://svn.example.com/jobeet/tags http://svn.example.com/jobeet/branches And checkout the empty trunk/ directory: $ cd /home/sfprojects/jobeet $ svn co http://svn.example.com/jobeet/trunk/ . Then, remove the content of the cache/ and log/ directories as we don’t want to put them into the repository. $ rm -rf cache/* log/* Now, make sure to set the write permissions on the cache and logs directories to the appropriate levels so that your web server can write to them: $ chmod 777 cache/ log/ Now, import all the files and directories: 12. http://subversion.tigris.org/ 13. http://code.google.com/hosting/ Day 1: Starting up the Project 24 ----------------- Brought to you by Listing 1-32 Listing 1-33 Listing 1-34 Listing 1-35 Listing 1-36 Listing 1-37 $ svn add * As we will never want to commit files located in the cache/ and log/ directories, you need to specify an ignore list: $ svn propedit svn:ignore cache The default text editor configured for SVN should launch. Subversion must ignore all the content of this directory: * Save and quit. You’re done. Repeat the procedure for the log/ directory: $ svn propedit svn:ignore log And enter: * Finally, commit these changes to the repository: $ svn import -m "made the initial import" . http://svn.example.com/jobeet/trunk Windows users can use the great TortoiseSVN14 client to manage their subversion repository. Final Thoughts Well, time is over! Even if we have not yet started talking about symfony, we have setup a solid development environment, we have talked about web development best practices, and we are ready to start coding. Tomorrow, we will reveal what the application will do and talk about the requirements we need to implement for Jobeet. 14. http://tortoisesvn.tigris.org/ Day 1: Starting up the Project 25 ----------------- Brought to you by Day 2 The Project We have not written a single line of PHP yet, but in day 1, we setup the environment, created an empty symfony project, and made sure we started with some good security defaults. If you followed along, you have been looking at your screen delightedly since then, as it displays the beautiful default symfony page for new applications. But you want more. You want to learn all the nitty gritty details of symfony application development. So, let’s resume our trip to symfony development nirvana. Now, we will take the time to describe the requirements of the Jobeet project with some basic mockups. The Project Pitch Everybody is talking about the crisis nowadays. Unemployment is rising again. I know, symfony developers are not really concerned and that’s why you want to learn symfony in the first place. But it is also quite difficult to find good symfony developers. Where can you find a symfony developer? Where can you advertise your symfony skills? Day 2: The Project 26 ----------------- Brought to you by You need to find a good job board. Monster you say? Think again. You need a focused job board. One where you can find the best people, the experts. One where it is easy, fast, and fun to look for a job, or to propose one. Search no more. Jobeet is the place. Jobeet is Open-Source job board software that only does one thing, but does it well. It is easy to use, customize, extend, and embed into your website. It supports multiple languages out of the box, and of course uses the latest Web 2.0 technologies to enhance user experience. It also provides feeds and an API to interact with it programatically. Does it already exist? As a user, you will find a lot of job boards like Jobeet on the Internet. But try to find one which is Open-Source, and as feature-rich as what we propose here. If you are really looking for a symfony job or want to hire a symfony developer, you can go to the symfonians15 website. The Project User Stories Before diving into the code head-first, let’s describe the project a bit more. The following sections describe the features we want to implement in the first version/iteration of the project with some simple stories. The Jobeet website has four kind of users: • admin: He owns the website and has the magic power • user: He visits the website to look for a job • poster: He visits the website to post a job • affiliate: He re-publishes some jobs on his website The project has two applications: the frontend (stories F1 to F7, below), where the users interact with the website, and the backend (stories B1 to B3), where admins manage the website. The backend application is secured and requires credentials to access. Story F1: On the homepage, the user sees the latest active jobs When a user comes to the Jobeet website, he sees a list of active jobs. The jobs are sorted by category and then by publication date (newer jobs first). For each job, only the location, the position, and the company are displayed. For each category, the list only shows the first 10 jobs and a link allows to list all the jobs for a given category (Story F2). On the homepage, the user can refine the job list (Story F3), or post a new job (Story F5). 15. http://symfonians.net/ Day 2: The Project 27 ----------------- Brought to you by Story F2: A user can ask for all the jobs in a given category When a user clicks on a category name or on a “more jobs” link on the homepage, he sees all the jobs for this category sorted by date. The list is paginated with 20 jobs per page. Day 2: The Project 28 ----------------- Brought to you by Story F3: A user refines the list with some keywords The user can enter some keywords to refine his search. Keywords can be words found in the location, the position, the category, or the company fields. Story F4: A user clicks on a job to see more detailed information The user can select a job from the list to see more detailed information. Day 2: The Project 29 ----------------- Brought to you by Story F5: A user posts a job A user can post a job. A job is made of several pieces of information: • Company • Type (full-time, part-time, or freelance) • Logo (optional) • URL (optional) • Position • Location • Category (the user chooses in a list of possible categories) • Job description (URLs and emails are automatically linked) • How to apply (URLs and emails are automatically linked) • Public (whether the job can also be published on affiliate websites) • Email (email of the poster) There is no need to create an account to post a job. The process is straightforward with only two steps: first, the user fills in the form with all the needed information to describe the job, then he validates the information by previewing the final job page. Even if the user has no account, a job can be modified afterwards thanks to a specific URL (protected by a token given to the user when the job is created). Day 2: The Project 30 ----------------- Brought to you by Each job post is online for 30 days (this is configurable by the admin - see Story B2). A user can come back to re-activate or extend the validity of the job for an extra 30 days but only when the job expires in less than 5 days. Story F6: A user applies to become an affiliate A user needs to apply to become an affiliate and be authorized to use the Jobeet API. To apply, he must give the following information: • Name • Email • Website URL The affiliate account must be activated by the admin (Story B3). Once activated, the affiliate receives a token to use with the API via email. When applying, the affiliate can also choose to get jobs from a sub-set of the available categories. Story F7: An affiliate retrieves the current active job list An affiliate can retrieve the current job list by calling the API with his affiliate token. The list can be returned in the XML, JSON or YAML format. Day 2: The Project 31 ----------------- Brought to you by The list contains the public information available for a job. The affiliate can also limit the number of jobs to be returned, and refine his query by specifying a category. Story B1: An admin configures the website An admin can edit the categories available on the website. Story B2: An admin manages the jobs An admin can edit and remove any posted job. Story B3: An admin manages the affiliates The admin can create or edit affiliates. He is responsible for activating an affiliate and can also disable one. When the admin activates a new affiliate, the system creates a unique token to be used by the affiliate. Final Thoughts As for any web development, you never start coding the first day. You need to gather the requirements first and work on a mockup design. That’s what we have done here. Day 2: The Project 32 ----------------- Brought to you by Day 3 The Data Model Those of you itching to open your text editor and lay down some PHP will be happy to know today will get us into some development. We will define the Jobeet data model, use an ORM to interact with the database, and build the first module of the application. But as symfony does a lot of the work for us, we will have a fully functional web module without writing too much PHP code. The Relational Model The user stories we saw yesterday describe the main objects of our project: jobs, affiliates, and categories. Here is the corresponding entity relationship diagram: In addition to the columns described in the stories, we have also added a created_at field to some tables. Symfony recognizes such fields and sets the value to the current system time when a record is created. That’s the same for updated_at fields: Their value is set to the system time whenever the record is updated. The Schema To store the jobs, affiliates, and categories, we obviously need a relational database. Day 3: The Data Model 33 ----------------- Brought to you by Listing 3-1 But as symfony is an Object-Oriented framework, we like to manipulate objects whenever we can. For example, instead of writing SQL statements to retrieve records from the database, we’d rather prefer to use objects. The relational database information must be mapped to an object model. This can be done with an ORM tool and thankfully, symfony comes bundled with two of them: Propel16 and Doctrine17. In this tutorial, we will use Doctrine. The ORM needs a description of the tables and their relationships to create the related classes. There are two ways to create this description schema: by introspecting an existing database or by creating it by hand. As the database does not exist yet and as we want to keep Jobeet database agnostic, let’s create the schema file by hand by editing the empty config/doctrine/schema.yml file: # config/doctrine/schema.yml JobeetCategory: actAs: { Timestampable: ~ } columns: name: { type: string(255), notnull: true, unique: true } JobeetJob: actAs: { Timestampable: ~ } columns: category_id: { type: integer, notnull: true } type: { type: string(255) } company: { type: string(255), notnull: true } logo: { type: string(255) } url: { type: string(255) } position: { type: string(255), notnull: true } location: { type: string(255), notnull: true } description: { type: string(4000), notnull: true } how_to_apply: { type: string(4000), notnull: true } token: { type: string(255), notnull: true, unique: true } is_public: { type: boolean, notnull: true, default: 1 } is_activated: { type: boolean, notnull: true, default: 0 } email: { type: string(255), notnull: true } expires_at: { type: timestamp, notnull: true } relations: JobeetCategory: { onDelete: CASCADE, local: category_id, foreign: id, foreignAlias: JobeetJobs } JobeetAffiliate: actAs: { Timestampable: ~ } columns: url: { type: string(255), notnull: true } email: { type: string(255), notnull: true, unique: true } token: { type: string(255), notnull: true } is_active: { type: boolean, notnull: true, default: 0 } relations: JobeetCategories: class: JobeetCategory refClass: JobeetCategoryAffiliate local: affiliate_id foreign: category_id foreignAlias: JobeetAffiliates 16. http://www.propelorm.org/ 17. http://www.doctrine-project.org/ Day 3: The Data Model 34 ----------------- Brought to you by Listing 3-2 JobeetCategoryAffiliate: columns: category_id: { type: integer, primary: true } affiliate_id: { type: integer, primary: true } relations: JobeetCategory: { onDelete: CASCADE, local: category_id, foreign: id } JobeetAffiliate: { onDelete: CASCADE, local: affiliate_id, foreign: id } If you have decided to create the tables by writing SQL statements, you can generate the corresponding schema.yml configuration file by running the doctrine:build-schema task: $ php symfony doctrine:build-schema The above task requires that you have a configured database in databases.yml. We show you how to configure the database in a later step. If you try and run this task now it won’t work as it doesn’t know what database to build the schema for. The schema is the direct translation of the entity relationship diagram in the YAML format. The YAML Format According to the official YAML18 website, YAML is “a human friendly data serialization standard for all programming languages” Put another way, YAML is a simple language to describe data (strings, integers, dates, arrays, and hashes). In YAML, structure is shown through indentation, sequence items are denoted by a dash, and key/value pairs within a map are separated by a colon. YAML also has a shorthand syntax to describe the same structure with fewer lines, where arrays are explicitly shown with [] and hashes with {}. If you are not yet familiar with YAML, it is time to get started as the symfony framework uses it extensively for its configuration files. A good starting point is the symfony YAML component documentation19. There is one important thing you need to remember when editing a YAML file: indentation must be done with one or more spaces, but never with tabulations. The schema.yml file contains the description of all tables and their columns. Each column is described with the following information: • type: The column type (boolean, integer, float, decimal, string, array, object, blob, clob, timestamp, time, date, enum, gzip) • notnull: Set it to true if you want the column to be required • unique: Set it to true if you want to create a unique index for the column. The onDelete attribute defines the ON DELETE behavior of foreign keys, and Doctrine supports CASCADE, SET NULL, and RESTRICT. For instance, when a job record is deleted, all the jobeet_category_affiliate related records will be automatically deleted by the database. 18. http://yaml.org/ 19. http://components.symfony-project.org/yaml/documentation Day 3: The Data Model 35 ----------------- Brought to you by Listing 3-3 Listing 3-4 Listing 3-5 Listing 3-6 Listing 3-7 The Database The symfony framework supports all PDO-supported databases (MySQL, PostgreSQL, SQLite, Oracle, MSSQL, …). PDO20 is the database abstraction layer|Database Abstraction Layer bundled with PHP. Let’s use MySQL for this tutorial: $ mysqladmin -uroot -p create jobeet Enter password: mYsEcret ## The password will echo as ******** Feel free to choose another database engine if you want. It won’t be difficult to adapt the code we will write as we will use the ORM will write the SQL for us. We need to tell symfony to use this database for the Jobeet project: $ php symfony configure:database "mysql:host=localhost;dbname=jobeet" root mYsEcret The configure:database task takes three arguments: the PDO DSN21, the username, and the password to access the database. If you don’t need a password to access your database on the development server, just omit the third argument. The configure:database task stores the database configuration into the config/ databases.yml configuration file. Instead of using the task, you can edit this file by hand. Passing the database password on the command line is convenient but insecure22. Depending on who has access to your environment, it might be better to edit the config/ databases.yml to change the password. Of course, to keep the password safe, the configuration file access mode should also be restricted. The ORM Thanks to the database description from the schema.yml file, we can use some Doctrine built-in tasks to generate the SQL statements needed to create the database tables: First in order to generate the SQL you must build your models from your schema files. $ php symfony doctrine:build --model Now that your models are present you can generate and insert the SQL. $ php symfony doctrine:build --sql The doctrine:build --sql task generates SQL statements in the data/sql/ directory, optimized for the database engine we have configured: # snippet from data/sql/schema.sql CREATE TABLE jobeet_category (id BIGINT AUTO_INCREMENT, name VARCHAR(255) 20. http://www.php.net/PDO 21. http://www.php.net/manual/en/pdo.drivers.php 22. http://dev.mysql.com/doc/refman/5.1/en/password-security.html Day 3: The Data Model 36 ----------------- Brought to you by Listing 3-8 Listing 3-9 Listing 3-10 Listing 3-11 Listing 3-12 NOT NULL COMMENT 'test', created_at DATETIME, updated_at DATETIME, slug VARCHAR(255), UNIQUE INDEX sluggable_idx (slug), PRIMARY KEY(id)) ENGINE = INNODB; To actually create the tables in the database, you need to run the doctrine:insert-sql task: $ php symfony doctrine:insert-sql As for any command line tool, symfony tasks can take arguments and options. Each task comes with a built-in help message that can be displayed by running the help task: $ php symfony help doctrine:insert-sql The help message lists all the possible arguments and options, gives the default values for each of them, and provides some useful usage examples. The ORM also generates PHP classes that map table records to objects: $ php symfony doctrine:build --model The doctrine:build --model task generates PHP files in the lib/model/ directory that can be used to interact with the database. By browsing the generated files, you have probably noticed that Doctrine generates three classes per table. For the jobeet_job table: • JobeetJob: An object of this class represents a single record of the jobeet_job table. The class is empty by default. • BaseJobeetJob: The parent class of JobeetJob. Each time you run doctrine:build --model, this class is overwritten, so all customizations must be done in the JobeetJob class. • JobeetJobTable: The class defines methods that mostly return collections of JobeetJob objects. The class is empty by default. The column values of a record can be manipulated with a model object by using some accessors (get*() methods) and mutators (set*() methods): $job = new JobeetJob(); $job->setPosition('Web developer'); $job->save(); echo $job->getPosition(); $job->delete(); You can also define foreign keys directly by linking objects together: $category = new JobeetCategory(); $category->setName('Programming'); $job = new JobeetJob(); $job->setCategory($category); The doctrine:build --all task is a shortcut for the tasks we have run in this section and some more. So, run this task now to generate forms and validators for the Jobeet model classes: Day 3: The Data Model 37 ----------------- Brought to you by Listing 3-13 Listing 3-14 $ php symfony doctrine:build --all --no-confirmation You will see validators in action today and forms will be explained in great details on day 10. The Initial Data The tables have been created in the database but there is no data in them. For any web application, there are three types of data: • Initial data: Initial data are needed for the application to work. For example, Jobeet needs some initial categories. If not, nobody will be able to submit a job. We also need an admin user to be able to login to the backend. • Test data: Test Data are needed for the application to be tested. As a developer, you will write tests to ensure that Jobeet behaves as described in the user stories, and the best way is to write automated tests. So, each time you run your tests, you need a clean database with some fresh data to test on. • User data: User data are created by the users during the normal life of the application. Each time symfony creates the tables in the database, all the data are lost. To populate the database with some initial data, we could create a PHP script, or execute some SQL statements with the mysql program. But as the need is quite common, there is a better way with symfony: create YAML files in the data/fixtures/ directory and use the doctrine:data-load task to load them into the database. First, create the following fixture files: # data/fixtures/categories.yml JobeetCategory: design: name: Design programming: name: Programming manager: name: Manager administrator: name: Administrator # data/fixtures/jobs.yml JobeetJob: job_sensio_labs: JobeetCategory: programming type: full-time company: Sensio Labs logo: sensio-labs.gif url: http://www.sensiolabs.com/ position: Web Developer location: Paris, France description: | You've already developed websites with symfony and you want to work with Open-Source technologies. You have a minimum of 3 years experience in web development with PHP or Java and you wish to participate to development of Web 2.0 sites using the best frameworks available. how_to_apply: | Send your resume to fabien.potencier [at] sensio.com Day 3: The Data Model 38 ----------------- Brought to you by is_public: true is_activated: true token: job_sensio_labs email: job@example.com expires_at: '2010-10-10' job_extreme_sensio: JobeetCategory: design type: part-time company: Extreme Sensio logo: extreme-sensio.gif url: http://www.extreme-sensio.com/ position: Web Designer location: Paris, France description: | Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in. Voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. how_to_apply: | Send your resume to fabien.potencier [at] sensio.com is_public: true is_activated: true token: job_extreme_sensio email: job@example.com expires_at: '2010-10-10' The job fixture file references two images. You can download them (http://www.symfony-project.org/get/jobeet/sensio-labs.gif, http://www.symfony-project.org/get/jobeet/extreme-sensio.gif) and put them under the web/uploads/jobs/ directory. A fixtures file is written in YAML, and defines model objects, labelled with a unique name (for instance, we have defined two jobs labelled job_sensio_labs and job_extreme_sensio). This label is of great use to link related objects without having to define primary keys (which are often auto-incremented and cannot be set). For instance, the job_sensio_labs job category is programming, which is the label given to the ‘Programming’ category. In a YAML file, when a string contains line breaks (like the description column in the job fixture file), you can use the pipe (|) to indicate that the string will span several lines. Although a fixture file can contain objects from one or several models, we have decided to create one file per model for the Jobeet fixtures. Propel requires that the fixtures files be prefixed with numbers to determine the order in which the files will be loaded. With Doctrine this is not required as all fixtures will be loaded and saved in the correct order to make sure foreign keys are set properly. In a fixture file, you don’t need to define all columns values. If not, symfony will use the default value defined in the database schema. And as symfony uses Doctrine to load the data Day 3: The Data Model 39 ----------------- Brought to you by Listing 3-15 Listing 3-16 Listing 3-17 into the database, all the built-in behaviors (like automatically setting the created_at or updated_at columns) and the custom behaviors you might have added to the model classes are activated. Loading the initial data into the database is as simple as running the doctrine:data-load task: $ php symfony doctrine:data-load The doctrine:build --all --and-load task is a shortcut for the doctrine:build - -all task followed by the doctrine:data-load task. Run the doctrine:build --all --and-load task to make sure everything is generated from your schema. This will generate your forms, filters, models, drop your database and re- create it with all the tables. $ php symfony doctrine:build --all --and-load See it in Action in the Browser We have used the command line interface a lot but that’s not really exciting, especially for a web project. We now have everything we need to create Web pages that interact with the database. Let’s see how to display the list of jobs, how to edit an existing job, and how to delete a job. As explained during the first day, a symfony project is made of applications. Each application is further divided into modules. A module is a self-contained set of PHP code that represents a feature of the application (the API module for example), or a set of manipulations the user can do on a model object (a job module for example). Symfony is able to automatically generate a module for a given model that provides basic manipulation features: $ php symfony doctrine:generate-module --with-show --non-verbose-templates frontend job JobeetJob The doctrine:generate-module generates a job module in the frontend application for the JobeetJob model. As with most symfony tasks, some files and directories have been created for you under the apps/frontend/modules/job/ directory: Directory Description actions/ The module actions templates/ The module templates The actions/actions.class.php file defines all the available action for the job module: Action name Description index Displays the records of the table show Displays the fields and their values for a given record new Displays a form to create a new record create Creates a new record edit Displays a form to edit an existing record Day 3: The Data Model 40 ----------------- Brought to you by Listing 3-18 Listing 3-19 Action name Description update Updates a record according to the user submitted values delete Deletes a given record from the table You can now test the job module in a browser: http://www.jobeet.com.localhost/frontend_dev.php/job If you try to edit a job, you will notice the Category id drop down has a list of all the category names. The value of each option is gotten from the __toString() method. Doctrine will try and provide a base __toString() method by guessing a descriptive column name like, title, name, subject, etc. If you want something custom then you will need to add your own __toString() methods like below. The JobeetCategory model is able to guess the __toString() method by using the name column of the jobeet_category table. // lib/model/doctrine/JobeetJob.class.php class JobeetJob extends BaseJobeetJob { public function __toString() { return sprintf('%s at %s (%s)', $this->getPosition(), $this->getCompany(), $this->getLocation()); } } // lib/model/doctrine/JobeetAffiliate.class.php class JobeetAffiliate extends BaseJobeetAffiliate { public function __toString() { Day 3: The Data Model 41 ----------------- Brought to you by return $this->getUrl(); } } You can now create and edit jobs. Try to leave a required field blank, or try to enter an invalid date. That’s right, symfony has created basic validation rules by introspecting the database schema. Final Thoughts That’s all. I have warned you in the introduction. Today, we have barely written PHP code but we have a working web module for the job model, ready to be tweaked and customized. Remember, no PHP code also means no bugs! If you still have some energy left, feel free to read the generated code for the module and the model and try to understand how it works. If not, don’t worry and sleep well, as tomorrow we will talk about one of the most used paradigm in web frameworks, the MVC design pattern23. 23. http://en.wikipedia.org/wiki/Model-view-controller Day 3: The Data Model 42 ----------------- Brought to you by Day 4 The Controller and the View Yesterday, we explored how symfony simplifies database management by abstracting the differences between database engines, and by converting the relational elements to nice object oriented classes. We have also played with Doctrine to describe the database schema, create the tables, and populate the database with some initial data. Today, we are going to customize the basic job module we created previously. The job module already has all the code we need for Jobeet: • A page to list all jobs • A page to create a new job • A page to update an existing job • A page to delete a job Although the code is ready to be used as is, we will refactor the templates to match closer to the Jobeet mockups. The MVC Architecture If you are used to developing PHP websites without a framework, you probably use the one PHP file per HTML page paradigm. These PHP files probably contain the same kind of structure: initialization and global configuration, business logic related to the requested page, database records fetching, and finally HTML code that builds the page. You may use a templating engine to separate the logic from the HTML. Perhaps you use a database abstraction layer to separate model interaction from business logic. But most of the time, you end up with a lot of code that is a nightmare to maintain. It was fast to build, but over time, it’s more and more difficult to make changes, especially because nobody except you understands how it is built and how it works. As with every problem, there are nice solutions. For web development, the most common solution for organizing your code nowadays is the MVC design pattern24. In short, the MVC design pattern defines a way to organize your code according to its nature. This pattern separates the code into three layers: • The Model layer defines the business logic (the database belongs to this layer). You already know that symfony stores all the classes and files related to the Model in the lib/model/ directory. • The View is what the user interacts with (a template engine is part of this layer). In symfony, the View layer is mainly made of PHP templates. They are stored in various templates/ directories as we will see later in these lines. 24. http://en.wikipedia.org/wiki/Model-view-controller Day 4: The Controller and the View 43 ----------------- Brought to you by • The Controller is a piece of code that calls the Model to get some data that it passes to the View for rendering to the client. When we installed symfony at the beginning of this book, we saw that all requests are managed by front controllers (index.php and frontend_dev.php). These front controllers delegate the real work to actions. As we saw previously, these actions are logically grouped into modules. Today, we will use the mockup defined in day 2 to customize the homepage and the job page. We will also make them dynamic. Along the way, we will tweak a lot of things in many different files to demonstrate the symfony directory structure and the way to separate code between layers. The Layout First, if you have a closer look at the mockups, you will notice that much of each page looks the same. You already know that code duplication is bad, whether we are talking about HTML or PHP code, so we need to find a way to prevent these common view elements from resulting in code duplication. One way to solve the problem is to define a header and a footer and include them in each template: Day 4: The Controller and the View 44 ----------------- Brought to you by Listing 4-1 But here the header and the footer files do not contain valid HTML. There must be a better way. Instead of reinventing the wheel, we will use another design pattern to solve this problem: the decorator design pattern25. The decorator design pattern resolves the problem the other way around: the template is decorated after the content is rendered by a global template, called a layout in symfony: The default layout of an application is called layout.php and can be found in the apps/ frontend/templates/ directory. This directory contains all the global templates for an application. Replace the default symfony layout with the following code: Jobeet - Your best job board
hasFlash('notice')): ?>
getFlash('notice') ?>
hasFlash('error')): ?>
getFlash('error') ?>
A symfony template is just a plain PHP file. In the layout template, you see calls to PHP functions and references to PHP variables. $sf_content is the most interesting variable: it is defined by the framework itself and contains the HTML generated by the action. If you browse the job module (http://www.jobeet.com.localhost/ frontend_dev.php/job), you will see that all actions are now decorated by the layout. Day 4: The Controller and the View 46 ----------------- Brought to you by The Stylesheets, Images, and JavaScripts As this tutorial is not about web design, we have already prepared all the needed assets we will use for Jobeet: download the image files26 archive and put them into the web/images/ directory; download the stylesheet files27 archive and put them into the web/css/ directory. In the layout, we have included a favicon. You can download the Jobeet one28 and put it under the web/ directory. By default, the generate:project task has created three directories for the project assets: web/images/ for images, web/~css|CSS~/ for stylesheets, and web/js/ for JavaScripts. This is one of the many conventions defined by symfony, but you can of course store them elsewhere under the web/ directory. The astute reader will have noticed that even if the main.css file is not mentioned anywhere in the default layout, it is definitely present in the generated HTML. But not the other ones. How is this possible? The stylesheet file has been included by the include_stylesheets() function call found within the layout tag. The include_stylesheets() function is called a helper. A helper is a function, defined by symfony, that can take parameters and returns HTML code. Most of the time, helpers are time-savers, they package code snippets frequently used in templates. The include_stylesheets() helper generates tags for stylesheets. 26. http://www.symfony-project.org/get/jobeet/images.zip 27. http://www.symfony-project.org/get/jobeet/css.zip 28. http://www.symfony-project.org/get/jobeet/favicon.ico Day 4: The Controller and the View 47 ----------------- Brought to you by Listing 4-2 Listing 4-3 Listing 4-4 Listing 4-5 But how does the helper know which stylesheets to include? The View layer can be configured by editing the view.yml configuration file of the application. Here is the default one generated by the generate:app task: # apps/frontend/config/view.yml default: http_metas: content-type: text/html metas: #title: symfony project #description: symfony project #keywords: symfony, project #language: en #robots: index, follow stylesheets: [main.css] javascripts: [] has_layout: true layout: layout The view.yml file configures the default settings for all the templates of the application. For instance, the stylesheets entry defines an array of stylesheet files to include for every page of the application (the inclusion is done by the include_stylesheets() helper). In the default view.yml configuration file, the referenced file is main.css, and not /css/ main.css. As a matter of fact, both definitions are equivalent as symfony prefixes relative paths with /~css|CSS~/. If many files are defined, symfony will include them in the same order as the definition: stylesheets: [main.css, jobs.css, job.css] You can also change the media attribute and omit the .css suffix: stylesheets: [main.css, jobs.css, job.css, print: { media: print }] This configuration will be rendered as: The view.yml configuration file also defines the default layout used by the application. By default, the name is layout, and so symfony decorates every page with the layout.php file. You can also disable the decoration process altogether by switching the has_layout entry to false. Day 4: The Controller and the View 48 ----------------- Brought to you by Listing 4-6 Listing 4-7 Listing 4-8 Listing 4-9 It works as is but the jobs.css file is only needed for the homepage and the job.css file is only needed for the job page. The view.yml configuration file can be customized on a per- module basis. Change the stylesheets key of the application view.yml file to only contain the main.css file: # apps/frontend/config/view.yml stylesheets: [main.css] To customize the view for the job module, create a view.yml file in the apps/frontend/ modules/job/config/ directory: # apps/frontend/modules/job/config/view.yml indexSuccess: stylesheets: [jobs.css] showSuccess: stylesheets: [job.css] Under the indexSuccess and showSuccess sections (they are the template names associated with the index and show actions, as we will see later on), you can customize any entry found under the default section of the application view.yml. All specific entries are merged with the application configuration. You can also define some configuration for all actions of a module with the special all section. Configuration Principles in symfony For many symfony configuration files, the same setting can be defined at different levels: • The default configuration is located in the framework • The global configuration for the project (in config/) • The local configuration for an application (in apps/APP/config/) • The local configuration restricted to a module (in apps/APP/modules/MODULE/ config/) At runtime, the configuration system merges all the values from the different files if they exist and caches the result for better performance. As a rule of thumb, when something is configurable via a configuration file, the same can be accomplished with PHP code. Instead of creating a view.yml file for the job module for instance, you can also use the use_stylesheet() helper to include a stylesheet from a template: You can also use this helper in the layout to include a stylesheet globally. Choosing between one method or the other is really a matter of taste. The view.yml file provides a way to define things for all actions of a module, which is not possible in a template, but the configuration is quite static. On the other hand, using the use_stylesheet() helper is more flexible and moreover, everything is in the same place: the stylesheet definition and the HTML code. For Jobeet, we will use the use_stylesheet() helper, so you can remove the view.yml we have just created and update the job templates with the use_stylesheet() calls: Day 4: The Controller and the View 49 ----------------- Brought to you by Listing 4-10 Listing 4-11 Listing 4-12 Symmetrically, the JavaScript configuration is done via the javascripts entry of the view.yml configuration file and the use_javascript() helper defines JavaScript files to include for a template. The Job Homepage As seen in day 3, the job homepage is generated by the index action of the job module. The index action is the Controller part of the page and the associated template, indexSuccess.php, is the View part: apps/ frontend/ modules/ job/ actions/ actions.class.php templates/ indexSuccess.php The Action Each action is represented by a method of a class. For the job homepage, the class is jobActions (the name of the module suffixed by Actions) and the method is executeIndex() (execute suffixed by the name of the action). It retrieves all the jobs from the database: // apps/frontend/modules/job/actions/actions.class.php class jobActions extends sfActions { public function executeIndex(sfWebRequest $request) { $this->jobeet_jobs = Doctrine::getTable('JobeetJob') ->createQuery('a') ->execute(); } // ... } Let’s have a closer look at the code: the executeIndex() method (the Controller) calls the Table JobeetJob to create a query to retrieve all the jobs. It returns a Doctrine_Collection of JobeetJob objects that are assigned to the jobeet_jobs object property. All such object properties are then automatically passed to the template (the View). To pass data from the Controller to the View, just create a new property: public function executeFooBar(sfWebRequest $request) { $this->foo = 'bar'; $this->bar = array('bar', 'baz'); } Day 4: The Controller and the View 50 ----------------- Brought to you by Listing 4-13 Listing 4-14 This code will make $foo and $bar variables accessible in the template. The Template By default, the template name associated with an action is deduced by symfony thanks to a convention (the action name suffixed by Success). The indexSuccess.php template generates an HTML table for all the jobs. Here is the current template code:

Job List

Id Category Type Created at Updated at
getId() ?> getCategoryId() ?> getType() ?> getCreatedAt() ?> getUpdatedAt() ?>
New In the template code, the foreach iterates through the list of Job objects ($jobeet_jobs), and for each job, each column value is output. Remember, accessing a column value is as simple as calling an accessor method which name begins with get and the camelCased column name (for instance the getCreatedAt() method for the created_at column). Let’s clean this up a bit to only display a sub-set of the available columns:
Day 4: The Controller and the View 51 ----------------- Brought to you by Listing 4-15 $job): ?>
getLocation() ?> getPosition() ?> getCompany() ?>
The url_for() function call in this template is a symfony helper that we will discuss tomorrow. The Job Page Template Now let’s customize the template of the job page. Open the showSuccess.php file and replace its content with the following code:

getCompany() ?>

getLocation() ?>

getPosition() ?> - getType() ?>

Day 4: The Controller and the View 52 ----------------- Brought to you by Listing 4-16 Listing 4-17 getLogo()): ?>
getDescription()) ?>

How to apply?

getHowToApply() ?>

posted on getDateTimeObject('created_at')->format('m/d/Y') ?>
Edit
This template uses the $job variable passed by the action to display the job information. As we have renamed the variable passed to the template from $jobeet_job to $job, you need to also make this change in the show action (be careful, there are two occurrences of the variable): // apps/frontend/modules/job/actions/actions.class.php public function executeShow(sfWebRequest $request) { $this->job = Doctrine::getTable('JobeetJob')-> find($request->getParameter('id')); $this->forward404Unless($this->job); } Notice that date columns can be converted to PHP DateTime object instances. As we have defined the created_at column as a timestamp, you can convert the column value to a DateTime object by using the getDateTimeObject() method and then call the format() method which takes a date formatting pattern as its first argument: $job->getDateTimeObject('created_at')->format('m/d/Y'); The job description uses the simple_format_text() helper to format it as HTML, by replacing carriage returns with
for instance. As this helper belongs to the Text helper group, which is not loaded by default, we have loaded it manually by using the use_helper() helper. Day 4: The Controller and the View 53 ----------------- Brought to you by Listing 4-18 Listing 4-19 Slots Right now, the title of all pages is defined in the tag of the layout: <title>Jobeet - Your best job board But for the job page, we want to provide more useful information, like the company name and the job position. In symfony, when a zone of the layout depends on the template to be displayed, you need to define a slot: Add a slot to the layout to allow the title to be dynamic: // apps/frontend/templates/layout.php <?php include_slot('title') ?> Each slot is defined by a name (title) and can be displayed by using the include_slot() helper. Now, at the beginning of the showSuccess.php template, use the slot() helper to define the content of the slot for the job page: Day 4: The Controller and the View 54 ----------------- Brought to you by Listing 4-20 Listing 4-21 Listing 4-22 Listing 4-23 Listing 4-24 Listing 4-25 // apps/frontend/modules/job/templates/showSuccess.php getCompany(), $job->getPosition())) ?> If the title is complex to generate, the slot() helper can also be used with a block of code: // apps/frontend/modules/job/templates/showSuccess.php getCompany(), $job->getPosition()) ?> For some pages, like the homepage, we just need a generic title. Instead of repeating the same title over and over again in templates, we can define a default title in the layout: // apps/frontend/templates/layout.php <?php include_slot('title', 'Jobeet - Your best job board') ?> The second argument of the include_slot() method is the default value for the slot if it has not been defined. If the default value is longer or has some HTML tags, you can also defined it like in the following code: // apps/frontend/templates/layout.php <?php if (!include_slot('title')): ?> Jobeet - Your best job board <?php endif ?> The include_slot() helper returns true if the slot has been defined. So, when you define the title slot content in a template, it is used; if not, the default title is used. We have already seen quite a few helpers beginning with include_. These helpers output the HTML and in most cases have a get_ helper counterpart to just return the content: The Job Page Action The job page is generated by the show action, defined in the executeShow() method of the job module: class jobActions extends sfActions { public function executeShow(sfWebRequest $request) Day 4: The Controller and the View 55 ----------------- Brought to you by { $this->job = Doctrine::getTable('JobeetJob')-> find($request->getParameter('id')); $this->forward404Unless($this->job); } // ... } As in the index action, the JobeetJob table class is used to retrieve a job, this time by using the find() method. The parameter of this method is the unique identifier of a job, its primary key. The next section will explain why the $request->getParameter('id') statement returns the job primary key. If the job does not exist in the database, we want to forward the user to a 404 page, which is exactly what the forward404Unless() method does. It takes a Boolean as its first argument and, unless it is true, stops the current flow of execution. As the forward methods stops the execution of the action right away by throwing a sfError404Exception, you don’t need to return afterwards. As for exceptions, the page displayed to the user is different in the prod environment and in the dev environment: Before you deploy the Jobeet website to the production server, you will learn how to customize the default 404 page. Day 4: The Controller and the View 56 ----------------- Brought to you by Listing 4-26 Listing 4-27 Listing 4-28 The “forward” Methods Family The forward404Unless call is actually equivalent to: $this->forward404If(!$this->job); which is also equivalent to: if (!$this->job) { $this->forward404(); } The forward404() method itself is just a shortcut for: $this->forward('default', '404'); The forward() method forwards to another action of the same application; in the previous example, to the 404 action of the default module. The default module is bundled with symfony and provides default actions to render 404, secure, and login pages. The Request and the Response When you browse to the /job or /job/show/id/1 pages in your browser, your are initiating a round trip with the web server. The browser is sending a request and the server sends back a response|HTTP Response. We have already seen that symfony encapsulates the request in a sfWebRequest object (see the executeShow() method signature). And as symfony is an Object-Oriented framework, the response is also an object, of class sfWebResponse. You can access the response object in an action by calling $this->getResponse(). These objects provide a lot of convenient methods to access information from PHP functions and PHP global variables. Why does symfony wrap existing PHP functionalities? First, because the symfony methods are more powerful than their PHP counterpart. Then, because when you test an application, it is much more easier to simulate a request or a response object than trying to fiddle around with global variables or work with PHP functions like header() which do too much magic behind the scene. The Request The sfWebRequest class wraps the $_SERVER, $_COOKIE, $_GET, $_POST, and $_FILES PHP global arrays: Method name PHP equivalent getMethod() $_SERVER['REQUEST_METHOD'] getUri() $_SERVER['REQUEST_URI'] getReferer() $_SERVER['HTTP_REFERER'] getHost() $_SERVER['HTTP_HOST'] getLanguages() $_SERVER['HTTP_ACCEPT_LANGUAGE'] Day 4: The Controller and the View 57 ----------------- Brought to you by Method name PHP equivalent getCharsets() $_SERVER['HTTP_ACCEPT_CHARSET'] isXmlHttpRequest() $_SERVER['X_REQUESTED_WITH'] == 'XMLHttpRequest' getHttpHeader() $_SERVER getCookie() $_COOKIE isSecure() $_SERVER['HTTPS'] getFiles() $_FILES getGetParameter() $_GET getPostParameter() $_POST getUrlParameter() $_SERVER['PATH_INFO'] getRemoteAddress() $_SERVER['REMOTE_ADDR'] We have already accessed request parameters by using the getParameter() method. It returns a value from the $_GET or $_POST global variable, or from the PATH_INFO variable. If you want to ensure that a request parameter comes from a particular one of these variables, you need use the getGetParameter(), getPostParameter(), and getUrlParameter() methods respectively. When you want to restrict an action for a specific HTTP method, for instance when you want to ensure that a form is submitted as a POST, you can use the isMethod() method: $this->forwardUnless($request->isMethod('POST'));. The Response The sfWebResponse class wraps the header() and setrawcookie() PHP methods: Method name PHP equivalent setCookie() setrawcookie() setStatusCode() header() setHttpHeader() header() setContentType() header() addVaryHttpHeader() header() addCacheControlHttpHeader() header() Of course, the sfWebResponse class also provides a way to set the content of the response (setContent()) and send the response to the browser (send()). Earlier today we saw how to manage stylesheets and JavaScripts in both the view.yml file and in templates. In the end, both techniques use the response object addStylesheet() and addJavascript() methods. The sfAction29, sfRequest30, and sfResponse31 classes provide a lot of other useful methods. Don’t hesitate to browse the API documentation32 to learn more about all symfony internal classes. 29. http://www.symfony-project.org/api/1_4/sfAction 30. http://www.symfony-project.org/api/1_4/sfRequest 31. http://www.symfony-project.org/api/1_4/sfResponse Day 4: The Controller and the View 58 ----------------- Brought to you by Final Thoughts Today, we have described some design patterns used by symfony. Hopefully the project directory structure now makes more sense. We have played with templates by manipulating the layout and template files. We have also made them a bit more dynamic thanks to slots and actions. Tomorrow, we will be dedicated to the url_for() helper we have used here, and the routing sub-framework associated with it. 32. http://www.symfony-project.org/api/1_4/ Day 4: The Controller and the View 59 ----------------- Brought to you by Listing 5-1 Listing 5-2 Listing 5-3 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&... Day 5: The Routing 60 ----------------- Brought to you by Listing 5-4 Listing 5-5 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 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 @: Day 5: The Routing 61 ----------------- Brought to you by Listing 5-6 Listing 5-7 Listing 5-8 Listing 5-9 Listing 5-10 Listing 5-11 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:

Jobeet  Job Board

That was easy! 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. 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: Day 5: The Routing 62 ----------------- Brought to you by Listing 5-12 Listing 5-13 Listing 5-14 Listing 5-15 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 sfRoute33. 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|HEAD (HTTP Method), 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 sfRequestRoute34 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 } 33. http://www.symfony-project.org/api/1_4/sfRoute 34. http://www.symfony-project.org/api/1_4/sfRequestRoute Day 5: The Routing 63 ----------------- Brought to you by Listing 5-16 Listing 5-17 Listing 5-18 Listing 5-19 requirements: id: \d+ sf_method: [get] 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 sfDoctrineRoute35 as the class is optimized for routes that represent Doctrine objects or collections of Doctrine objects: job_show_user: url: /job/:company/:location/:id/:position class: sfDoctrineRoute 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 Doctrine 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 just: url_for('job_show_user', $job) 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: 35. http://www.symfony-project.org/api/1_4/sfDoctrineRoute Day 5: The Routing 64 ----------------- Brought to you by Listing 5-20 Listing 5-21 Listing 5-22 Listing 5-23 // lib/model/doctrine/JobeetJob.class.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; } } In this tutorial, we never show the opening 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(); } // ... } If you don’t want the route to generate a 404 error, you can set the allow_empty routing option to true. The related object of a route is lazy loaded. It is only retrieved from the database if you call the getRoute() method. Day 5: The Routing 66 ----------------- Brought to you by Listing 5-26 Listing 5-27 Listing 5-28 Listing 5-29 Listing 5-30 Listing 5-31 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 tag: getPosition(), 'job_show_user', $job) ?> It generates the following HTML code: Web Developer 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 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 sfDoctrineRoute 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 sfDoctrineRouteCollection36 class. Open the routing.yml file and modify it to read as follows: # apps/frontend/config/routing.yml job: class: sfDoctrineRouteCollection options: { model: JobeetJob } 36. http://www.symfony-project.org/api/1_4/sfDoctrineRouteCollection Day 5: The Routing 67 ----------------- Brought to you by Listing 5-32 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] # 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 sfDoctrineRoute routes: job: url: /job.:sf_format class: sfDoctrineRoute 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: sfDoctrineRoute options: { model: JobeetJob, type: object } param: { module: job, action: new, sf_format: html } requirements: { sf_method: get } job_create: url: /job.:sf_format class: sfDoctrineRoute 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: sfDoctrineRoute 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: sfDoctrineRoute options: { model: JobeetJob, type: object } param: { module: job, action: update, sf_format: html } Day 5: The Routing 68 ----------------- Brought to you by Listing 5-33 Listing 5-34 Listing 5-35 requirements: { sf_method: put } job_delete: url: /job/:id.:sf_format class: sfDoctrineRoute 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: sfDoctrineRoute options: { model: JobeetJob, type: object } param: { module: job, action: show, sf_format: html } requirements: { sf_method: get } Some routes generated by sfDoctrineRouteCollection 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
getObject()->isNew()): ?> 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 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 Day 5: The Routing 69 ----------------- Brought to you by Listing 5-36 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. Day 5: The Routing 70 ----------------- Brought to you by Listing 6-1 Listing 6-2 Day 6 More with the Model Yesterday was great. You learned how to create pretty URLs and how to use the symfony framework to automate a lot of things for you. Today, we will enhance the Jobeet website by tweaking the code here and there. In the process, you will learn more about all the features we have introduced during the first five days of this tutorial. The Doctrine Query Object From the second day’s requirements: “When a user comes to the Jobeet website, she sees a list of active jobs.” But as of now, all jobs are displayed, whether they are active or not: // apps/frontend/modules/job/actions/actions.class.php class jobActions extends sfActions { public function executeIndex(sfWebRequest $request) { $this->jobeet_jobs = Doctrine::getTable('JobeetJob') ->createQuery('a') ->execute(); } // ... } An active job is one that was posted less than 30 days ago. The ~Doctrine_Query~::execute() method will make a request to the database. In the code above, we are not specifying any where condition which means that all the records are retrieved from the database. Let’s change it to only select active jobs: public function executeIndex(sfWebRequest $request) { $q = Doctrine_Query::create() ->from('JobeetJob j') ->where('j.created_at > ?', date('Y-m-d H:i:s', time() - 86400 * 30)); Day 6: More with the Model 71 ----------------- Brought to you by Listing 6-3 $this->jobeet_jobs = $q->execute(); } Debugging Doctrine generated SQL As you don’t write the SQL statements by hand, Doctrine will take care of the differences between database engines and will generate SQL statements optimized for the database engine you choose during day 3. But sometimes, it is of great help to see the SQL generated by Doctrine; for instance, to debug a query that does not work as expected. In the dev environment, symfony logs these queries (along with much more) in the log/ directory. There is one log file for every combination of an application and an environment. The file we are looking for is named frontend_dev.log: # log/frontend_dev.log Dec 04 13:58:33 symfony [info] {sfDoctrineLogger} executeQuery : SELECT j.id AS j__id, j.category_id AS j__category_id, j.type AS j__type, j.company AS j__company, j.logo AS j__logo, j.url AS j__url, j.position AS j__position, j.location AS j__location, j.description AS j__description, j.how_to_apply AS j__how_to_apply, j.token AS j__token, j.is_public AS j__is_public, j.is_activated AS j__is_activated, j.email AS j__email, j.expires_at AS j__expires_at, j.created_at AS j__created_at, j.updated_at AS j__updated_at FROM jobeet_job j WHERE j.created_at > ? (2008-11-08 01:13:35) You can see for yourself that Doctrine has a where clause for the created_at column (WHERE j.created_at > ?). The ? string in the query indicates that Doctrine generates prepared statements. The actual value of ? (‘2008-11-08 01:13:35’ in the example above) is passed during the execution of the query and properly escaped by the database engine. The use of prepared statements dramatically reduces your exposure to SQL injection37 attacks. This is good, but it’s a bit annoying to have to switch between the browser, the IDE, and the log file every time you need to test a change. Thanks to the symfony web debug toolbar, all the information you need is also available within the comfort of your browser: Object Serialization Even if the above code works, it is far from perfect as it does not take into account some requirements from day 2: “A user can come back to re-activate or extend the validity of the job ad for an extra 30 days…” But as the above code only relies on the created_at value, and because this column stores the creation date, we cannot satisfy the above requirement. But if you remember the database schema we have described during day 3, we also have defined an expires_at column. Currently, if this value is not set in fixture file, it remains 37. http://en.wikipedia.org/wiki/Sql_injection Day 6: More with the Model 72 ----------------- Brought to you by Listing 6-4 Listing 6-5 Listing 6-6 always empty. But when a job is created, it can be automatically set to 30 days after the current date. When you need to do something automatically before a Doctrine object is serialized to the database, you can override the save() method of the model class: // lib/model/doctrine/JobeetJob.class.php class JobeetJob extends BaseJobeetJob { public function save(Doctrine_Connection $conn = null) { if ($this->isNew() && !$this->getExpiresAt()) { $now = $this->getCreatedAt() ? $this->getDateTimeObject('created_at')->format('U') : time(); $this->setExpiresAt(date('Y-m-d H:i:s', $now + 86400 * 30)); } return parent::save($conn); } // ... } The isNew() method returns true when the object has not been serialized yet in the database, and false otherwise. Now, let’s change the action to use the expires_at column instead of the created_at one to select the active jobs: public function executeIndex(sfWebRequest $request) { $q = Doctrine_Query::create() ->from('JobeetJob j') ->where('j.expires_at > ?', date('Y-m-d H:i:s', time())); $this->jobeet_jobs = $q->execute(); } We restrict the query to only select jobs with the expires_at date in the future. More with Fixtures Refreshing the Jobeet homepage in your browser won’t change anything as the jobs in the database have been posted just a few days ago. Let’s change the fixtures to add a job that is already expired: # data/fixtures/jobs.yml JobeetJob: # other jobs expired_job: JobeetCategory: programming company: Sensio Labs position: Web Developer location: Paris, France description: Lorem ipsum dolor sit amet, consectetur adipisicing Day 6: More with the Model 73 ----------------- Brought to you by Listing 6-7 Listing 6-8 Listing 6-9 Listing 6-10 Listing 6-11 elit. how_to_apply: Send your resume to lorem.ipsum [at] dolor.sit is_public: true is_activated: true created_at: '2005-12-01 00:00:00' token: job_expired email: job@example.com Be careful when you copy and paste code in a fixture file to not break the indentation. The expired_job must only have two spaces before it. As you can see in the job we have added in the fixture file, the created_at column value can be defined even if it is automatically filled by Doctrine. The defined value will override the default one. Reload the fixtures and refresh your browser to ensure that the old job does not show up: $ php symfony doctrine:data-load You can also execute the following query to make sure that the expires_at column is automatically filled by the save() method, based on the created_at value: SELECT `position`, `created_at`, `expires_at` FROM `jobeet_job`; Custom Configuration In the JobeetJob::save() method, we have hardcoded the number of days for the job to expire. It would have been better to make the 30 days configurable. The symfony framework provides a built-in configuration file for application specific settings, the app.yml file. This YAML file can contain any setting you want: # apps/frontend/config/app.yml all: active_days: 30 In the application, these settings are available through the global sfConfig class: sfConfig::get('app_active_days') The setting has been prefixed by app_ because the sfConfig class also provides access to symfony settings as we will see later on. Let’s update the code to take this new setting into account: public function save(Doctrine_Connection $conn = null) { if ($this->isNew() && !$this->getExpiresAt()) { $now = $this->getCreatedAt() ? $this->getDateTimeObject('created_at')->format('U') : time(); $this->setExpiresAt(date('Y-m-d H:i:s', $now + 86400 * sfConfig::get('app_active_days'))); } return parent::save($conn); } Day 6: More with the Model 74 ----------------- Brought to you by Listing 6-12 Listing 6-13 Listing 6-14 The app.yml configuration file is a great way to centralize global settings|Global Settings for your application. Last, if you need project-wide settings, just create a new app.yml file in the config folder at the root of your symfony project. Refactoring Although the code we have written works fine, it’s not quite right yet. Can you spot the problem? The Doctrine_Query code does not belong to the action (the Controller layer), it belongs to the Model layer. In the MVC model, the Model defines all the business logic, and the Controller only calls the Model to retrieve data from it. As the code returns a collection of jobs, let’s move the code to the JobeetJobTable class and create a getActiveJobs() method: // lib/model/doctrine/JobeetJobTable.class.php class JobeetJobTable extends Doctrine_Table { public function getActiveJobs() { $q = $this->createQuery('j') ->where('j.expires_at > ?', date('Y-m-d H:i:s', time())); return $q->execute(); } } Now the action code can use this new method to retrieve the active jobs. public function executeIndex(sfWebRequest $request) { $this->jobeet_jobs = Doctrine_Core::getTable('JobeetJob')->getActiveJobs(); } This refactoring has several benefits over the previous code: • The logic to get the active jobs is now in the Model, where it belongs • The code in the controller is thinner and much more readable • The getActiveJobs() method is re-usable (for instance in another action) • The model code is now unit testable Let’s sort the jobs by the expires_at column: public function getActiveJobs() { $q = $this->createQuery('j') ->where('j.expires_at > ?', date('Y-m-d H:i:s', time())) ->orderBy('j.expires_at DESC'); return $q->execute(); } The orderBy methods sets the ORDER BY clause to the generated SQL (addOrderBy() also exists). Day 6: More with the Model 75 ----------------- Brought to you by Listing 6-15 Listing 6-16 Listing 6-17 Categories on the Homepage From the second day’s requirements: “The jobs are sorted by category and then by publication date (newer jobs first).” Until now, we have not taken the job category into account. From the requirements, the homepage must display jobs by category. First, we need to get all categories with at least one active job. Open the JobeetCategoryTable class and add a getWithJobs() method: // lib/model/doctrine/JobeetCategoryTable.class.php class JobeetCategoryTable extends Doctrine_Table { public function getWithJobs() { $q = $this->createQuery('c') ->leftJoin('c.JobeetJobs j') ->where('j.expires_at > ?', date('Y-m-d H:i:s', time())); return $q->execute(); } } Change the index action accordingly: // apps/frontend/modules/job/actions/actions.class.php public function executeIndex(sfWebRequest $request) { $this->categories = Doctrine_Core::getTable('JobeetCategory')->getWithJobs(); } In the template, we need to iterate through all categories and display the active jobs: // apps/frontend/modules/job/templates/indexSuccess.php
getActiveJobs() as $i => $job): ?>
getLocation() ?> getPosition(), 'job_show_user', $job) ?> Day 6: More with the Model 76 ----------------- Brought to you by Listing 6-18 Listing 6-19 getCompany() ?>
To display the category name in the template, we have used echo $category. Does this sound weird? $category is an object, how can echo magically display the category name? The answer was given during day 3 when we have defined the magic __toString() method for all the model classes. For this to work, we need to add the getActiveJobs() method to the JobeetCategory class: // lib/model/doctrine/JobeetCategory.class.php public function getActiveJobs() { $q = Doctrine_Query::create() ->from('JobeetJob j') ->where('j.category_id = ?', $this->getId()); return Doctrine_Core::getTable('JobeetJob')->getActiveJobs($q); } The JobeetCategory::getActiveJobs() method uses the Doctrine_Core::getTable('JobeetJob')->getActiveJobs() method to retrieve the active jobs for the given category. When calling the Doctrine_Core::getTable('JobeetJob')->getActiveJobs(), we want to restrict the condition even more by providing a category. Instead of passing the category object, we have decided to pass a Doctrine_Query object as this is the best way to encapsulate a generic condition. The getActiveJobs() needs to merge this Doctrine_Query object with its own query. As the Doctrine_Query is an object, this is quite simple: // lib/model/doctrine/JobeetJobTable.class.php public function getActiveJobs(Doctrine_Query $q = null) { if (is_null($q)) { $q = Doctrine_Query::create() ->from('JobeetJob j'); } $q->andWhere('j.expires_at > ?', date('Y-m-d H:i:s', time())) ->addOrderBy('j.expires_at DESC'); return $q->execute(); } Day 6: More with the Model 77 ----------------- Brought to you by Listing 6-20 Listing 6-21 Listing 6-22 Limit the Results There is still one requirement to implement for the homepage job list: “For each category, the list only shows the first 10 jobs and a link allows to list all the jobs for a given category.” That’s simple enough to add to the getActiveJobs() method: // lib/model/doctrine/JobeetCategory.class.php public function getActiveJobs($max = 10) { $q = Doctrine_Query::create() ->from('JobeetJob j') ->where('j.category_id = ?', $this->getId()) ->limit($max); return Doctrine_Core::getTable('JobeetJob')->getActiveJobs($q); } The appropriate LIMIT clause is now hard-coded into the Model, but it is better for this value to be configurable. Change the template to pass a maximum number of jobs set in app.yml: getActiveJobs(sfConfig::get('app_max_jobs_on_homepage')) as $i => $job): ?> and add a new setting in app.yml: all: active_days: 30 max_jobs_on_homepage: 10 Day 6: More with the Model 78 ----------------- Brought to you by Listing 6-23 Dynamic Fixtures Unless you lower the max_jobs_on_homepage setting to one, you won’t see any difference. We need to add a bunch of jobs to the fixture. So, you can copy and paste an existing job ten or twenty times by hand… but there’s a better way. Duplication is bad, even in fixture files. symfony to the rescue! YAML files in symfony can contain PHP code that will be evaluated just before the parsing of the file. Edit the jobs.yml fixtures file and add the following code at the end: # Starts at the beginning of the line (no whitespace before) job_: JobeetCategory: programming company: Company position: Web Developer location: Paris, France description: Lorem ipsum dolor sit amet, consectetur adipisicing elit. how_to_apply: | Send your resume to lorem.ipsum [at] company_.sit is_public: true is_activated: true token: job_ email: job@example.com Be careful, the YAML parser won’t like you if you mess up with Indentation|Code Formatting. Keep in mind the following simple tips when adding PHP code to a YAML file: • The statements must always start the line or be embedded in a value. • If a statement ends a line, you need to explicly output a new line (“\n”). You can now reload the fixtures with the doctrine:data-load task and see if only 10 jobs are displayed on the homepage for the Programming category. In the following screenshot, we have changed the maximum number of jobs to five to make the image smaller: Day 6: More with the Model 79 ----------------- Brought to you by Listing 6-24 Listing 6-25 Listing 6-26 Secure the Job Page When a job expires, even if you know the URL, it must not be possible to access it anymore. Try the URL for the expired job (replace the id with the actual id in your database - SELECT id, token FROM jobeet_job WHERE expires_at < NOW()): /frontend_dev.php/job/sensio-labs/paris-france/ID/web-developer-expired Instead of displaying the job, we need to forward the user to a 404 page. But how can we do this as the job is retrieved automatically by the route? # apps/frontend/config/routing.yml job_show_user: url: /job/:company_slug/:location_slug/:id/:position_slug class: sfDoctrineRoute options: model: JobeetJob type: object method_for_query: retrieveActiveJob param: { module: job, action: show } requirements: id: \d+ sf_method: [GET] The retrieveActiveJob() method will receive the Doctrine_Query object built by the route: // lib/model/doctrine/JobeetJobTable.class.php class JobeetJobTable extends Doctrine_Table { Day 6: More with the Model 80 ----------------- Brought to you by public function retrieveActiveJob(Doctrine_Query $q) { $q->andWhere('a.expires_at > ?', date('Y-m-d H:i:s', time())); return $q->fetchOne(); } // ... } Now, if you try to get an expired job, you will be forwarded to a 404 page. Link to the Category Page Now, let’s add a link to the category page on the homepage and create the category page. But, wait a minute. the hour is not yet over and we haven’t worked that much. So, you have plenty of free time and enough knowledge to implement this all by yourself! Let’s make an exercise of it. Check back tomorrow for our implementation. Final Thoughts Do work on an implementation on your local Jobeet project. Please, abuse the online API documentation38 and all the free documentation39 available on the symfony website to help you out. Tomorrow, we will give you the solution on how to implement this feature. 38. http://www.symfony-project.org/api/1_4/ 39. http://www.symfony-project.org/doc/1_4/ Day 6: More with the Model 81 ----------------- Brought to you by Listing 7-1 Listing 7-2 Day 7 Playing with the Category Page 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 to start today. We hope you worked on the Jobeet category page as today 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 } 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()); } Day 7: Playing with the Category Page 82 ----------------- Brought to you by Listing 7-3 Listing 7-4 Listing 7-5 The Category Link Now, edit the indexSuccess.php template of the job module to add the link to the category page:

countActiveJobs() - sfConfig::get('app_max_jobs_on_homepage')) > 0): ?>
and more...
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_Core::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(); } Day 7: Playing with the Category Page 83 ----------------- Brought to you by 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). 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): Day 7: Playing with the Category Page 84 ----------------- Brought to you by Listing 7-6 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. 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. Day 7: Playing with the Category Page 85 ----------------- Brought to you by Listing 7-7 Listing 7-8 Listing 7-9 Listing 7-10 # 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. 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 --and-load task to update the database tables, and repopulate the database with our fixtures: $ php symfony doctrine:build --all --and-load --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(); } } 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 getName())) ?>

getActiveJobs() as $i => $job): ?> Day 7: Playing with the Category Page 86 ----------------- Brought to you by Listing 7-11 Listing 7-12 Listing 7-13
getLocation() ?> getPosition(), 'job_show_user', $job) ?> getCompany() ?>
Partials Notice that we have copied and pasted the 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
$job): ?>
getLocation() ?> getPosition(), 'job_show_user', $job) ?> getCompany() ?>
You can include a partial by using the include_partial() helper: $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. 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 HTML code from both templates with the call to include_partial(): // in apps/frontend/modules/job/templates/indexSuccess.php Day 7: Playing with the Category Page 87 ----------------- Brought to you by Listing 7-14 Listing 7-15 Listing 7-16 $category->getActiveJobs(sfConfig::get('app_max_jobs_on_homepage')))) ?> // in apps/frontend/modules/category/templates/showSuccess.php $category->getActiveJobs())) ?> List Pagination From the second day’s requirements: “The list is paginated with 20 jobs per page.” To paginate a list of Doctrine objects, symfony provides a dedicated class: sfDoctrinePager40. 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(); } 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 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()); 40. http://www.symfony-project.org/api/1_4/sfDoctrinePager Day 7: Playing with the Category Page 88 ----------------- Brought to you by Listing 7-17 Listing 7-18 return Doctrine_Core::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: getName())) ?>

$pager->getResults())) ?> haveToPaginate()): ?>
jobs in this category haveToPaginate()): ?> - page getPage() ?>/getLastPage() ?>
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 page • getNbResults(): Returns the total number of results • haveToPaginate(): Returns true if there is more than one page • getLinks(): Returns a list of page links to display • getPage(): Returns the current page number • getPreviousPage(): Returns the previous page number • getNextPage(): Returns the next page number • getLastPage(): Returns the last page number As sfDoctrinePager also implements the Iterator and Countable interfaces, you can use count() function to get the number of results instead of the getNbResults() method. Day 7: Playing with the Category Page 90 ----------------- Brought to you by Final Thoughts If you worked on your own implementation in day 6 and feel that you didn’t learn much here, 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: automated tests. Day 7: Playing with the Category Page 91 ----------------- Brought to you by Day 8 The Unit Tests During the last two days, we reviewed all the features learned during the first five days of the Practical symfony book to customize Jobeet features and add new ones. In the process, we have also touched on other more advanced symfony features. Today, we will start talking about something completely different: automated tests. As the topic is quite large, it will take us two full days to cover everything. Tests in symfony There are two different kinds of automated tests in symfony: unit tests|Unit Testing and functional tests. Unit tests verify that each method and function is working properly. Each test must be as independent as possible from the others. On the other hand, functional tests verify that the resulting application behaves correctly as a whole. All tests in symfony are located under the test/ directory of the project. It contains two sub- directories, one for unit tests (test/unit/) and one for functional tests (test/ functional/). Unit tests will be covered today, whereas tomorrow will be dedicated to functional tests. Unit Tests Writing unit tests is perhaps one of the hardest web development best practices to put into action. As web developers are not really used to testing their work, a lot of questions arise: Do I have to write tests before implementing a feature? What do I need to test? Do my tests need to cover every single edge case|Edge Cases? How can I be sure that everything is well tested? But usually, the first question is much more basic: Where to start? Even if we strongly advocate testing, the symfony approach is pragmatic: it’s always better to have some tests than no test at all. Do you already have a lot of code without any test? No problem. You don’t need to have a full test suite to benefit from the advantages of having tests. Start by adding tests whenever you find a bug in your code. Over time, your code will become better, the code coverage|Code Coverage will rise, and you will become more confident about it. By starting with a pragmatic approach, you will feel more comfortable with tests over time. The next step is to write tests for new features. In no time, you will become a test addict. The problem with most testing libraries is their steep learning curve. That’s why symfony provides a very simple testing library, lime, to make writing test insanely easy. Day 8: The Unit Tests 92 ----------------- Brought to you by Listing 8-1 Even if this tutorial describes the lime built-in library extensively, you can use any testing library, like the excellent PHPUnit41 library. The lime Testing Framework All unit tests written with the lime framework start with the same code: require_once dirname(__FILE__).'/../bootstrap/unit.php'; $t = new lime_test(1); First, the unit.php bootstrap file is included to initialize a few things. Then, a new lime_test object is created and the number of tests planned to be launched is passed as an argument. The plan allows lime to output an error message in case too few tests are run (for instance when a test generates a PHP fatal error). Testing works by calling a method or a function with a set of predefined inputs and then comparing the results with the expected output. This comparison determines whether a test passes or fails. To ease the comparison, the lime_test object provides several methods: Method Description ok($test) Tests a condition and passes if it is true is($value1, $value2) Compares two values and passes if they are equal (==) isnt($value1, $value2) Compares two values and passes if they are not equal like($string, $regexp) Tests a string against a regular expression unlike($string, $regexp) Checks that a string doesn’t match a regular expression is_deeply($array1, $array2) Checks that two arrays have the same values You may wonder why lime defines so many test methods, as all tests can be written just by using the ok() method. The benefit of alternative methods lies in much more explicit error messages in case of a failed test and in improved readability of the tests. The lime_test object also provides other convenient testing methods: Method Description fail() Always fails—useful for testing exceptions pass() Always passes—useful for testing exceptions skip($msg, $nb_tests) Counts as $nb_tests tests—useful for conditional 41. http://www.phpunit.de/ Day 8: The Unit Tests 93 ----------------- Brought to you by Listing 8-2 Listing 8-3 Listing 8-4 Method Description tests todo() Counts as a test—useful for tests yet to be written Finally, the comment($msg) method outputs a comment but runs no test. Running Unit Tests All unit tests are stored under the test/unit/ directory. By convention, tests are named after the class they test and suffixed by Test. Although you can organize the files under the test/unit/ directory anyway you like, we recommend you replicate the directory structure of the lib/ directory. To illustrate unit testing, we will test the Jobeet class. Create a test/unit/JobeetTest.php file and copy the following code inside: // test/unit/JobeetTest.php require_once dirname(__FILE__).'/../bootstrap/unit.php'; $t = new lime_test(1); $t->pass('This test always passes.'); To launch the tests, you can execute the file directly: $ php test/unit/JobeetTest.php Or use the test:unit task: $ php symfony test:unit Jobeet Windows command line unfortunately cannot highlight test results in red or green color. But if you use Cygwin, you can force symfony to use colors by passing the --color option to the task. Testing slugify Let’s start our trip to the wonderful world of unit testing by writing tests for the Jobeet::slugify() method. We created the ~slug|Slug~ify() method during day 5 to clean up a string so that it can be safely included in a URL. The conversion consists in some basic transformations like converting all non-ASCII characters to a dash (-) or converting the string to lowercase: Day 8: The Unit Tests 94 ----------------- Brought to you by Listing 8-5 Listing 8-6 Input Output Sensio Labs sensio-labs Paris, France paris-france Replace the content of the test file with the following code: // test/unit/JobeetTest.php require_once dirname(__FILE__).'/../bootstrap/unit.php'; $t = new lime_test(6); $t->is(Jobeet::slugify('Sensio'), 'sensio'); $t->is(Jobeet::slugify('sensio labs'), 'sensio-labs'); $t->is(Jobeet::slugify('sensio labs'), 'sensio-labs'); $t->is(Jobeet::slugify('paris,france'), 'paris-france'); $t->is(Jobeet::slugify(' sensio'), 'sensio'); $t->is(Jobeet::slugify('sensio '), 'sensio'); If you take a closer look at the tests we have written, you will notice that each line only tests one thing. That’s something you need to keep in mind when writing unit tests. Test one thing at a time. You can now execute the test file. If all tests pass, as we expect them to, you will enjoy the “green bar”. If not, the infamous “red bar” will alert you that some tests do not pass and that you need to fix them. If a test fails, the output will give you some information about why it failed; but if you have hundreds of tests in a file, it can be difficult to quickly identify the behavior that fails. All lime test methods take a string as their last argument that serves as the description for the test. It’s very convenient as it forces you to describe what you are really testing. It can also serve as a form of documentation for a method’s expected behavior. Let’s add some messages to the slugify test file: require_once dirname(__FILE__).'/../bootstrap/unit.php'; $t = new lime_test(6); $t->comment('::slugify()'); $t->is(Jobeet::slugify('Sensio'), 'sensio', '::slugify() converts all characters to lower case'); $t->is(Jobeet::slugify('sensio labs'), 'sensio-labs', '::slugify() replaces a white space by a -'); $t->is(Jobeet::slugify('sensio labs'), 'sensio-labs', '::slugify() replaces several white spaces by a single -'); $t->is(Jobeet::slugify(' sensio'), 'sensio', '::slugify() removes - at the beginning of a string'); Day 8: The Unit Tests 95 ----------------- Brought to you by Listing 8-7 Listing 8-8 Listing 8-9 $t->is(Jobeet::slugify('sensio '), 'sensio', '::slugify() removes - at the end of a string'); $t->is(Jobeet::slugify('paris,france'), 'paris-france', '::slugify() replaces non-ASCII characters by a -'); The test description string is also a valuable tool when trying to figure out what to test. You can see a pattern in the test strings: they are sentences describing how the method must behave and they always start with the method name to test. Code Coverage When you write tests, it is easy to forget a portion of the code. To help you check that all your code is well tested, symfony provides the test:coverage task. Pass this task a test file or directory and a lib file or directory as arguments and it will tell you the code coverage of your code: $ php symfony test:coverage test/unit/JobeetTest.php lib/Jobeet.class.php If you want to know which lines are not covered by your tests, pass the --detailed option: $ php symfony test:coverage --detailed test/unit/JobeetTest.php lib/ Jobeet.class.php Keep in mind that when the task indicates that your code is fully unit tested, it just means that each line has been executed, not that all the edge cases have been tested. As the test:coverage relies on XDebug to collect its information, you need to install it and enable it first. Adding Tests for new Features The slug for an empty string is an empty string. You can test it, it will work. But an empty string in a URL is not that a great idea. Let’s change the slugify() method so that it returns the “n-a” string in case of an empty string. You can write the test first, then update the method, or the other way around. It is really a matter of taste but writing the test first gives you the confidence that your code actually implements what you planned: $t->is(Jobeet::slugify(''), 'n-a', '::slugify() converts the empty string to n-a'); This development methodology, where you first write tests then implement features, is known as Test Driven Development (TDD)42. Day 8: The Unit Tests 96 ----------------- Brought to you by Listing 8-10 Listing 8-11 If you launch the tests now, you must have a red bar. If not, it means that the feature is already implemented or that your test does not test what it is supposed to test. Now, edit the Jobeet class and add the following condition at the beginning: // lib/Jobeet.class.php static public function slugify($text) { if (empty($text)) { return 'n-a'; } // ... } The test must now pass as expected, and you can enjoy the green bar, but only if you have remembered to update the test plan. If not, you will have a message that says you planned six tests and ran one extra. Having the planned test count up to date is important, as it you will keep you informed if the test script dies early on. Adding Tests because of a Bug Let’s say that time has passed and one of your users reports a weird bug: some job links point to a 404 error page. After some investigation, you find that for some reason, these jobs have an empty company, position, or location slug. How is it possible? You look through the records in the database and the columns are definitely not empty. You think about it for a while, and bingo, you find the cause. When a string only contains non- ASCII characters, the slugify() method converts it to an empty string. So happy to have found the cause, you open the Jobeet class and fix the problem right away. That’s a bad idea. First, let’s add a test: $t->is(Jobeet::slugify(' - '), 'n-a', '::slugify() converts a string that only contains non-ASCII characters to n-a'); 42. http://en.wikipedia.org/wiki/Test_Driven_Development Day 8: The Unit Tests 97 ----------------- Brought to you by Listing 8-12 After checking that the test does not pass, edit the Jobeet class and move the empty string check to the end of the method: static public function slugify($text) { // ... if (empty($text)) { return 'n-a'; } return $text; } The new test now passes, as do all the other ones. The slugify() had a bug despite our 100% coverage. You cannot think about all edge cases when writing tests, and that’s fine. But when you discover one, you need to write a test for it before fixing your code. It also means that your code will get better over time, which is always a good thing. Day 8: The Unit Tests 98 ----------------- Brought to you by Listing 8-13 Listing 8-14 Listing 8-15 Towards a better slugify Method You probably know that symfony has been created by French people, so let’s add a test with a French word that contains an “accent”: $t->is(Jobeet::slugify('Développeur Web'), 'developpeur-web', '::slugify() removes accents'); The test must fail. Instead of replacing é by e, the slugify() method has replaced it by a dash (-). That’s a tough problem, called transliteration. Hopefully, if you have “iconv” installed, it will do the job for us. Replace the code of the slugify method with the following: // code derived from http://php.vrana.cz/vytvoreni-pratelskeho-url.php static public function slugify($text) { // replace non letter or digits by - $text = preg_replace('#[^\\pL\d]+#u', '-', $text); // trim $text = trim($text, '-'); // transliterate if (function_exists('iconv')) { $text = iconv('utf-8', 'us-ascii//TRANSLIT', $text); } // lowercase $text = strtolower($text); // remove unwanted characters $text = preg_replace('#[^-\w]+#', '', $text); if (empty($text)) { return 'n-a'; } return $text; } Remember to save all your PHP files with the UTF-8 encoding, as this is the default symfony encoding, and the one used by “iconv” to do the transliteration. Also change the test file to run the test only if “iconv” is available: if (function_exists('iconv')) { $t->is(Jobeet::slugify('Développeur Web'), 'developpeur-web', '::slugify() removes accents'); } else { $t->skip('::slugify() removes accents - iconv not installed'); } Day 8: The Unit Tests 99 ----------------- Brought to you by Listing 8-16 Listing 8-17 Doctrine Unit Tests Database Configuration Unit testing a Doctrine model class is a bit more complex as it requires a database connection. You already have the one you use for your development, but it is a good habit to create a dedicated database for tests. At the beginning of this book, we introduced the environments as a way to vary an application’s settings. By default, all symfony tests are run in the test environment, so let’s configure a different database for the test environment: $ php symfony configure:database --name=doctrine --class=sfDoctrineDatabase --env=test "mysql:host=localhost;dbname=jobeet_test" root mYsEcret The env option tells the task that the database configuration is only for the test environment. When we used this task during day 3, we did not pass any env option, so the configuration was applied to all environments. If you are curious, open the config/databases.yml configuration file to see how symfony makes it easy to change the configuration depending on the environment. Now that we have configured the database, we can bootstrap it by using the doctrine:insert-sql task: $ mysqladmin -uroot -pmYsEcret create jobeet_test $ php symfony doctrine:insert-sql --env=test Day 8: The Unit Tests 100 ----------------- Brought to you by Listing 8-18 Listing 8-19 Configuration Principles in symfony During day 4, we saw that settings coming from configuration files can be defined at different levels. These settings can also be environment dependent. This is true for most configuration files we have used until now: databases.yml, app.yml, view.yml, and settings.yml. In all those files, the main key is the environment, the all key indicating its settings are for all environments: # config/databases.yml dev: doctrine: class: sfDoctrineDatabase test: doctrine: class: sfDoctrineDatabase param: dsn: 'mysql:host=localhost;dbname=jobeet_test' all: doctrine: class: sfDoctrineDatabase param: dsn: 'mysql:host=localhost;dbname=jobeet' username: root password: null Test Data Now that we have a dedicated database for our tests, we need a way to load some test data. During day 3, you learned to use the doctrine:data-load task, but for tests, we need to reload the data each time we run them to put the database in a known state. The doctrine:data-load task internally uses the Doctrine_Core::loadData() method to load the data: Doctrine_Core::loadData(sfConfig::get('sf_test_dir').'/fixtures'); The sfConfig object can be used to get the full path of a project sub-directory. Using it allows for the default directory structure to be customized. The loadData() method takes a directory or a file as its first argument. It can also take an array of directories and/or files. We have already created some initial data in the data/fixtures/ directory. For tests, we will put the fixtures into the test/fixtures/ directory. These fixtures will be used for Doctrine unit and functional tests. For now, copy the files from data/fixtures/ to the test/fixtures/ directory. Testing JobeetJob Let’s create some unit tests for the JobeetJob model class. Day 8: The Unit Tests 101 ----------------- Brought to you by Listing 8-20 Listing 8-21 Listing 8-22 Listing 8-23 Listing 8-24 Listing 8-25 Listing 8-26 As all our Doctrine unit tests will begin with the same code, create a Doctrine.php file in the bootstrap/ test directory with the following code: // test/bootstrap/Doctrine.php include(dirname(__FILE__).'/unit.php'); $configuration = ProjectConfiguration::getApplicationConfiguration( 'frontend', 'test', true); new sfDatabaseManager($configuration); Doctrine_Core::loadData(sfConfig::get('sf_test_dir').'/fixtures'); The script is pretty self-explanatory: • As for the front controllers, we initialize a configuration object for the test environment: $configuration = ProjectConfiguration::getApplicationConfiguration( 'frontend', 'test', true); • We create a database manager. It initializes the Doctrine connection by loading the databases.yml configuration file. new sfDatabaseManager($configuration); • We load our test data by using Doctrine_Core::loadData(): Doctrine_Core::loadData(sfConfig::get('sf_test_dir').'/fixtures'); Doctrine connects to the database only if it has some SQL statements to execute. Now that everything is in place, we can start testing the JobeetJob class. First, we need to create the JobeetJobTest.php file in test/unit/model: // test/unit/model/JobeetJobTest.php include(dirname(__FILE__).'/../../bootstrap/Doctrine.php'); $t = new lime_test(1); Then, let’s start by adding a test for the getCompanySlug() method: $t->comment('->getCompanySlug()'); $job = Doctrine_Core::getTable('JobeetJob')->createQuery()->fetchOne(); $t->is($job->getCompanySlug(), Jobeet::slugify($job->getCompany()), '->getCompanySlug() return the slug for the company'); Notice that we only test the getCompanySlug() method and not if the slug is correct or not, as we are already testing this elsewhere. Writing tests for the save() method is slightly more complex: $t->comment('->save()'); $job = create_job(); Day 8: The Unit Tests 102 ----------------- Brought to you by Listing 8-27 $job->save(); $expiresAt = date('Y-m-d', time() + 86400 * sfConfig::get('app_active_days')); $t->is($job->getDateTimeObject('expires_at')->format('Y-m-d'), $expiresAt, '->save() updates expires_at if not set'); $job = create_job(array('expires_at' => '2008-08-08')); $job->save(); $t->is($job->getDateTimeObject('expires_at')->format('Y-m-d'), '2008-08-08', '->save() does not update expires_at if set'); function create_job($defaults = array()) { static $category = null; if (is_null($category)) { $category = Doctrine_Core::getTable('JobeetCategory') ->createQuery() ->limit(1) ->fetchOne(); } $job = new JobeetJob(); $job->fromArray(array_merge(array( 'category_id' => $category->getId(), 'company' => 'Sensio Labs', 'position' => 'Senior Tester', 'location' => 'Paris, France', 'description' => 'Testing is fun', 'how_to_apply' => 'Send e-Mail', 'email' => 'job@example.com', 'token' => rand(1111, 9999), 'is_activated' => true, ), $defaults)); return $job; } Each time you add tests, don’t forget to update the number of expected tests (the plan) in the lime_test constructor method. For the JobeetJobTest file, you need to change it from 1 to 3. Test other Doctrine Classes You can now add tests for all other Doctrine classes. As you are now getting used to the process of writing unit tests, it should be quite easy. Unit Tests Harness The test:unit task can also be used to launch all unit tests for a project: $ php symfony test:unit The task outputs whether each test file passes or fails: Day 8: The Unit Tests 103 ----------------- Brought to you by If the test:unit task returns a “dubious status” for a file, it indicates that the script died before end. Running the test file alone will give you the exact error message. Final Thoughts Even if testing an application is quite important, I know that some of you might have been tempted to just skip this day. I’m glad you have not. Sure, embracing symfony is about learning all the great features the framework provides, but it’s also about its philosophy of development and the best practices it advocates. And testing is one of them. Sooner or later, unit tests will save the day for you. They give you a solid confidence about your code and the freedom to refactor it without fear. Unit tests are a safe guard that will alert you if you break something. The symfony framework itself has more than 9000 tests. Tomorrow, we will write some functional tests for the job and category modules. Until then, take some time to write more unit tests for the Jobeet model classes. Day 8: The Unit Tests 104 ----------------- Brought to you by Day 9 The Functional Tests Yesterday, we saw how to unit test our Jobeet classes using the lime testing library packaged with symfony. Today, we will write functional tests for the features we have already implemented in the job and category modules. Functional Tests Functional tests are a great tool to test your application from end to end: from the request made by a browser to the response sent by the server. They test all the layers of an application: the routing, the model, the actions, and the templates. They are very similar to what you probably already do manually: each time you add or modify an action, you need to go to the browser and check that everything works as expected by clicking on links and checking elements on the rendered page. In other words, you run a scenario corresponding to the use case you have just implemented. As the process is manual, it is tedious and error prone. Each time you change something in your code, you must step through all the scenarios to ensure that you did not break something. That’s insane. Functional tests in symfony provide a way to easily describe scenarios. Each scenario can then be played automatically over and over again by simulating the experience a user has in a browser. Like unit tests, they give you the confidence to code in peace. The functional test framework does not replace tools like “Selenium43”. Selenium runs directly in the browser to automate testing across many platforms and browsers and as such, it is able to test your application’s JavaScript. The sfBrowser class In symfony, functional tests are run through a special browser, implemented by the sfBrowser44 class. It acts as a browser tailored for your application and directly connected to it, without the need for a web server. It gives you access to all symfony objects before and after each request, giving you the opportunity to introspect them and do the checks you want programatically. sfBrowser provides methods that simulates navigation done in a classic browser: 43. http://selenium.seleniumhq.org/ 44. http://www.symfony-project.org/api/1_4/sfBrowser Day 9: The Functional Tests 105 ----------------- Brought to you by Listing 9-1 Method Description get() Gets a URL post() Posts to a URL call() Calls a URL (used for PUT and DELETE methods) back() Goes back one page in the history forward() Goes forward one page in the history reload() Reloads the current page click() Clicks on a link or a button select() selects a radiobutton or checkbox deselect() deselects a radiobutton or checkbox restart() Restarts the browser Here are some usage examples of the sfBrowser methods: $browser = new sfBrowser(); $browser-> get('/')-> click('Design')-> get('/category/programming?page=2')-> get('/category/programming', array('page' => 2))-> post('search', array('keywords' => 'php')) ; sfBrowser contains additional methods to configure the browser behavior: Method Description setHttpHeader() Sets an HTTP header setAuth() Sets the basic authentication credentials setCookie() Set a cookie removeCookie() Removes a cookie clearCookies() Clears all current cookies followRedirect() Follows a redirect The sfTestFunctional class We have a browser, but we need a way to introspect the symfony objects to do the actual testing. It can be done with lime and some sfBrowser methods like getResponse() and getRequest() but symfony provides a better way. The test methods are provided by another class, sfTestFunctional45 that takes a sfBrowser instance in its constructor. The sfTestFunctional class delegates the tests to tester objects. Several testers are bundled with symfony, and you can also create your own. As we saw in day 8, functional tests are stored under the test/functional/ directory. For Jobeet, tests are to be found in the test/functional/frontend/ sub-directory as each application has its own subdirectory. This directory already contains two files: 45. http://www.symfony-project.org/api/1_4/sfTestFunctional Day 9: The Functional Tests 106 ----------------- Brought to you by Listing 9-2 Listing 9-3 Listing 9-4 categoryActionsTest.php, and jobActionsTest.php as all tasks that generate a module automatically create a basic functional test file: // test/functional/frontend/categoryActionsTest.php include(dirname(__FILE__).'/../../bootstrap/functional.php'); $browser = new sfTestFunctional(new sfBrowser()); $browser-> get('/category/index')-> with('request')->begin()-> isParameter('module', 'category')-> isParameter('action', 'index')-> end()-> with('response')->begin()-> isStatusCode(200)-> checkElement('body', '!/This is a temporary page/')-> end() ; At first sight, the script above may look a bit strange to you. That’s because methods of sfBrowser and sfTestFunctional implement a fluent interface46 by always returning $this. It allows you to chain method calls for better readability. The above snippet is equivalent to: // test/functional/frontend/categoryActionsTest.php include(dirname(__FILE__).'/../../bootstrap/functional.php'); $browser = new sfTestFunctional(new sfBrowser()); $browser->get('/category/index'); $browser->with('request')->begin(); $browser->isParameter('module', 'category'); $browser->isParameter('action', 'index'); $browser->end(); $browser->with('response')->begin(); $browser->isStatusCode(200); $browser->checkElement('body', '!/This is a temporary page/'); $browser->end(); Tests are run within a tester block context. A tester block context begins with with('TESTER NAME')->begin() and ends with end(): $browser-> with('request')->begin()-> isParameter('module', 'category')-> isParameter('action', 'index')-> end() ; The code tests that the request parameter module equals category and action equals index. 46. http://en.wikipedia.org/wiki/Fluent_interface Day 9: The Functional Tests 107 ----------------- Brought to you by Listing 9-5 Listing 9-6 When you only need to call one test method on a tester, you don’t need to create a block: with('request')->isParameter('module', 'category'). The Request Tester The request tester provides tester methods to introspect and test the sfWebRequest object: Method Description isParameter() Checks a request parameter value isFormat() Checks the format of a request isMethod() Checks the method hasCookie() Checks whether the request has a cookie with the given name isCookie() Checks the value of a cookie The Response Tester There is also a response tester class that provides tester methods against the sfWebResponse object: Method Description checkElement() Checks if a response CSS selector match some criteria checkForm() Checks an sfForm form object debug() Prints the response output to ease debug matches() Tests a response against a regexp isHeader() Checks the value of a header isStatusCode() Checks the response status code isRedirected() Checks if the current response is a redirect isValid() Checks if a response is well-formed XML (you also validate the response again its document type be passing true as an argument) We will describe more testers classes in the coming days (for forms, user, cache, …). Running Functional Tests As for unit tests, launching functional tests can be done by executing the test file directly: $ php test/functional/frontend/categoryActionsTest.php Or by using the test:functional task: $ php symfony test:functional frontend categoryActions Day 9: The Functional Tests 108 ----------------- Brought to you by Listing 9-7 Listing 9-8 Listing 9-9 Test Data As for Doctrine unit tests, we need to load test data each time we launch a functional test. We can reuse the code we have written previously: include(dirname(__FILE__).'/../../bootstrap/functional.php'); $browser = new sfTestFunctional(new sfBrowser()); Doctrine_Core::loadData(sfConfig::get('sf_test_dir').'/fixtures'); Loading data in a functional test is a bit easier than in unit tests as the database has already been initialized by the bootstrapping script. As for unit tests, we won’t copy and paste this snippet of code in each test file, but we will rather create our own functional class that inherits from sfTestFunctional: // lib/test/JobeetTestFunctional.class.php class JobeetTestFunctional extends sfTestFunctional { public function loadData() { Doctrine_Core::loadData(sfConfig::get('sf_test_dir').'/fixtures'); return $this; } } Writing Functional Tests Writing functional tests is like playing a scenario in a browser. We already have written all the scenarios we need to test as part of the day 2 stories. First, let’s test the Jobeet homepage by editing the jobActionsTest.php test file. Replace the code with the following one: Expired jobs are not listed // test/functional/frontend/jobActionsTest.php include(dirname(__FILE__).'/../../bootstrap/functional.php'); $browser = new JobeetTestFunctional(new sfBrowser()); $browser->loadData(); Day 9: The Functional Tests 109 ----------------- Brought to you by Listing 9-10 Listing 9-11 $browser->info('1 - The homepage')-> get('/')-> with('request')->begin()-> isParameter('module', 'job')-> isParameter('action', 'index')-> end()-> with('response')->begin()-> info(' 1.1 - Expired jobs are not listed')-> checkElement('.jobs td.position:contains("expired")', false)-> end() ; As with lime, an informational message can be inserted by calling the info() method to make the output more readable. To verify the exclusion of expired jobs from the homepage, we check that the CSS selector .jobs td.position:contains("expired") does not match anywhere in the response HTML content (remember that in the fixture files, the only expired job we have contains “expired” in the position). When the second argument of the checkElement() method is a Boolean, the method tests the existence of nodes that match the CSS selector. The checkElement() method is able to interpret most valid CSS3 selectors. Only n jobs are listed for a category Add the following code at the end of the test file: // test/functional/frontend/jobActionsTest.php $max = sfConfig::get('app_max_jobs_on_homepage'); $browser->info('1 - The homepage')-> get('/')-> info(sprintf(' 1.2 - Only %s jobs are listed for a category', $max))-> with('response')-> checkElement('.category_programming tr', $max) ; The checkElement() method can also check that a CSS selector matches ‘n’ nodes in the document by passing an integer as its second argument. A category has a link to the category page only if too many jobs // test/functional/frontend/jobActionsTest.php $browser->info('1 - The homepage')-> get('/')-> info(' 1.3 - A category has a link to the category page only if too many jobs')-> with('response')->begin()-> checkElement('.category_design .more_jobs', false)-> checkElement('.category_programming .more_jobs')-> end() ; Day 9: The Functional Tests 110 ----------------- Brought to you by Listing 9-12 Listing 9-13 Listing 9-14 In these tests, we check that there is no “more jobs” link for the design category (.category_design .more_jobs does not exist), and that there is a “more jobs” link for the programming category (.category_programming .more_jobs does exist). Jobs are sorted by date $q = Doctrine_Query::create() ->select('j.*') ->from('JobeetJob j') ->leftJoin('j.JobeetCategory c') ->where('c.slug = ?', 'programming') ->andWhere('j.expires_at > ?', date('Y-m-d', time())) ->orderBy('j.created_at DESC'); $job = $q->fetchOne(); $browser->info('1 - The homepage')-> get('/')-> info(' 1.4 - Jobs are sorted by date')-> with('response')->begin()-> checkElement(sprintf('.category_programming tr:first a[href*="/%d/"]', $job->getId()))-> end() ; To test if jobs are actually sorted by date, we need to check that the first job listed on the homepage is the one we expect. This can be done by checking that the URL contains the expected primary key. As the primary key can change between runs, we need to get the Doctrine object from the database first. Even if the test works as is, we need to refactor the code a bit, as getting the first job of the programming category can be reused elsewhere in our tests. We won’t move the code to the Model layer as the code is test specific. Instead, we will move the code to the JobeetTestFunctional class we have created earlier. This class acts as a Domain Specific functional tester class for Jobeet: // lib/test/JobeetTestFunctional.class.php class JobeetTestFunctional extends sfTestFunctional { public function getMostRecentProgrammingJob() { $q = Doctrine_Query::create() ->select('j.*') ->from('JobeetJob j') ->leftJoin('j.JobeetCategory c') ->where('c.slug = ?', 'programming'); $q = Doctrine_Core::getTable('JobeetJob')->addActiveJobsQuery($q); return $q->fetchOne(); } // ... } You can now replace the previous test code by the following one: Day 9: The Functional Tests 111 ----------------- Brought to you by Listing 9-15 Listing 9-16 // test/functional/frontend/jobActionsTest.php $browser->info('1 - The homepage')-> get('/')-> info(' 1.4 - Jobs are sorted by date')-> with('response')->begin()-> checkElement(sprintf('.category_programming tr:first a[href*="/%d/"]', $browser->getMostRecentProgrammingJob()->getId()))-> end() ; Each job on the homepage is clickable $job = $browser->getMostRecentProgrammingJob(); $browser->info('2 - The job page')-> get('/')-> info(' 2.1 - Each job on the homepage is clickable and give detailed information')-> click('Web Developer', array(), array('position' => 1))-> with('request')->begin()-> isParameter('module', 'job')-> isParameter('action', 'show')-> isParameter('company_slug', $job->getCompanySlug())-> isParameter('location_slug', $job->getLocationSlug())-> isParameter('position_slug', $job->getPositionSlug())-> isParameter('id', $job->getId())-> end() ; To test the job link on the homepage, we simulate a click on the “Web Developer” text. As there are many of them on the page, we have explicitly to asked the browser to click on the first one (array('position' => 1)). Each request parameter is then tested to ensure that the routing has done its job correctly. Learn by the Example In this section, we have provided all the code needed to test the job and category pages. Read the code carefully as you may learn some new neat tricks: // lib/test/JobeetTestFunctional.class.php class JobeetTestFunctional extends sfTestFunctional { public function loadData() { Doctrine_Core::loadData(sfConfig::get('sf_test_dir').'/fixtures'); return $this; } public function getMostRecentProgrammingJob() { $q = Doctrine_Query::create() ->select('j.*') ->from('JobeetJob j') Day 9: The Functional Tests 112 ----------------- Brought to you by ->leftJoin('j.JobeetCategory c') ->where('c.slug = ?', 'programming'); $q = Doctrine_Core::getTable('JobeetJob')->addActiveJobsQuery($q); return $q->fetchOne(); } public function getExpiredJob() { $q = Doctrine_Query::create() ->from('JobeetJob j') ->where('j.expires_at < ?', date('Y-m-d', time())); return $q->fetchOne(); } } // test/functional/frontend/jobActionsTest.php include(dirname(__FILE__).'/../../bootstrap/functional.php'); $browser = new JobeetTestFunctional(new sfBrowser()); $browser->loadData(); $browser->info('1 - The homepage')-> get('/')-> with('request')->begin()-> isParameter('module', 'job')-> isParameter('action', 'index')-> end()-> with('response')->begin()-> info(' 1.1 - Expired jobs are not listed')-> checkElement('.jobs td.position:contains("expired")', false)-> end() ; $max = sfConfig::get('app_max_jobs_on_homepage'); $browser->info('1 - The homepage')-> info(sprintf(' 1.2 - Only %s jobs are listed for a category', $max))-> with('response')-> checkElement('.category_programming tr', $max) ; $browser->info('1 - The homepage')-> get('/')-> info(' 1.3 - A category has a link to the category page only if too many jobs')-> with('response')->begin()-> checkElement('.category_design .more_jobs', false)-> checkElement('.category_programming .more_jobs')-> end() ; $browser->info('1 - The homepage')-> info(' 1.4 - Jobs are sorted by date')-> with('response')->begin()-> checkElement(sprintf('.category_programming tr:first a[href*="/%d/"]', $browser->getMostRecentProgrammingJob()->getId()))-> Day 9: The Functional Tests 113 ----------------- Brought to you by end() ; $job = $browser->getMostRecentProgrammingJob(); $browser->info('2 - The job page')-> get('/')-> info(' 2.1 - Each job on the homepage is clickable and give detailed information')-> click('Web Developer', array(), array('position' => 1))-> with('request')->begin()-> isParameter('module', 'job')-> isParameter('action', 'show')-> isParameter('company_slug', $job->getCompanySlug())-> isParameter('location_slug', $job->getLocationSlug())-> isParameter('position_slug', $job->getPositionSlug())-> isParameter('id', $job->getId())-> end()-> info(' 2.2 - A non-existent job forwards the user to a 404')-> get('/job/foo-inc/milano-italy/0/painter')-> with('response')->isStatusCode(404)-> info(' 2.3 - An expired job page forwards the user to a 404')-> get(sprintf('/job/sensio-labs/paris-france/%d/web-developer', $browser->getExpiredJob()->getId()))-> with('response')->isStatusCode(404) ; // test/functional/frontend/categoryActionsTest.php include(dirname(__FILE__).'/../../bootstrap/functional.php'); $browser = new JobeetTestFunctional(new sfBrowser()); $browser->loadData(); $browser->info('1 - The category page')-> info(' 1.1 - Categories on homepage are clickable')-> get('/')-> click('Programming')-> with('request')->begin()-> isParameter('module', 'category')-> isParameter('action', 'show')-> isParameter('slug', 'programming')-> end()-> info(sprintf(' 1.2 - Categories with more than %s jobs also have a "more" link', sfConfig::get('app_max_jobs_on_homepage')))-> get('/')-> click('27')-> with('request')->begin()-> isParameter('module', 'category')-> isParameter('action', 'show')-> isParameter('slug', 'programming')-> end()-> info(sprintf(' 1.3 - Only %s jobs are listed', sfConfig::get('app_max_jobs_on_category')))-> Day 9: The Functional Tests 114 ----------------- Brought to you by Listing 9-17 Listing 9-18 Listing 9-19 with('response')->checkElement('.jobs tr', sfConfig::get('app_max_jobs_on_category'))-> info(' 1.4 - The job listed is paginated')-> with('response')->begin()-> checkElement('.pagination_desc', '/32 jobs/')-> checkElement('.pagination_desc', '#page 1/2#')-> end()-> click('2')-> with('request')->begin()-> isParameter('page', 2)-> end()-> with('response')->checkElement('.pagination_desc', '#page 2/2#') ; Debugging Functional Tests Sometimes a functional test fails. As symfony simulates a browser without any graphical interface, it can be hard to diagnose the problem. Thankfully, symfony provides the ~debug|Debug~() method to output the response header and content: $browser->with('response')->debug(); The debug() method can be inserted anywhere in a response tester block and will halt the script execution. Functional Tests Harness The test:functional task can also be used to launch all functional tests for an application: $ php symfony test:functional frontend The task outputs a single line for each test file: Tests Harness As you may expect, there is also a task to launch all tests for a project (unit and functional): $ php symfony test:all Day 9: The Functional Tests 115 ----------------- Brought to you by Listing 9-20 Listing 9-21 When you have a large suite of tests, it can be very time consuming to launch all tests every time you make a change, especially if some tests fail. That’s because each time you fix a test, you should run the whole test suite again to ensure that you have not break something else. But as long as the failed tests are not fixed, there is no point in re-executing all other tests. The test:all tasks have a --only-failed option that forces the task to only re-execute tests that failed during the previous run: $ php symfony test:all --only-failed The first time you run the task, all tests are run as usual. But for subsequent test runs, only tests that failed last time are executed. As you fix your code, some tests will pass, and will be removed from subsequent runs. When all tests pass again, the full test suite is run… you can then rinse and repeat. If you want to integrate your test suite in a continuous integration process, use the --xml option to force the test:all task to generate a JUnit compatible XML output. $ php symfony test:all --xml=log.xml Final Thoughts That wraps up our tour of the symfony test tools. You have no excuse anymore to not test your applications! With the lime framework and the functional test framework, symfony provides powerful tools to help you write tests with little effort. We have just scratched the surface of functional tests. From now on, each time we implement a feature, we will also write tests to learn more features of the test framework. Tomorrow, we will talk about yet another great feature of symfony: the form framework. Day 9: The Functional Tests 116 ----------------- Brought to you by Listing 10-1 Day 10 The Forms Previous day of this Jobeet tutorial got off to a flying start with the introduction of the symfony test framework. We will continue today with the form framework. The Form Framework Any website has forms; from the simple contact form to the complex ones with lots of fields. Writing forms is also one of the most complex and tedious task for a web developer: you need to write the HTML form, implement validation rules for each field, process the values to store them in a database, display error messages, repopulate fields in case of errors, and much more… Of course, instead of reinventing the wheel over and over again, symfony provides a framework to ease form management. The form framework is made of three parts: • validation: The validation sub-framework provides classes to validate inputs (integer, string, email address, …) • widgets: The widget sub-framework provides classes to output HTML fields (input, textarea, select, …) • forms: The form classes represent forms made of widgets and validators and provide methods to help manage the form. Each form field has its own validator and widget. Forms A symfony form is a class made of fields. Each field has a name, a validator, and a widget. A simple ContactForm can be defined with the following class: class ContactForm extends sfForm { public function configure() { $this->setWidgets(array( 'email' => new sfWidgetFormInputText(), 'message' => new sfWidgetFormTextarea(), )); $this->setValidators(array( 'email' => new sfValidatorEmail(), 'message' => new sfValidatorString(array('max_length' => 255)), Day 10: The Forms 117 ----------------- Brought to you by Listing 10-2 Listing 10-3 Listing 10-4 )); } } Form fields are configured in the configure() method, by using the setValidators() and setWidgets() methods. The form framework comes bundled with a lot of widgets47 and validators48. The API describes them quite extensively with all the options, errors, and default error messages. The widget and validator class names are quite explicit: the email field will be rendered as an HTML tag (sfWidgetFormInputText) and validated as an email address (sfValidatorEmail). The message field will be rendered as a