The second week of Jobeet 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 sfWidgetFormInput(), 'message' => new sfWidgetFormTextarea(), )); $this->setValidators(array( 'email' => new sfValidatorEmail(), 'message' => new sfValidatorString(array('max_length' => 255)), )); } }
Form fields are configured in the configure() method, by using the
setValidators() and setWidgets() methods.
tip
The form framework comes bundled with a lot of widgets and validators. 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 <input> tag (sfWidgetFormInput) and validated
as an email address (sfValidatorEmail). The message field will be rendered as a
<textarea> tag (sfWidgetFormTextarea), and must be a string of no more than
255 characters (sfValidatorString).
By default all fields are required, as the default value 
for the required option is true. So, the validation definition for email 
is equivalent to new sfValidatorEmail(array('required' => true)).
tip
  You can merge a form in another one by using the mergeForm() method, or
  embed one by using the embedForm() method:
$this->mergeForm(new AnotherForm()); $this->embedForm('name', new AnotherForm());
Propel Forms
Most of the time, a form has to be serialized to the database. As symfony
already knows everything about your database model, it can automatically
generate forms based on this information. In fact, when you launched the
propel:build-all task during day 3, symfony automatically called the
propel:build-forms task:
$ php symfony propel:build-forms
The propel:build-forms task generates form classes in the lib/form/
directory. The organization of these generated files is similar to that of
lib/model/. Each model class has a related form class (for instance
JobeetJob has JobeetJobForm), which is empty by default as it inherits
from a base class:
// lib/form/JobeetJobForm.class.php class JobeetJobForm extends BaseJobeetJobForm { public function configure() { } }
tip
  By browsing the generated files under the lib/form/base/ sub-directory, you
  will see a lot of great usage examples of symfony built-in widgets and
  validators.
Customizing the Job Form
The job form is a perfect example to learn form customization. Let's see how to customize it, step by step.
First, change the "Post a Job" link in the layout to be able to check changes directly in your browser:
<!-- apps/frontend/templates/layout.php -->
<a href="<?php echo url_for('@job_new') ?>">Post a Job</a>
    By default, a Propel form displays fields for all the table columns. But for the job form, some of them must not be editable by the end user. Removing fields from a form is as simple as unsetting them:
// lib/form/JobeetJobForm.class.php class JobeetJobForm extends BaseJobeetJobForm { public function configure() { unset( $this['created_at'], $this['updated_at'], $this['expires_at'], $this['is_activated'] ); } }
Unsetting a field means that both the field widget and validator are removed.
The form configuration must sometimes be more precise than what can be
introspected from the database schema. For example, the email column is a
varchar in the schema, but we need this column to be validated as an email.
Let's change the default sfValidatorString to a sfValidatorEmail:
// lib/form/JobeetJobForm.class.php public function configure() { // ... $this->validatorSchema['email'] = new sfValidatorEmail(); }
Replacing the default validator is not always the best solution, as the
default validation rules introspected from the database schema are lost
(new sfValidatorString(array('max_length' => 255))). It is almost always
better to add the new validator to the existing ones by using the special
sfValidatorAnd validator:
// lib/form/JobeetJobForm.class.php public function configure() { // ... $this->validatorSchema['email'] = new sfValidatorAnd(array( $this->validatorSchema['email'], new sfValidatorEmail(), )); }
The sfValidatorAnd validator takes an array of validators that must pass for
the value to be valid. The trick here is to reference the current validator
($this->validatorSchema['email']), and to add the new one.
note
  You can also use the sfValidatorOr validator to force a value to
  pass at least one validator. And of course, you can mix and match
  sfValidatorAnd and sfValidatorOr validators to create complex
  boolean based validators.
