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 sfWidgetFormInputText(), '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 (sfWidgetFormInputText
) 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.
tip
You can disable form generation on certain models by passing parameters to
the symfony
Propel behavior:
[yml] classes: SomeModel: propel_behaviors: symfony: form: false filter: false
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.
Instead of unsetting the fields you don't want to display, you can also
explicitly list the fields you want by using the useFields()
method:
// lib/form/JobeetJobForm.class.php class JobeetJobForm extends BaseJobeetJobForm { public function configure() { $this->useFields(array('category_id', 'type', 'company', 'logo', 'url', 'position', 'location', 'description', 'how_to_apply', 'token', 'is_public', 'email')); } }
The useFields()
method does two things automatically for you: it adds the
hidden fields and the array of fields is used to change the fields order.
tip
Explicitly listing the form fields you want to display means that when adding new fields to a base form, they won't automagically appear in your form (think of a model form where you add a new column to the related table).
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
logo
column 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
create
andupdate
, 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('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('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 submission.
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.