Even if the type column is also a varchar in the schema, we want its value
to be restricted to a list of choices: full time, part time, or freelance.
First, let's define the possible values in JobeetJobPeer:
// lib/model/JobeetJobPeer.php class JobeetJobPeer extends BaseJobeetJobPeer { static public $types = array( 'full-time' => 'Full time', 'part-time' => 'Part time', 'freelance' => 'Freelance', ); // ... }
Then, use sfWidgetFormChoice for the type widget:
$this->widgetSchema['type'] = new sfWidgetFormChoice(array( 'choices' => JobeetJobPeer::$types, 'expanded' => true, ));
sfWidgetFormChoice represents a choice widget which can be rendered by a
different widget according to some configuration options (expanded and
multiple):
- Dropdown list (<select>):array('multiple' => false, 'expanded' => false)
- Dropdown box (<select multiple="multiple">):array('multiple' => true, 'expanded' => false)
- List of radio buttons: array('multiple' => false, 'expanded' => true)
- List of checkboxes: array('multiple' => true, 'expanded' => true)
note
  If you want one of the radio button to be selected by default (full-time
  for instance), you can change the default value in the database schema.
Even if you think nobody can submit a non-valid value, a hacker can easily bypass the widget choices by using tools like curl or the Firefox Web Developer Toolbar. Let's change the validator to restrict the possible choices:
$this->validatorSchema['type'] = new sfValidatorChoice(array( 'choices' => array_keys(JobeetJobPeer::$types), ));
As the logo column will store the filename of the logo associated with the
job, we need to change the widget to a file input tag:
$this->widgetSchema['logo'] = new sfWidgetFormInputFile(array( 'label' => 'Company logo', ));
For each field, symfony automatically generates a label (which will be used in
the rendered <label> tag). This can be changed with the label option.
You can also change labels in a batch with the setLabels() method of the
widget array:
$this->widgetSchema->setLabels(array( 'category_id' => 'Category', 'is_public' => 'Public?', 'how_to_apply' => 'How to apply?', ));
We also need to change the default validator:
$this->validatorSchema['logo'] = new sfValidatorFile(array( 'required' => false, 'path' => sfConfig::get('sf_upload_dir').'/jobs', 'mime_types' => 'web_images', ));
sfValidatorFile is quite interesting as it does a number of things:
- Validates that the uploaded file is an image in a web format (mime_types)
- Renames the file to something unique
- Stores the file in the given path
- Updates the logocolumn with the generated name
note
  You need to create the logo directory (web/uploads/jobs/) and check that it
  is writable by the web server.
As the validator will save the relative path in the database, change the path
used in the showSuccess template:
// apps/frontend/modules/job/templates/showSuccess.php <img src="/uploads/jobs/<?php echo $job->getLogo() ?>" alt="<?php echo $job->getCompany() ?> logo" />
tip
  If a generateLogoFilename() method exists in the model, it will be called
  by the validator and the result will override the default generated logo
  filename. The method is given the sfValidatedFile object as an argument.
Just as you can override the generated label of any field, you can also define a
help message. Let's add one for the is_public column to better 
explain its significance:
$this->widgetSchema->setHelp('is_public', 'Whether the job can also be published on affiliate websites or not.');
The final JobeetJobForm class reads as follows:
// lib/form/JobeetJobForm.class.php class JobeetJobForm extends BaseJobeetJobForm { public function configure() { unset( $this['created_at'], $this['updated_at'], $this['expires_at'], $this['is_activated'] ); $this->validatorSchema['email'] = new sfValidatorAnd(array( $this->validatorSchema['email'], new sfValidatorEmail(), )); $this->widgetSchema['type'] = new sfWidgetFormChoice(array( 'choices' => JobeetJobPeer::$types, 'expanded' => true, )); $this->validatorSchema['type'] = new sfValidatorChoice(array( 'choices' => array_keys(JobeetJobPeer::$types), )); $this->widgetSchema['logo'] = new sfWidgetFormInputFile(array( 'label' => 'Company logo', )); $this->widgetSchema->setLabels(array( 'category_id' => 'Category', 'is_public' => 'Public?', 'how_to_apply' => 'How to apply?', )); $this->validatorSchema['logo'] = new sfValidatorFile(array( 'required' => false, 'path' => sfConfig::get('sf_upload_dir').'/jobs', 'mime_types' => 'web_images', )); $this->widgetSchema->setHelp('is_public', 'Whether the job can also be published on affiliate websites or not.'); } }
The Form Template
Now that the form class has been customized, we need to display it. The
template for the form is the same whether you want to create a new job or
edit an existing one. In fact, both newSuccess.php and editSuccess.php
templates are quite similar:
<!-- apps/frontend/modules/job/templates/newSuccess.php --> <?php use_stylesheet('job.css') ?> <h1>Post a Job</h1> <?php include_partial('form', array('form' => $form)) ?>
note
  If you have not added the job stylesheet yet, it is time to do so in both
  templates (<?php use_stylesheet('job.css') ?>).
The form itself is rendered in the _form partial. 
Replace the content of the generated _form partial with the following code:
<!-- apps/frontend/modules/job/templates/_form.php --> <?php include_stylesheets_for_form($form) ?> <?php include_javascripts_for_form($form) ?> <?php echo form_tag_for($form, '@job') ?> <table id="job_form"> <tfoot> <tr> <td colspan="2"> <input type="submit" value="Preview your job" /> </td> </tr> </tfoot> <tbody> <?php echo $form ?> </tbody> </table> </form>
The include_javascripts_for_form() and include_stylesheets_for_form()
helpers include JavaScript and stylesheet dependencies needed for the form
widgets.
tip
Even if the job form does not need any JavaScript or stylesheet file, it is a good habit to keep these helper calls "just in case". It can save your day later if you decide to change a widget that needs some JavaScript or a specific stylesheet.
The form_tag_for() helper generates a <form> tag for the 
given form and route and changes the HTTP methods to 
POST or PUT depending on whether 
the object is new or not. It also takes care of the ~multipart|Forms (Multipart)~ 
attribute if the form has any file input tags.
Eventually, the <?php echo $form ?> renders the form widgets.
The Form Action
We now have a form class and a template that renders it. Now, it's time to actually make it work with some actions.
The job form is managed by five methods in the job module:
- new: Displays a blank form to create a new job
- edit: Displays a form to edit an existing job
- create: Creates a new job with the user submitted values
- update: Updates an existing job with the user submitted values
- processForm: Called by createandupdate, it processes the form (validation, form repopulation, and serialization to the database)
All forms have the following life-cycle:

As we have created a Propel route collection 5 days ago for the job module,
we can simplify the code for the form management methods:
// apps/frontend/modules/job/actions/actions.class.php public function executeNew(sfWebRequest $request) { $this->form = new JobeetJobForm(); } public function executeCreate(sfWebRequest $request) { $this->form = new JobeetJobForm(); $this->processForm($request, $this->form); $this->setTemplate('new'); } public function executeEdit(sfWebRequest $request) { $this->form = new JobeetJobForm($this->getRoute()->getObject()); } public function executeUpdate(sfWebRequest $request) { $this->form = new JobeetJobForm($this->getRoute()->getObject()); $this->processForm($request, $this->form); $this->setTemplate('edit'); } public function executeDelete(sfWebRequest $request) { $request->checkCSRFProtection(); $job = $this->getRoute()->getObject(); $job->delete(); $this->redirect('job/index'); } protected function processForm(sfWebRequest $request, sfForm $form) { $form->bind( $request->getParameter($form->getName()), $request->getFiles($form->getName()) ); if ($form->isValid()) { $job = $form->save(); $this->redirect($this->generateUrl('job_show', $job)); } }
When you browse to the /job/new page, a new form instance is created and
passed to the template (new action).
When the user submits the form (create action), the form is bound (bind()
method) with the user submitted values and the validation is triggered.
Once the form is bound, it is possible to check its validity using the
isValid() method: If the form is valid (returns true), the job is saved
to the database ($form->save()), and the user is redirected 
to the job preview page; if not, the newSuccess.php template is displayed 
again with the user submitted values and the associated error messages.
tip
  The setTemplate() method changes the template used for a given action. If
  the submitted form is not valid, the create and update methods use the
  same template as the new and edit action respectively to re-display the
  form with error messages.
The modification of an existing job is quite similar. The only difference
between the new and the edit action is that the job object to be modified
is passed as the first argument of the form constructor. This object will be
used for default widget values in the template (default values are an object
for Propel forms, but a plain array for simple forms).
You can also define default values for the creation form. One way is to
declare the values in the database schema. Another one is to pass a
pre-modified Job object to the form constructor.
Change the executeNew() method to define full-time as the default value
for the type column:
// apps/frontend/modules/job/actions/actions.class.php public function executeNew(sfWebRequest $request) { $job = new JobeetJob(); $job->setType('full-time'); $this->form = new JobeetJobForm($job); }
note
When the form is bound, the default values are replaced with the user submitted ones. The user submitted values will be used for form repopulation when the form is redisplayed in case of validation errors.
Protecting the Job Form with a Token
Everything must work fine by now. As of now, the user must enter the token for the job. But the job token must be generated automatically when a new job is created, as we don't want to rely on the user to provide a unique token.
Update the save() method of JobeetJob to add the logic that generates the
token before a new job is saved:
// lib/model/JobeetJob.php public function save(PropelPDO $con = null) { // ... if (!$this->getToken()) { $this->setToken(sha1($this->getEmail().rand(11111, 99999))); } return parent::save($con); }
You can now remove the token field from the form:
// lib/form/JobeetJobForm.class.php class JobeetJobForm extends BaseJobeetJobForm { public function configure() { unset( $this['created_at'], $this['updated_at'], $this['expires_at'], $this['is_activated'], $this['token'] ); // ... } // ... }
If you remember the user stories from day 2, a job can be edited only if the
user knows the associated token. Right now, it is pretty easy to edit or
delete any job, just by guessing the URL. That's because the edit URL is like
/job/ID/edit, where ID is the primary key of the job.
By default, a sfPropelRouteCollection route generates URLs with the primary
key, but it can be changed to any unique column by passing the column
option:
# apps/frontend/config/~routing|Routing~.yml
job:
  class:        sfPropelRouteCollection
  options:      { model: JobeetJob, column: token }
  requirements: { token: \w+ }
    Notice that we have also changed the token parameter requirement to match
any string as the symfony default requirements is \d+ for the unique key.
Now, all routes related to the jobs, except the job_show_user one, embed the
token. For instance, the route to edit a job is now of the following pattern:
http://jobeet.localhost/job/TOKEN/edit
You will also need to change the "Edit" link in the showSuccess template:
<!-- apps/frontend/modules/job/templates/showSuccess.php -->
<a href="<?php echo url_for('job_edit', $job) ?>">Edit</a>
    The Preview Page
The preview page is the same as the job page display. Thanks to the routing,
if the user comes with the right token, it will be accessible in the token
request parameter.
If the user comes in with the tokenized URL, we will add an admin bar at the
top. At the beginning of the showSuccess template, add a partial to host the
admin bar and remove the edit link at the bottom:
<!-- apps/frontend/modules/job/templates/showSuccess.php --> <?php if ($sf_request->getParameter('token') == $job->getToken()): ?> <?php include_partial('job/admin', array('job' => $job)) ?> <?php endif; ?>
Then, create the _admin partial:
<!-- apps/frontend/modules/job/templates/_admin.php --> <div id="job_actions"> <h3>Admin</h3> <ul> <?php if (!$job->getIsActivated()): ?> <li><?php echo link_to('Edit', 'job_edit', $job) ?></li> <li><?php echo link_to('Publish', 'job_edit', $job) ?></li> <?php endif; ?> <li><?php echo link_to('Delete', 'job_delete', $job, array('method' => 'delete', 'confirm' => 'Are you sure?')) ?></li> <?php if ($job->getIsActivated()): ?> <li<?php $job->expiresSoon() and print ' class="expires_soon"' ?>> <?php if ($job->isExpired()): ?> Expired <?php else: ?> Expires in <strong><?php echo $job->getDaysBeforeExpires() ?></strong> days <?php endif; ?> <?php if ($job->expiresSoon()): ?> - <a href="">Extend</a> for another <?php echo sfConfig::get('app_active_days') ?> days <?php endif; ?> </li> <?php else: ?> <li> [Bookmark this <?php echo link_to('URL', 'job_show', $job, true) ?> to manage this job in the future.] </li> <?php endif; ?> </ul> </div>
There is a lot of code, but most of the code is simple to understand.
To make the template more readable, we have added a bunch of shortcut methods
in the JobeetJob class:
// lib/model/JobeetJob.php public function getTypeName() { return $this->getType() ? JobeetJobPeer::$types[$this->getType()] : ''; } public function isExpired() { return $this->getDaysBeforeExpires() < 0; } public function expiresSoon() { return $this->getDaysBeforeExpires() < 5; } public function getDaysBeforeExpires() { return floor(($this->getExpiresAt('U') - time()) / 86400); }
The admin bar displays the different actions depending on the job status:


note
You will be able to see the "activated" bar after the next section.
Job Activation and Publication
In the previous section, there is a link to publish the job. The link needs to
be changed to point to a new publish action. Instead of creating a new
route, we can just configure the existing job route:
# apps/frontend/config/routing.yml
job:
  class:   sfPropelRouteCollection
  options:
    model:          JobeetJob
    column:         token
    object_actions: { publish: put }
  requirements:
    token: \w+
    The object_actions takes an array of additional actions for the given
object. We can now change the link of the "Publish" link:
<!-- apps/frontend/modules/job/templates/_admin.php --> <li> <?php echo link_to('Publish', 'job_publish', $job, array('method' => 'put')) ?> </li>
The last step is to create the publish action:
// apps/frontend/modules/job/actions/actions.class.php public function executePublish(sfWebRequest $request) { $request->checkCSRFProtection(); $job = $this->getRoute()->getObject(); $job->publish(); $this->getUser()->setFlash('notice', sprintf('Your job is now online for %s days.', sfConfig::get('app_active_days'))); $this->redirect($this->generateUrl('job_show_user', $job)); }
The astute reader will have noticed that the "Publish" link is submitted with the HTTP put method. To simulate the put method, the link is automatically converted to a form when you click on it.
And because we have enabled the CSRF protection, the link_to() helper embeds
a CSRF token in the link and the checkCSRFProtection() method of the request
object checks the validity of it on submision.
The executePublish() method uses a new publish() method that can be
defined as follows:
// lib/model/JobeetJob.php public function publish() { $this->setIsActivated(true); $this->save(); }
You can now test the new publish feature in your browser.
But we still have something to fix. The non-activated jobs must not be
accessible, which means that they must not show up on the Jobeet homepage, and
must not be accessible by their URL. As we have created an
addActiveJobsCriteria() method to restrict a Criteria to active jobs, we
can just edit it and add the new requirements at the end:
// lib/model/JobeetJobPeer.php static public function addActiveJobsCriteria(Criteria $criteria = null) { // ... $criteria->add(self::IS_ACTIVATED, true); return $criteria; }
That's all. You can test it now in your browser. All non-activated jobs have disappeared from the homepage; even if you know their URLs, they are not accessible anymore. They are, however, accessible if one knows the job's token URL. In that case, the job preview will show up with the admin bar.
That's one of the great advantages of the MVC pattern and the refactorization we have done along the way. Only a single change in one method was needed to add the new requirement.
note
  When we created the getWithJobs() method, we forgot to use the
  addActiveJobsCriteria() method. So, we need to edit it and add the new
  requirement:
class JobeetCategoryPeer extends BaseJobeetCategoryPeer { static public function getWithJobs() { // ... $criteria->add(JobeetJobPeer::IS_ACTIVATED, true); return self::doSelect($criteria); }
See you Tomorrow
Today's tutorial was packed with a lot of new information, but hopefully you now have a better understanding of symfony's form framework.
I know that some of you noticed that we forgot something today... We have not implemented any test for the new features. Because writing tests is an important part of developing an application, this is the first thing we will do tomorrow.
This work is licensed under the Creative Commons Attribution-Share Alike 3.0 Unported License license.