Symfony2 Jobeet Day 10: The Forms
* This article is part of the original Jobeet Tutorial, created by Fabien Potencier, for Symfony 1.4.
Forms in jobeet
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 …
In Day 3 of this tutorial we used the doctrine:generate:crud
command to generate a simple CRUD controller for our Job
entity. This also generated a Job form
that you can find in /src/Ibw/JobeetBundle/Form/JobType.php
file.
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:
<a href="{{ path('ibw_job_new') }}">Post a Job</a>
Then, change the ibw_job_show
route parameters in createAction
of the JobController
to match the new route we created in day 5 of this tutorial:
// ... public function createAction(Request $request) { $entity = new Job(); $form = $this->createForm(new JobType(), $entity); $form->bind($request); if ($form->isValid()) { $em = $this->getDoctrine()->getManager(); $em->persist($entity); $em->flush(); return $this->redirect($this->generateUrl('ibw_job_show', array( 'company' => $entity->getCompanySlug(), 'location' => $entity->getLocationSlug(), 'id' => $entity->getId(), 'position' => $entity->getPositionSlug() ))); } return $this->render('IbwJobeetBundle:Job:new.html.twig', array( 'entity' => $entity, 'form' => $form->createView(), )); } // ...
By default, the Doctrine generated form displays fields for all the table columns. But for the Job
form, some of them must not be editable by the end user. Edit the Job
form as you see below:
namespace IbwJobeetBundleForm; use SymfonyComponentFormAbstractType; use SymfonyComponentFormFormBuilderInterface; use SymfonyComponentOptionsResolverOptionsResolverInterface; class JobType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options) { $builder ->add('type') ->add('category') ->add('company') ->add('logo') ->add('url') ->add('position') ->add('location') ->add('description') ->add('how_to_apply') ->add('token') ->add('is_public') ->add('email') ; } public function setDefaultOptions(OptionsResolverInterface $resolver) { $resolver->setDefaults(array( 'data_class' => 'IbwJobeetBundleEntityJob' )); } public function getName() { return 'job'; } }
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. In Symfony2, validation is applied to the underlying object (e.g. Job). In other words, the question isn’t whether the form
is valid, but whether or not the Job
object is valid after the form has applied the submitted data to it. To do this, create a new validation.yml
file in the Resources/config
directory of our bundle:
IbwJobeetBundleEntityJob: properties: email: - NotBlank: ~ - Email: ~
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.
// ... use IbwJobeetBundleEntityJob; class JobType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options) { $builder ->add('type', 'choice', array('choices' => Job::getTypes(), 'expanded' => true)) // ... } // ... }
For this to work, add the following methods in the Job
entity:
// ... public static function getTypes() { return array('full-time' => 'Full time', 'part-time' => 'Part time', 'freelance' => 'Freelance'); } public static function getTypeValues() { return array_keys(self::getTypes()); } // ...
The getTypes()
method is used in the form to get the possible types for a Job and getTypeValues()
will be used in the validation to get the valid values for the type field.
IbwJobeetBundleEntityJob: properties: type: - NotBlank: ~ - Choice: { callback: getTypeValues } email: - NotBlank: ~ - Email: ~
For each field, symfony automatically generates a label
(which will be used in the rendered tag). This can be changed with the
label
option:
public function buildForm(FormBuilderInterface $builder, array $options) { $builder // ... ->add('logo', null, array('label' => 'Company logo')) // ... ->add('how_to_apply', null, array('label' => 'How to apply?')) // ... ->add('is_public', null, array('label' => 'Public?')) // ... }
You should also add validation constraints for the rest of the fields:
IbwJobeetBundleEntityJob: properties: category: - NotBlank: ~ type: - NotBlank: ~ - Choice: {callback: getTypeValues} company: - NotBlank: ~ position: - NotBlank: ~ location: - NotBlank: ~ description: - NotBlank: ~ how_to_apply: - NotBlank: ~ token: - NotBlank: ~ email: - NotBlank: ~ - Email: ~ url: - Url: ~
The constraint applied to url field enforces the URL format to be like this: http://www.sitename.domain
or https://www.sitename.domain
.
After modifying validation.yml
, you need to clear the cache.
Handling File Uploads in Symfony2
To handle the actual file upload in the form, we will use a virtual file
field. For this, we will add a new file property to the Job
entity:
// ... public $file; // ...
Now we need to replace the logo with the file widget and change it to a file input tag:
// ... public function buildForm(FormBuilderInterface $builder, array $options) { $builder // ... ->add('file', 'file', array('label' => 'Company logo', 'required' => false)) // ... } // ...
To make sure the uploaded file is a valid image, we will use the Image validation constraint:
IbwJobeetBundleEntityJob: properties: # ... file: - Image: ~
When the form is submitted, the file field will be an instance of UploadedFile
. It can be used to move the file to a permanent location. After this, we will set the job logo
property to the uploaded file name.
// ... public function createAction(Request $request) { // ... if ($form->isValid()) { $em = $this->getDoctrine()->getManager(); $entity->file->move(__DIR__.'/../../../../web/uploads/jobs', $entity->file->getClientOriginalName()); $entity->setLogo($entity->file->getClientOriginalName()); $em->persist($entity); $em->flush(); return $this->redirect($this->generateUrl('ibw_job_show', array( 'company' => $entity->getCompanySlug(), 'location' => $entity->getLocationSlug(), 'id' => $entity->getId(), 'position' => $entity->getPositionSlug() ))); } // ... } // ...
You need to create the logo directory (web/uploads/jobs/
) and check that it is writable by the web server.
Even if this implementation works, a better way is to handle the file upload using the Doctrine Job
entity.
First, add the following to the Job
entity:
class Job { // ... protected function getUploadDir() { return 'uploads/jobs'; } protected function getUploadRootDir() { return __DIR__.'/../../../../web/'.$this->getUploadDir(); } public function getWebPath() { return null === $this->logo ? null : $this->getUploadDir().'/'.$this->logo; } public function getAbsolutePath() { return null === $this->logo ? null : $this->getUploadRootDir().'/'.$this->logo; } }
The logo
property stores the relative path to the file and is persisted to the database. The getAbsolutePath()
is a convenience method that returns the absolute path to the file while the getWebPath()
is a convenience method that returns the web path, which can be used in a template to link to the uploaded file.
We will make the implementation so that the database operation and the moving of the file are atomic: if there is a problem persisting the entity or if the file cannot be saved, then nothing will happen. To do this, we need to move the file right as Doctrine persists the entity to the database. This can be accomplished by hooking into the Job
entity lifecycle callback
. Like we did in day 3 of the Jobeet tutorial, we will edit the Job.orm.yml
file and add the preUpload
, upload
and removeUpload
callbacks in it:
IbwJobeetBundleEntityJob: # ... lifecycleCallbacks: prePersist: [ preUpload, setCreatedAtValue, setExpiresAtValue ] preUpdate: [ preUpload, setUpdatedAtValue ] postPersist: [ upload ] postUpdate: [ upload ] postRemove: [ removeUpload ]
Now run the generate:entities
doctrine command to add these new methods to the Job
entity:
php app/console doctrine:generate:entities IbwJobeetBundle
Edit the Job
entity and change the added methods to the following:
class Job { // ... /** * @ORMPrePersist */ public function preUpload() { if (null !== $this->file) { $this->logo = uniqid().'.'.$this->file->guessExtension(); } } /** * @ORMPostPersist */ public function upload() { if (null === $this->file) { return; } // If there is an error when moving the file, an exception will // be automatically thrown by move(). This will properly prevent // the entity from being persisted to the database on error $this->file->move($this->getUploadRootDir(), $this->logo); unset($this->file); } /** * @ORMPostRemove */ public function removeUpload() { if(file_exists($file)) { if ($file = $this->getAbsolutePath()) { unlink($file); } } } }
The class now does everything we need: it generates a unique filename before persisting, moves the file after persisting, and removes the file if the entity is ever deleted. Now that the moving of the file is handled atomically by the entity, we should remove the code we added earlier in the controller to handle the upload:
// ... public function createAction(Request $request) { $entity = new Job(); $form = $this->createForm(new JobType(), $entity); $form->bind($request); if ($form->isValid()) { $em = $this->getDoctrine()->getManager(); $em->persist($entity); $em->flush(); return $this->redirect($this->generateUrl('ibw_job_show', array( 'company' => $entity->getCompanySlug(), 'location' => $entity->getLocationSlug(), 'id' => $entity->getId(), 'position' => $entity->getPositionSlug() ))); } return $this->render('IbwJobeetBundle:Job:new.html.twig', array( 'entity' => $entity, 'form' => $form->createView(), )); } // ...
The Form Template
Now that the form class has been customized, we need to display it. Open the new.html.twig
template and edit it:
{% extends 'IbwJobeetBundle::layout.html.twig' %} {% form_theme form _self %} {% block form_errors %} {% spaceless %} {% if errors|length > 0 %} <ul class="error_list"> {% for error in errors %} <li>{{ error.messageTemplate|trans(error.messageParameters, 'validators') }}</li> {% endfor %} </ul> {% endif %} {% endspaceless %} {% endblock form_errors %} {% block stylesheets %} {{ parent() }} <link rel="stylesheet" href="{{ asset('bundles/ibwjobeet/css/job.css') }}" type="text/css" media="all" /> {% endblock %} {% block content %} <h1>Job creation</h1> <form action="{{ path('ibw_job_create') }}" method="post" {{ form_enctype(form) }}> <table id="job_form"> <tfoot> <tr> <td colspan="2"> <input type="submit" value="Preview your job" /> </td> </tr> </tfoot> <tbody> <tr> <th>{{ form_label(form.category) }}</th> <td> {{ form_errors(form.category) }} {{ form_widget(form.category) }} </td> </tr> <tr> <th>{{ form_label(form.type) }}</th> <td> {{ form_errors(form.type) }} {{ form_widget(form.type) }} </td> </tr> <tr> <th>{{ form_label(form.company) }}</th> <td> {{ form_errors(form.company) }} {{ form_widget(form.company) }} </td> </tr> <tr> <th>{{ form_label(form.file) }}</th> <td> {{ form_errors(form.file) }} {{ form_widget(form.file) }} </td> </tr> <tr> <th>{{ form_label(form.url) }}</th> <td> {{ form_errors(form.url) }} {{ form_widget(form.url) }} </td> </tr> <tr> <th>{{ form_label(form.position) }}</th> <td> {{ form_errors(form.position) }} {{ form_widget(form.position) }} </td> </tr> <tr> <th>{{ form_label(form.location) }}</th> <td> {{ form_errors(form.location) }} {{ form_widget(form.location) }} </td> </tr> <tr> <th>{{ form_label(form.description) }}</th> <td> {{ form_errors(form.description) }} {{ form_widget(form.description) }} </td> </tr> <tr> <th>{{ form_label(form.how_to_apply) }}</th> <td> {{ form_errors(form.how_to_apply) }} {{ form_widget(form.how_to_apply) }} </td> </tr> <tr> <th>{{ form_label(form.token) }}</th> <td> {{ form_errors(form.token) }} {{ form_widget(form.token) }} </td> </tr> <tr> <th>{{ form_label(form.is_public) }}</th> <td> {{ form_errors(form.is_public) }} {{ form_widget(form.is_public) }} <br /> Whether the job can also be published on affiliate websites or not. </td> </tr> <tr> <th>{{ form_label(form.email) }}</th> <td> {{ form_errors(form.email) }} {{ form_widget(form.email) }} </td> </tr> </tbody> </table> {{ form_end(form) }} {% endblock %}
We could render the form by just using the following line of code, but as we need more customization, we choose to render each form field by hand.
{{ form(form) }}
By printing form(form)
, each field in the form is rendered, along with a label and error message (if there is one). As easy as this is, it’s not very flexible (yet). Usually, you’ll want to render each form field individually so you can control how the form looks.
We also used a technique named form theming to customize how the form errors will be rendered. You can read more about this in the official Symfony2 documentation.
Do the same thing with the edit.html.twig
template:
{% extends 'IbwJobeetBundle::layout.html.twig' %} {% form_theme edit_form _self %} {% block form_errors %} {% spaceless %} {% if errors|length > 0 %} <ul class="error_list"> {% for error in errors %} <li>{{ error.messageTemplate|trans(error.messageParameters, 'validators') }}</li> {% endfor %} </ul> {% endif %} {% endspaceless %} {% endblock form_errors %} {% block stylesheets %} {{ parent() }} <link rel="stylesheet" href="{{ asset('bundles/ibwjobeet/css/job.css') }}" type="text/css" media="all" /> {% endblock %} {% block content %} <h1>Job edit</h1> <form action="{{ path('ibw_job_update', { 'id': entity.id }) }}" method="post" {{ form_enctype(edit_form) }}> <table id="job_form"> <tfoot> <tr> <td colspan="2"> <input type="submit" value="Preview your job" /> </td> </tr> </tfoot> <tbody> <tr> <th>{{ form_label(edit_form.category) }}</th> <td> {{ form_errors(edit_form.category) }} {{ form_widget(edit_form.category) }} </td> </tr> <tr> <th>{{ form_label(edit_form.type) }}</th> <td> {{ form_errors(edit_form.type) }} {{ form_widget(edit_form.type) }} </td> </tr> <tr> <th>{{ form_label(edit_form.company) }}</th> <td> {{ form_errors(edit_form.company) }} {{ form_widget(edit_form.company) }} </td> </tr> <tr> <th>{{ form_label(edit_form.file) }}</th> <td> {{ form_errors(edit_form.file) }} {{ form(edit_form.file) }} </td> </tr> <tr> <th>{{ form_label(edit_form.url) }}</th> <td> {{ form_errors(edit_form.url) }} {{ form_widget(edit_form.url) }} </td> </tr> <tr> <th>{{ form_label(edit_form.position) }}</th> <td> {{ form_errors(edit_form.position) }} {{ form_widget(edit_form.position) }} </td> </tr> <tr> <th>{{ form_label(edit_form.location) }}</th> <td> {{ form_errors(edit_form.location) }} {{ form_widget(edit_form.location) }} </td> </tr> <tr> <th>{{ form_label(edit_form.description) }}</th> <td> {{ form_errors(edit_form.description) }} {{ form_widget(edit_form.description) }} </td> </tr> <tr> <th>{{ form_label(edit_form.how_to_apply) }}</th> <td> {{ form_errors(edit_form.how_to_apply) }} {{ form_widget(edit_form.how_to_apply) }} </td> </tr> <tr> <th>{{ form_label(edit_form.token) }}</th> <td> {{ form_errors(edit_form.token) }} {{ form_widget(edit_form.token) }} </td> </tr> <tr> <th>{{ form_label(edit_form.is_public) }}</th> <td> {{ form_errors(edit_form.is_public) }} {{ form_widget(edit_form.is_public) }} <br /> Whether the job can also be published on affiliate websites or not. </td> </tr> <tr> <th>{{ form_label(edit_form.email) }}</th> <td> {{ form_errors(edit_form.email) }} {{ form_widget(edit_form.email) }} </td> </tr> </tbody> </table> {{ form_end(edit_form) }} {% endblock %}
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 four methods in the JobController:
newAction
: Displays a blank form to create a new jobcreateAction
: Processes the form (validation, form repopulation) and creates a new job with the user submitted valueseditAction
: Displays a form to edit an existing jobupdateAction
: Processes the form (validation, form repopulation) and updates an existing job with the user submitted values
When you browse to the /job/new page
, a form instance for a new job object is created by calling the createForm()
method and passed to the template (newAction
).
When the user submits the form (createAction
), the form is bound (bind($request)
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 ($em->persist($entity)
), and the user is redirected to the job preview page; if not, the new.html.twig
template is displayed again with the user submitted values and the associated 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 second argument of the createForm
method. This object will be used for default widget values in the template.
You can also define default values for the creation
form. For this we will pass a pre-modified Job
object to the createForm()
method to set the type default value to full-time
:
// ... public function newAction() { $entity = new Job(); $entity->setType('full-time'); $form = $this->createForm(new JobType(), $entity); return $this->render('IbwJobeetBundle:Job:new.html.twig', array( 'entity' => $entity, 'form' => $form->createView() )); } // ...
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. Add the setTokenValue
method to the prePersist lifecycleCallbacks
for the Job
entity:
# ... lifecycleCallbacks: prePersist: [ setTokenValue, preUpload, setCreatedAtValue, setExpiresAtValue ] # ...
Regenerate the doctrine entities
to apply this modification:
php app/console doctrine:generate:entities IbwJobeetBundle
Edit the setTokenValue()
method of the Job
entity to add the logic that generates the token before a new job is saved:
// ... public function setTokenValue() { if(!$this->getToken()) { $this->token = sha1($this->getEmail().rand(11111, 99999)); } } // ...
You can now remove the token
field from the form:
// ... public function buildForm(FormBuilderInterface $builder, array $options) { $builder ->add('category') ->add('type', 'choice', array('choices' => Job::getTypes(), 'expanded' => true)) ->add('company') ->add('file', 'file', array('label' => 'Company logo', 'required' => false)) ->add('url') ->add('position') ->add('location') ->add('description') ->add('how_to_apply', null, array('label' => 'How to apply?')) ->add('is_public', null, array('label' => 'Public?')) ->add('email') ; } // ...
Remove it from the new.html.twig
and edit.html.twig
templates also:
<!-- ... --> <tr> <th>{{ form_label(form.token) }}</th> <td> {{ form_errors(form.token) }} {{ form_widget(form.token) }} </td> </tr> <!-- ... -->
<!-- ... --> <tr> <th>{{ form_label(edit_form.token) }}</th> <td> {{ form_errors(edit_form.token) }} {{ form(edit_form.token) }} </td> </tr> <!-- ... -->
And from the validation.yml
file:
# ... # ... token: - NotBlank: ~
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.
Let’s change the routes so you can edit or delete a job only if you now the secret token:
# ... ibw_job_edit: pattern: /{token}/edit defaults: { _controller: "IbwJobeetBundle:Job:edit" } ibw_job_update: pattern: /{token}/update defaults: { _controller: "IbwJobeetBundle:Job:update" } requirements: { _method: post|put } ibw_job_delete: pattern: /{token}/delete defaults: { _controller: "IbwJobeetBundle:Job:delete" } requirements: { _method: post|delete }
Now edit the JobController
to use the token instead of the id:
// ... class JobController extends Controller { // ... public function editAction($token) { $em = $this->getDoctrine()->getManager(); $entity = $em->getRepository('IbwJobeetBundle:Job')->findOneByToken($token); if (!$entity) { throw $this->createNotFoundException('Unable to find Job entity.'); } $editForm = $this->createForm(new JobType(), $entity); $deleteForm = $this->createDeleteForm($token); return $this->render('IbwJobeetBundle:Job:edit.html.twig', array( 'entity' => $entity, 'edit_form' => $editForm->createView(), 'delete_form' => $deleteForm->createView(), )); } public function updateAction(Request $request, $token) { $em = $this->getDoctrine()->getManager(); $entity = $em->getRepository('IbwJobeetBundle:Job')->findOneByToken($token); if (!$entity) { throw $this->createNotFoundException('Unable to find Job entity.'); } $editForm = $this->createForm(new JobType(), $entity); $deleteForm = $this->createDeleteForm($token); $editForm->bind($request); if ($editForm->isValid()) { $em->persist($entity); $em->flush(); return $this->redirect($this->generateUrl('ibw_job_edit', array('token' => $token))); } return $this->render('IbwJobeetBundle:Job:edit.html.twig', array( 'entity' => $entity, 'edit_form' => $editForm->createView(), 'delete_form' => $deleteForm->createView(), )); } public function deleteAction(Request $request, $token) { $form = $this->createDeleteForm($token); $form->bind($request); if ($form->isValid()) { $em = $this->getDoctrine()->getManager(); $entity = $em->getRepository('IbwJobeetBundle:Job')->findOneByToken($token); if (!$entity) { throw $this->createNotFoundException('Unable to find Job entity.'); } $em->remove($entity); $em->flush(); } return $this->redirect($this->generateUrl('ibw_job')); } /** * Creates a form to delete a Job entity by id. * * @param mixed $id The entity id * * @return SymfonyComponentFormForm The form */ private function createDeleteForm($token) { return $this->createFormBuilder(array('token' => $token)) ->add('token', 'hidden') ->getForm() ; } }
In the job show template show.html.twig
, change the ibw_job_edit
route parameter:
<a href="{{ path('ibw_job_edit', {'token': entity.token}) }}">
Do the same for ibw_job_update
route in edit.html.twig
job template:
<form action="{{ path('ibw_job_update', {'token': entity.token}) }}" method="post" {{ form_enctype(edit_form) }}>
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.local/job/TOKEN/edit
The Preview Page
The preview page is the same as the job page display. The only difference is that the job preview page will be accessed using the job token instead of the job id:
# ... ibw_job_show: pattern: /{company}/{location}/{id}/{position} defaults: { _controller: "IbwJobeetBundle:Job:show" } requirements: id: d+ ibw_job_preview: pattern: /{company}/{location}/{token}/{position} defaults: { _controller: "IbwJobeetBundle:Job:preview" } requirements: token: w+ # ...
The preview
action (here the difference from the show
action is that the job is retrieved from the database using the provided token instead of the id):
// ... public function previewAction($token) { $em = $this->getDoctrine()->getManager(); $entity = $em->getRepository('IbwJobeetBundle:Job')->findOneByToken($token); if (!$entity) { throw $this->createNotFoundException('Unable to find Job entity.'); } $deleteForm = $this->createDeleteForm($entity->getId()); return $this->render('IbwJobeetBundle:Job:show.html.twig', array( 'entity' => $entity, 'delete_form' => $deleteForm->createView(), )); } // ...
If the user comes in with the tokenized URL, we will add an admin bar at the top. At the beginning of the show.html.twig
template, include a template to host the admin bar and remove the edit
link at the bottom:
<!-- ... --> {% block content %} {% if app.request.get('token') %} {% include 'IbwJobeetBundle:Job:admin.html.twig' with {'job': entity} %} {% endif %} <!-- ... --> {% endblock %}
Then, create the admin.html.twig
template:
<div id="job_actions"> <h3>Admin</h3> <ul> {% if not job.isActivated %} <li><a href="{{ path('ibw_job_edit', { 'token': job.token }) }}">Edit</a></li> <li><a href="{{ path('ibw_job_edit', { 'token': job.token }) }}">Publish</a></li> {% endif %} <li> <form action="{{ path('ibw_job_delete', { 'token': job.token }) }}" method="post"> {{ form_widget(delete_form) }} <button type="submit" onclick="if(!confirm('Are you sure?')) { return false; }">Delete</button> </form> </li> {% if job.isActivated %} <li {% if job.expiresSoon %} class="expires_soon" {% endif %}> {% if job.isExpired %} Expired {% else %} Expires in <strong>{{ job.getDaysBeforeExpires }}</strong> days {% endif %} {% if job.expiresSoon %} - <a href="">Extend</a> for another 30 days {% endif %} </li> {% else %} <li> [Bookmark this <a href="{{ url('ibw_job_preview', { 'token': job.token, 'company': job.companyslug, 'location': job.locationslug, 'position': job.positionslug }) }}">URL</a> to manage this job in the future.] </li> {% 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 Job
entity class:
// ... public function isExpired() { return $this->getDaysBeforeExpires() < 0; } public function expiresSoon() { return $this->getDaysBeforeExpires() < 5; } public function getDaysBeforeExpires() { return ceil(($this->getExpiresAt()->format('U') - time()) / 86400); } // ...
The admin bar displays the different actions depending on the job status:
We will now redirect the create and update actions of the JobController
to the new preview page:
public function createAction(Request $request) { // ... if ($form->isValid()) { // ... return $this->redirect($this->generateUrl('ibw_job_preview', array( 'company' => $entity->getCompanySlug(), 'location' => $entity->getLocationSlug(), 'token' => $entity->getToken(), 'position' => $entity->getPositionSlug() ))); } // ... } public function updateAction(Request $request, $token) { // ... if ($editForm->isValid()) { // ... return $this->redirect($this->generateUrl('ibw_job_preview', array( 'company' => $entity->getCompanySlug(), 'location' => $entity->getLocationSlug(), 'token' => $entity->getToken(), 'position' => $entity->getPositionSlug() ))); } // ... }
As we said before, you can edit a job only if you know the job token and you’re the admin of the site. At the moment, when you access a job page, you will see the Edit
link and that’s bad. Let’s remove it from the show.html.twig
file:
<div style="padding: 20px 0"> <a href="{{ path('ibw_job_edit', { 'token': entity.token }) }}"> Edit </a> </div>
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. For this we will create new route:
# ... ibw_job_publish: pattern: /{token}/publish defaults: { _controller: "IbwJobeetBundle:Job:publish" } requirements: { _method: post }
We can now change the link of the Publish
link (we will use a form here, like when deleting a job, so we will have a POST request):
<!-- ... --> {% if not job.isActivated %} <li><a href="{{ path('ibw_job_edit', { 'token': job.token }) }}">Edit</a></li> <li> <form action="{{ path('ibw_job_publish', { 'token': job.token }) }}" method="post"> {{ form_widget(publish_form) }} <button type="submit">Publish</button> </form> </li> {% endif %} <!-- ... -->
The last step is to create the publish
action, the publish
form and to edit the preview
action to send the publish
form to the template:
// ... public function previewAction($token) { // ... $deleteForm = $this->createDeleteForm($entity->getToken()); $publishForm = $this->createPublishForm($entity->getToken()); return $this->render('IbwJobeetBundle:Job:show.html.twig', array( 'entity' => $entity, 'delete_form' => $deleteForm->createView(), 'publish_form' => $publishForm->createView(), )); } public function publishAction(Request $request, $token) { $form = $this->createPublishForm($token); $form->bind($request); if ($form->isValid()) { $em = $this->getDoctrine()->getManager(); $entity = $em->getRepository('IbwJobeetBundle:Job')->findOneByToken($token); if (!$entity) { throw $this->createNotFoundException('Unable to find Job entity.'); } $entity->publish(); $em->persist($entity); $em->flush(); $this->get('session')->getFlashBag()->add('notice', 'Your job is now online for 30 days.'); } return $this->redirect($this->generateUrl('ibw_job_preview', array( 'company' => $entity->getCompanySlug(), 'location' => $entity->getLocationSlug(), 'token' => $entity->getToken(), 'position' => $entity->getPositionSlug() ))); } private function createPublishForm($token) { return $this->createFormBuilder(array('token' => $token)) ->add('token', 'hidden') ->getForm() ; } // ...
The publishAction()
method uses a new publish()
method that can be defined as follows:
// ... public function publish() { $this->setIsActivated(true); } // ...
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. We need to edit the JobRepository
methods to add this requirement:
namespace IbwJobeetBundleRepository; use DoctrineORMEntityRepository; class JobRepository extends EntityRepository { public function getActiveJobs($category_id = null, $max = null, $offset = null) { $qb = $this->createQueryBuilder('j') ->where('j.expires_at > :date') ->setParameter('date', date('Y-m-d H:i:s', time())) ->andWhere('j.is_activated = :activated') ->setParameter('activated', 1) ->orderBy('j.expires_at', 'DESC'); if($max) { $qb->setMaxResults($max); } if($offset) { $qb->setFirstResult($offset); } if($category_id) { $qb->andWhere('j.category = :category_id') ->setParameter('category_id', $category_id); } $query = $qb->getQuery(); return $query->getResult(); } public function countActiveJobs($category_id = null) { $qb = $this->createQueryBuilder('j') ->select('count(j.id)') ->where('j.expires_at > :date') ->setParameter('date', date('Y-m-d H:i:s', time())) ->andWhere('j.is_activated = :activated') ->setParameter('activated', 1); if($category_id) { $qb->andWhere('j.category = :category_id') ->setParameter('category_id', $category_id); } $query = $qb->getQuery(); return $query->getSingleScalarResult(); } public function getActiveJob($id) { $query = $this->createQueryBuilder('j') ->where('j.id = :id') ->setParameter('id', $id) ->andWhere('j.expires_at > :date') ->setParameter('date', date('Y-m-d H:i:s', time())) ->andWhere('j.is_activated = :activated') ->setParameter('activated', 1) ->setMaxResults(1) ->getQuery(); try { $job = $query->getSingleResult(); } catch (DoctrineOrmNoResultException $e) { $job = null; } return $job; } }
The same for CategoryRepository getWithJobs()
method:
namespace IbwJobeetBundleRepository; use DoctrineORMEntityRepository; class CategoryRepository extends EntityRepository { public function getWithJobs() { $query = $this->getEntityManager() ->createQuery('SELECT c FROM IbwJobeetBundle:Category c LEFT JOIN c.jobs j WHERE j.expires_at > :date AND j.is_activated = :activated') ->setParameter('date', date('Y-m-d H:i:s', time())) ->setParameter('activated', 1); return $query->getResult(); } }
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.
This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 Unported License.
Symfony2 Jobeet Day 9: The Functional Tests
* This article is part of the original Jobeet Tutorial, created by Fabien Potencier, for Symfony 1.4.
Functional tests in jobeet
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.
Functional tests have a very specific workflow:
- Make a request;
- Test the response;
- Click on a link or submit a form;
- Test the response;
- Rinse and repeat;
Our First Functional Test
Functional tests are simple PHP files that typically live in the Tests/Controller
directory of your bundle. If you want to test the pages handled by your CategoryController
class, start by creating a new CategoryControllerTest
class that extends a special WebTestCase
class:
namespace IbwJobeetBundleTestsController; use SymfonyBundleFrameworkBundleTestWebTestCase; use SymfonyBundleFrameworkBundleConsoleApplication; use SymfonyComponentConsoleOutputNullOutput; use SymfonyComponentConsoleInputArrayInput; use DoctrineBundleDoctrineBundleCommandDropDatabaseDoctrineCommand; use DoctrineBundleDoctrineBundleCommandCreateDatabaseDoctrineCommand; use DoctrineBundleDoctrineBundleCommandProxyCreateSchemaDoctrineCommand; class CategoryControllerTest extends WebTestCase { private $em; private $application; public function setUp() { static::$kernel = static::createKernel(); static::$kernel->boot(); $this->application = new Application(static::$kernel); // drop the database $command = new DropDatabaseDoctrineCommand(); $this->application->add($command); $input = new ArrayInput(array( 'command' => 'doctrine:database:drop', '--force' => true )); $command->run($input, new NullOutput()); // we have to close the connection after dropping the database so we don't get "No database selected" error $connection = $this->application->getKernel()->getContainer()->get('doctrine')->getConnection(); if ($connection->isConnected()) { $connection->close(); } // create the database $command = new CreateDatabaseDoctrineCommand(); $this->application->add($command); $input = new ArrayInput(array( 'command' => 'doctrine:database:create', )); $command->run($input, new NullOutput()); // create schema $command = new CreateSchemaDoctrineCommand(); $this->application->add($command); $input = new ArrayInput(array( 'command' => 'doctrine:schema:create', )); $command->run($input, new NullOutput()); // get the Entity Manager $this->em = static::$kernel->getContainer() ->get('doctrine') ->getManager(); // load fixtures $client = static::createClient(); $loader = new SymfonyBridgeDoctrineDataFixturesContainerAwareLoader($client->getContainer()); $loader->loadFromDirectory(static::$kernel->locateResource('@IbwJobeetBundle/DataFixtures/ORM')); $purger = new DoctrineCommonDataFixturesPurgerORMPurger($this->em); $executor = new DoctrineCommonDataFixturesExecutorORMExecutor($this->em, $purger); $executor->execute($loader->getFixtures()); } public function testShow() { $client = static::createClient(); $crawler = $client->request('GET', '/category/index'); $this->assertEquals('IbwJobeetBundleControllerCategoryController::showAction', $client->getRequest()->attributes->get('_controller')); $this->assertTrue(200 === $client->getResponse()->getStatusCode()); } }
To learn more about crawler
, read the Symfony documentation here.
Running Functional Tests
As for unit tests, launching functional tests can be done by executing the phpunit
command:
phpunit -c app/ src/Ibw/JobeetBundle/Tests/Controller/CategoryControllerTest
This test will fail because the tested url, /category/index
, is not a valid url in Jobeet:
PHPUnit 3.7.22 by Sebastian Bergmann. Configuration read from /var/www/jobeet/app/phpunit.xml.dist F Time: 2 seconds, Memory: 25.25Mb There was 1 failure: 1) IbwJobeetBundleTestsControllerCategoryControllerTest::testShow Failed asserting that false is true.
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 JobControllerTest
class. Replace the code with the following one:
EXPIRED JOBS ARE NOT LISTED
namespace IbwJobeetBundleTestsController; use SymfonyBundleFrameworkBundleTestWebTestCase; use SymfonyBundleFrameworkBundleConsoleApplication; use SymfonyComponentConsoleOutputNullOutput; use SymfonyComponentConsoleInputArrayInput; use DoctrineBundleDoctrineBundleCommandDropDatabaseDoctrineCommand; use DoctrineBundleDoctrineBundleCommandCreateDatabaseDoctrineCommand; use DoctrineBundleDoctrineBundleCommandProxyCreateSchemaDoctrineCommand; class JobControllerTest extends WebTestCase { private $em; private $application; public function setUp() { static::$kernel = static::createKernel(); static::$kernel->boot(); $this->application = new Application(static::$kernel); // drop the database $command = new DropDatabaseDoctrineCommand(); $this->application->add($command); $input = new ArrayInput(array( 'command' => 'doctrine:database:drop', '--force' => true )); $command->run($input, new NullOutput()); // we have to close the connection after dropping the database so we don't get "No database selected" error $connection = $this->application->getKernel()->getContainer()->get('doctrine')->getConnection(); if ($connection->isConnected()) { $connection->close(); } // create the database $command = new CreateDatabaseDoctrineCommand(); $this->application->add($command); $input = new ArrayInput(array( 'command' => 'doctrine:database:create', )); $command->run($input, new NullOutput()); // create schema $command = new CreateSchemaDoctrineCommand(); $this->application->add($command); $input = new ArrayInput(array( 'command' => 'doctrine:schema:create', )); $command->run($input, new NullOutput()); // get the Entity Manager $this->em = static::$kernel->getContainer() ->get('doctrine') ->getManager(); // load fixtures $client = static::createClient(); $loader = new SymfonyBridgeDoctrineDataFixturesContainerAwareLoader($client->getContainer()); $loader->loadFromDirectory(static::$kernel->locateResource('@IbwJobeetBundle/DataFixtures/ORM')); $purger = new DoctrineCommonDataFixturesPurgerORMPurger($this->em); $executor = new DoctrineCommonDataFixturesExecutorORMExecutor($this->em, $purger); $executor->execute($loader->getFixtures()); } public function testIndex() { $client = static::createClient(); $crawler = $client->request('GET', '/'); $this->assertEquals('IbwJobeetBundleControllerJobController::indexAction', $client->getRequest()->attributes->get('_controller')); $this->assertTrue($crawler->filter('.jobs td.position:contains("Expired")')->count() == 0); } }
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 fixtures, the only expired job we have contains “Expired” in the position).
ONLY N JOBS ARE LISTED FOR A CATEGORY
Add the following code at the end of your testIndex()
function. To get the custom parameter defined in app/config/config.yml
in our functional test, we will use the kernel:
public function testIndex() { //... $kernel = static::createKernel(); $kernel->boot(); $max_jobs_on_homepage = $kernel->getContainer()->getParameter('max_jobs_on_homepage'); $this->assertTrue($crawler->filter('.category_programming tr')->count() <= $max_jobs_on_homepage ); }
For this test to work we will need to add the corresponding CSS class to each category in the Job/index.html.twig
file (so we can select each category and count the jobs listed) :
<!-- ... --> {% for category in categories %} <div class="category_{{ category.slug }}"> <div class="category"> <!-- ... -->
A CATEGORY HAS A LINK TO THE CATEGORY PAGE ONLY IF TOO MANY JOBS
public function testIndex() { //... $this->assertTrue($crawler->filter('.category_design .more_jobs')->count() == 0); $this->assertTrue($crawler->filter('.category_programming .more_jobs')->count() == 1); }
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
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.
public function testIndex() { // ... $em = $kernel->getContainer()->get('doctrine.orm.entity_manager'); $query = $em->createQuery('SELECT j from IbwJobeetBundle:Job j LEFT JOIN j.category c WHERE c.slug = :slug AND j.expires_at > :date ORDER BY j.created_at DESC'); $query->setParameter('slug', 'programming'); $query->setParameter('date', date('Y-m-d H:i:s', time())); $query->setMaxResults(1); $job = $query->getSingleResult(); $this->assertTrue($crawler->filter('.category_programming tr')->first()->filter(sprintf('a[href*="/%d/"]', $job->getId()))->count() == 1); }
Even if the test works in this very moment, 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 getMostRecentProgrammingJob
function in our test class:
// ... public function getMostRecentProgrammingJob() { $kernel = static::createKernel(); $kernel->boot(); $em = $kernel->getContainer()->get('doctrine.orm.entity_manager'); $query = $em->createQuery('SELECT j from IbwJobeetBundle:Job j LEFT JOIN j.category c WHERE c.slug = :slug AND j.expires_at > :date ORDER BY j.created_at DESC'); $query->setParameter('slug', 'programming'); $query->setParameter('date', date('Y-m-d H:i:s', time())); $query->setMaxResults(1); return $query->getSingleResult(); } // ...
You can now replace the previous test code by the following one:
// ... $this->assertTrue($crawler->filter('.category_programming tr')->first()->filter(sprintf('a[href*="/%d/"]', $this->getMostRecentProgrammingJob()->getId()))->count() == 1); //...
EACH JOB ON THE HOMEPAGE IS CLICKABLE
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 ask the browser to click on the first one.
Each request parameter is then tested to ensure that the routing has done its job correctly.
public function testIndex() { // ... $job = $this->getMostRecentProgrammingJob(); $link = $crawler->selectLink('Web Developer')->first()->link(); $crawler = $client->click($link); $this->assertEquals('IbwJobeetBundleControllerJobController::showAction', $client->getRequest()->attributes->get('_controller')); $this->assertEquals($job->getCompanySlug(), $client->getRequest()->attributes->get('company')); $this->assertEquals($job->getLocationSlug(), $client->getRequest()->attributes->get('location')); $this->assertEquals($job->getPositionSlug(), $client->getRequest()->attributes->get('position')); $this->assertEquals($job->getId(), $client->getRequest()->attributes->get('id')); } // ...
LEARN BY THE EXAMPLE
In this section, you have all the code needed to test the job and category pages. Read the code carefully as you may learn some new neat tricks:
namespace IbwJobeetBundleTestsController; use SymfonyBundleFrameworkBundleTestWebTestCase; use SymfonyBundleFrameworkBundleConsoleApplication; use SymfonyComponentConsoleOutputNullOutput; use SymfonyComponentConsoleInputArrayInput; use DoctrineBundleDoctrineBundleCommandDropDatabaseDoctrineCommand; use DoctrineBundleDoctrineBundleCommandCreateDatabaseDoctrineCommand; use DoctrineBundleDoctrineBundleCommandProxyCreateSchemaDoctrineCommand; class JobControllerTest extends WebTestCase { private $em; private $application; public function setUp() { static::$kernel = static::createKernel(); static::$kernel->boot(); $this->application = new Application(static::$kernel); // drop the database $command = new DropDatabaseDoctrineCommand(); $this->application->add($command); $input = new ArrayInput(array( 'command' => 'doctrine:database:drop', '--force' => true )); $command->run($input, new NullOutput()); // we have to close the connection after dropping the database so we don't get "No database selected" error $connection = $this->application->getKernel()->getContainer()->get('doctrine')->getConnection(); if ($connection->isConnected()) { $connection->close(); } // create the database $command = new CreateDatabaseDoctrineCommand(); $this->application->add($command); $input = new ArrayInput(array( 'command' => 'doctrine:database:create', )); $command->run($input, new NullOutput()); // create schema $command = new CreateSchemaDoctrineCommand(); $this->application->add($command); $input = new ArrayInput(array( 'command' => 'doctrine:schema:create', )); $command->run($input, new NullOutput()); // get the Entity Manager $this->em = static::$kernel->getContainer() ->get('doctrine') ->getManager(); // load fixtures $client = static::createClient(); $loader = new SymfonyBridgeDoctrineDataFixturesContainerAwareLoader($client->getContainer()); $loader->loadFromDirectory(static::$kernel->locateResource('@IbwJobeetBundle/DataFixtures/ORM')); $purger = new DoctrineCommonDataFixturesPurgerORMPurger($this->em); $executor = new DoctrineCommonDataFixturesExecutorORMExecutor($this->em, $purger); $executor->execute($loader->getFixtures()); } public function getMostRecentProgrammingJob() { $kernel = static::createKernel(); $kernel->boot(); $em = $kernel->getContainer()->get('doctrine.orm.entity_manager'); $query = $em->createQuery('SELECT j from IbwJobeetBundle:Job j LEFT JOIN j.category c WHERE c.slug = :slug AND j.expires_at > :date ORDER BY j.created_at DESC'); $query->setParameter('slug', 'programming'); $query->setParameter('date', date('Y-m-d H:i:s', time())); $query->setMaxResults(1); return $query->getSingleResult(); } public function getExpiredJob() { $kernel = static::createKernel(); $kernel->boot(); $em = $kernel->getContainer()->get('doctrine.orm.entity_manager'); $query = $em->createQuery('SELECT j from IbwJobeetBundle:Job j WHERE j.expires_at < :date'); $query->setParameter('date', date('Y-m-d H:i:s', time())); $query->setMaxResults(1); return $query->getSingleResult(); } public function testIndex() { // get the custom parameters from app config.yml $kernel = static::createKernel(); $kernel->boot(); $max_jobs_on_homepage = $kernel->getContainer()->getParameter('max_jobs_on_homepage'); $client = static::createClient(); $crawler = $client->request('GET', '/'); $this->assertEquals('IbwJobeetBundleControllerJobController::indexAction', $client->getRequest()->attributes->get('_controller')); // expired jobs are not listed $this->assertTrue($crawler->filter('.jobs td.position:contains("Expired")')->count() == 0); // only $max_jobs_on_homepage jobs are listed for a category $this->assertTrue($crawler->filter('.category_programming tr')->count()<= $max_jobs_on_homepage); $this->assertTrue($crawler->filter('.category_design .more_jobs')->count() == 0); $this->assertTrue($crawler->filter('.category_programming .more_jobs')->count() == 1); // jobs are sorted by date $this->assertTrue($crawler->filter('.category_programming tr')->first()->filter(sprintf('a[href*="/%d/"]', $this->getMostRecentProgrammingJob()->getId()))->count() == 1); // each job on the homepage is clickable and give detailed information $job = $this->getMostRecentProgrammingJob(); $link = $crawler->selectLink('Web Developer')->first()->link(); $crawler = $client->click($link); $this->assertEquals('IbwJobeetBundleControllerJobController::showAction', $client->getRequest()->attributes->get('_controller')); $this->assertEquals($job->getCompanySlug(), $client->getRequest()->attributes->get('company')); $this->assertEquals($job->getLocationSlug(), $client->getRequest()->attributes->get('location')); $this->assertEquals($job->getPositionSlug(), $client->getRequest()->attributes->get('position')); $this->assertEquals($job->getId(), $client->getRequest()->attributes->get('id')); // a non-existent job forwards the user to a 404 $crawler = $client->request('GET', '/job/foo-inc/milano-italy/0/painter'); $this->assertTrue(404 === $client->getResponse()->getStatusCode()); // an expired job page forwards the user to a 404 $crawler = $client->request('GET', sprintf('/job/sensio-labs/paris-france/%d/web-developer', $this->getExpiredJob()->getId())); $this->assertTrue(404 === $client->getResponse()->getStatusCode()); } }
namespace IbwJobeetBundleTestsController; use SymfonyBundleFrameworkBundleTestWebTestCase; use SymfonyBundleFrameworkBundleConsoleApplication; use SymfonyComponentConsoleOutputNullOutput; use SymfonyComponentConsoleInputArrayInput; use DoctrineBundleDoctrineBundleCommandDropDatabaseDoctrineCommand; use DoctrineBundleDoctrineBundleCommandCreateDatabaseDoctrineCommand; use DoctrineBundleDoctrineBundleCommandProxyCreateSchemaDoctrineCommand; class CategoryControllerTest extends WebTestCase { private $em; private $application; public function setUp() { static::$kernel = static::createKernel(); static::$kernel->boot(); $this->application = new Application(static::$kernel); // drop the database $command = new DropDatabaseDoctrineCommand(); $this->application->add($command); $input = new ArrayInput(array( 'command' => 'doctrine:database:drop', '--force' => true )); $command->run($input, new NullOutput()); // we have to close the connection after dropping the database so we don't get "No database selected" error $connection = $this->application->getKernel()->getContainer()->get('doctrine')->getConnection(); if ($connection->isConnected()) { $connection->close(); } // create the database $command = new CreateDatabaseDoctrineCommand(); $this->application->add($command); $input = new ArrayInput(array( 'command' => 'doctrine:database:create', )); $command->run($input, new NullOutput()); // create schema $command = new CreateSchemaDoctrineCommand(); $this->application->add($command); $input = new ArrayInput(array( 'command' => 'doctrine:schema:create', )); $command->run($input, new NullOutput()); // get the Entity Manager $this->em = static::$kernel->getContainer() ->get('doctrine') ->getManager(); // load fixtures $client = static::createClient(); $loader = new SymfonyBridgeDoctrineDataFixturesContainerAwareLoader($client->getContainer()); $loader->loadFromDirectory(static::$kernel->locateResource('@IbwJobeetBundle/DataFixtures/ORM')); $purger = new DoctrineCommonDataFixturesPurgerORMPurger($this->em); $executor = new DoctrineCommonDataFixturesExecutorORMExecutor($this->em, $purger); $executor->execute($loader->getFixtures()); } public function testShow() { $kernel = static::createKernel(); $kernel->boot(); // get the custom parameters from app/config.yml $max_jobs_on_category = $kernel->getContainer()->getParameter('max_jobs_on_category'); $max_jobs_on_homepage = $kernel->getContainer()->getParameter('max_jobs_on_homepage'); $client = static::createClient(); $categories = $this->em->getRepository('IbwJobeetBundle:Category')->getWithJobs(); // categories on homepage are clickable foreach($categories as $category) { $crawler = $client->request('GET', '/'); $link = $crawler->selectLink($category->getName())->link(); $crawler = $client->click($link); $this->assertEquals('IbwJobeetBundleControllerCategoryController::showAction', $client->getRequest()->attributes->get('_controller')); $this->assertEquals($category->getSlug(), $client->getRequest()->attributes->get('slug')); $jobs_no = $this->em->getRepository('IbwJobeetBundle:Job')->countActiveJobs($category->getId()); // categories with more than $max_jobs_on_homepage jobs also have a "more" link if($jobs_no > $max_jobs_on_homepage) { $crawler = $client->request('GET', '/'); $link = $crawler->filter(".category_" . $category->getSlug() . " .more_jobs a")->link(); $crawler = $client->click($link); $this->assertEquals('IbwJobeetBundleControllerCategoryController::showAction', $client->getRequest()->attributes->get('_controller')); $this->assertEquals($category->getSlug(), $client->getRequest()->attributes->get('slug')); } $pages = ceil($jobs_no/$max_jobs_on_category); // only $max_jobs_on_category jobs are listed $this->assertTrue($crawler->filter('.jobs tr')->count() <= $max_jobs_on_category); $this->assertRegExp("/" . $jobs_no . " jobs/", $crawler->filter('.pagination_desc')->text()); if($pages > 1) { $this->assertRegExp("/page 1/" . $pages . "/", $crawler->filter('.pagination_desc')->text()); for ($i = 2; $i <= $pages; $i++) { $link = $crawler->selectLink($i)->link(); $crawler = $client->click($link); $this->assertEquals('IbwJobeetBundleControllerCategoryController::showAction', $client->getRequest()->attributes->get('_controller')); $this->assertEquals($i, $client->getRequest()->attributes->get('page')); $this->assertTrue($crawler->filter('.jobs tr')->count() <= $max_jobs_on_category); if($jobs_no >1) { $this->assertRegExp("/" . $jobs_no . " jobs/", $crawler->filter('.pagination_desc')->text()); } $this->assertRegExp("/page " . $i . "/" . $pages . "/", $crawler->filter('.pagination_desc')->text()); } } } } }
That’s all for today! Tomorrow, we will learn all there is to know about forms.
This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 Unported License.
Symfony2 Jobeet Day 8: The Unit Tests
* This article is part of the original Jobeet Tutorial, created by Fabien Potencier, for Symfony 1.4.
Unit tests in jobeet - Tests in Symfony
There are two different kinds of automated tests in Symfony: unit tests 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.
Unit tests will be covered in this post, whereas the next post will be dedicated to funcional tests.
Symfony2 integrates with an independent library, the PHPUnit, to give you a rich testing framework. To run tests, you will have to install PHPUnit 3.5.11 or later.
If you don’t have PHPUnit installed, use the following to get it:
sudo apt-get install phpunit sudo pear channel-discover pear.phpunit.de sudo pear channel-discover pear.symfony-project.com sudo pear channel-discover components.ez.no sudo pear channel-discover pear.symfony.com sudo pear update-channels sudo pear upgrade-all sudo pear install pear.symfony.com/Yaml sudo pear install --alldeps phpunit/PHPUnit sudo pear install --force --alldeps phpunit/PHPUnit
Each test – whether it’s a unit test or a functional test – is a PHP class that should live in the Tests/
subdirectory of your bundles. If you follow this rule, then you can run all of your application’s tests with the following command:
phpunit -c app/
The -c
option tells PHPUnit to look in the app/
directory for a configuration file. If you’re curious about the PHPUnit options, check out the app/phpunit.xml.dist
file.
A unit test is usually a test against a specific PHP class. Let’s start by writing tests for the Jobeet:slugify()
method.
Create a new file, JobeetTest.php
, in the src/Ibw/JobeetBundle/Tests/Utils
folder. By convention, the Tests/
subdirectory should replicate the directory of your bundle. So, when we are testing a class in our bundle’s Utils/
directory, we put the test in the Tests/Utils/
directory:
namespace IbwJobeetBundleTestsUtils; use IbwJobeetBundleUtilsJobeet; class JobeetTest extends PHPUnit_Framework_TestCase { public function testSlugify() { $this->assertEquals('sensio', Jobeet::slugify('Sensio')); $this->assertEquals('sensio-labs', Jobeet::slugify('sensio labs')); $this->assertEquals('sensio-labs', Jobeet::slugify('sensio labs')); $this->assertEquals('paris-france', Jobeet::slugify('paris,france')); $this->assertEquals('sensio', Jobeet::slugify(' sensio')); $this->assertEquals('sensio', Jobeet::slugify('sensio ')); } }
To run only this test, you can use the following command:
phpunit -c app/ src/Ibw/JobeetBundle/Tests/Utils/JobeetTest
As everything should work fine, you should get the following result:
PHPUnit 3.7.22 by Sebastian Bergmann. Configuration read from /var/www/jobeet/app/phpunit.xml.dist . Time: 0 seconds, Memory: 8.00Mb OK (1 test, 6 assertions)
For a full list of assertions, you can check the PHPUnit documentation.
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:
// ... $this->assertEquals('n-a', Jobeet::slugify('')); // ...
Now, if we run the test again, we will have a failure:
PHPUnit 3.7.22 by Sebastian Bergmann. Configuration read from /var/www/jobeet/app/phpunit.xml.dist F Time: 0 seconds, Memory: 8.25Mb There was 1 failure: 1) IbwJobeetBundleTestsUtilsJobeetTest::testSlugify Failed asserting that two strings are equal. --- Expected +++ Actual @@ @@ -'n-a' +'' /var/www/jobeet/src/Ibw/JobeetBundle/Tests/Utils/JobeetTest.php:13 FAILURES! Tests: 1, Assertions: 5, Failures: 1.
Now, edit the Jobeet::slugify
method and add the following condition at the beginning:
// ... 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.
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:
$this->assertEquals('n-a', Jobeet::slugify(' - '));
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.
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”:
$this->assertEquals('developpeur-web', Jobeet::slugify('Développeur Web'));
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 Library installed, it will do the job for us. Replace the code of the slugify
method with the following:
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 Library installed, it will do the job for us. Replace the code of the slugify
method with the following:
static public function slugify($text) { // replace non letter or digits by - $text = preg_replace('#[^\pLd]+#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')) { $this->assertEquals('developpeur-web', Jobeet::slugify('Développeur Web')); }
Code Coverage
When you write tests, it is easy to forget a portion of the code. If you add a new feature or you just want to verify your code coverage statistics, all you need to do is to check the code coverage by using the --coverage-html
option:
phpunit --coverage-html=web/cov/ -c app/
http://jobeet.local/cov/index.html
page in a browser.The code coverage only works if you have XDebug enabled and all dependencies installed.
sudo apt-get install php5-xdebug
Your cov/index.html
should look like this:
Keep in mind that when this 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.
Doctrine Unit Tests
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 tutorial, 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:
Go to your app/config
directory and create a copy of parameters.yml
file, called parameters_test.yml
. Open parameters_test.yml
and change the name of your database to jobeet_test
. For this to be imported, we have to add it in the config_test.yml
file :
imports: - { resource: config_dev.yml } - { resource: parameters_test.yml } // ...
Testing the Job
Entity
First, we need to create the JobTest.php
file in the Tests/Entity
folder.
The setUp
function will manipulate your database each time you will run the test. At first, it will drop your current database, then it will re-create it and load data from fixtures in it. This will help you have the same initial data in the database you created for the test environment before running the tests.
namespace IbwJobeetBundleEntity; use SymfonyBundleFrameworkBundleTestWebTestCase; use IbwJobeetBundleUtilsJobeet as Jobeet; use SymfonyBundleFrameworkBundleConsoleApplication; use SymfonyComponentConsoleOutputNullOutput; use SymfonyComponentConsoleInputArrayInput; use DoctrineBundleDoctrineBundleCommandDropDatabaseDoctrineCommand; use DoctrineBundleDoctrineBundleCommandCreateDatabaseDoctrineCommand; use DoctrineBundleDoctrineBundleCommandProxyCreateSchemaDoctrineCommand; class JobTest extends WebTestCase { private $em; private $application; public function setUp() { static::$kernel = static::createKernel(); static::$kernel->boot(); $this->application = new Application(static::$kernel); // drop the database $command = new DropDatabaseDoctrineCommand(); $this->application->add($command); $input = new ArrayInput(array( 'command' => 'doctrine:database:drop', '--force' => true )); $command->run($input, new NullOutput()); // we have to close the connection after dropping the database so we don't get "No database selected" error $connection = $this->application->getKernel()->getContainer()->get('doctrine')->getConnection(); if ($connection->isConnected()) { $connection->close(); } // create the database $command = new CreateDatabaseDoctrineCommand(); $this->application->add($command); $input = new ArrayInput(array( 'command' => 'doctrine:database:create', )); $command->run($input, new NullOutput()); // create schema $command = new CreateSchemaDoctrineCommand(); $this->application->add($command); $input = new ArrayInput(array( 'command' => 'doctrine:schema:create', )); $command->run($input, new NullOutput()); // get the Entity Manager $this->em = static::$kernel->getContainer() ->get('doctrine') ->getManager(); // load fixtures $client = static::createClient(); $loader = new SymfonyBridgeDoctrineDataFixturesContainerAwareLoader($client->getContainer()); $loader->loadFromDirectory(static::$kernel->locateResource('@IbwJobeetBundle/DataFixtures/ORM')); $purger = new DoctrineCommonDataFixturesPurgerORMPurger($this->em); $executor = new DoctrineCommonDataFixturesExecutorORMExecutor($this->em, $purger); $executor->execute($loader->getFixtures()); } public function testGetCompanySlug() { $job = $this->em->createQuery('SELECT j FROM IbwJobeetBundle:Job j ') ->setMaxResults(1) ->getSingleResult(); $this->assertEquals($job->getCompanySlug(), Jobeet::slugify($job->getCompany())); } public function testGetPositionSlug() { $job = $this->em->createQuery('SELECT j FROM IbwJobeetBundle:Job j ') ->setMaxResults(1) ->getSingleResult(); $this->assertEquals($job->getPositionSlug(), Jobeet::slugify($job->getPosition())); } public function testGetLocationSlug() { $job = $this->em->createQuery('SELECT j FROM IbwJobeetBundle:Job j ') ->setMaxResults(1) ->getSingleResult(); $this->assertEquals($job->getLocationSlug(), Jobeet::slugify($job->getLocation())); } public function testSetExpiresAtValue() { $job = new Job(); $job->setExpiresAtValue(); $this->assertEquals(time() + 86400 * 30, $job->getExpiresAt()->format('U')); } protected function tearDown() { parent::tearDown(); $this->em->close(); } }
Testing the Repository Classes
Now, let’s write some tests for the JobRepository
class, to see if the functions we created in the previous days are returning the right values:
namespace IbwJobeetBundleTestsRepository; use SymfonyBundleFrameworkBundleTestWebTestCase; use SymfonyBundleFrameworkBundleConsoleApplication; use SymfonyComponentConsoleOutputNullOutput; use SymfonyComponentConsoleInputArrayInput; use DoctrineBundleDoctrineBundleCommandDropDatabaseDoctrineCommand; use DoctrineBundleDoctrineBundleCommandCreateDatabaseDoctrineCommand; use DoctrineBundleDoctrineBundleCommandProxyCreateSchemaDoctrineCommand; class JobRepositoryTest extends WebTestCase { private $em; private $application; public function setUp() { static::$kernel = static::createKernel(); static::$kernel->boot(); $this->application = new Application(static::$kernel); // drop the database $command = new DropDatabaseDoctrineCommand(); $this->application->add($command); $input = new ArrayInput(array( 'command' => 'doctrine:database:drop', '--force' => true )); $command->run($input, new NullOutput()); // we have to close the connection after dropping the database so we don't get "No database selected" error $connection = $this->application->getKernel()->getContainer()->get('doctrine')->getConnection(); if ($connection->isConnected()) { $connection->close(); } // create the database $command = new CreateDatabaseDoctrineCommand(); $this->application->add($command); $input = new ArrayInput(array( 'command' => 'doctrine:database:create', )); $command->run($input, new NullOutput()); // create schema $command = new CreateSchemaDoctrineCommand(); $this->application->add($command); $input = new ArrayInput(array( 'command' => 'doctrine:schema:create', )); $command->run($input, new NullOutput()); // get the Entity Manager $this->em = static::$kernel->getContainer() ->get('doctrine') ->getManager(); // load fixtures $client = static::createClient(); $loader = new SymfonyBridgeDoctrineDataFixturesContainerAwareLoader($client->getContainer()); $loader->loadFromDirectory(static::$kernel->locateResource('@IbwJobeetBundle/DataFixtures/ORM')); $purger = new DoctrineCommonDataFixturesPurgerORMPurger($this->em); $executor = new DoctrineCommonDataFixturesExecutorORMExecutor($this->em, $purger); $executor->execute($loader->getFixtures()); } public function testCountActiveJobs() { $query = $this->em->createQuery('SELECT c FROM IbwJobeetBundle:Category c'); $categories = $query->getResult(); foreach($categories as $category) { $query = $this->em->createQuery('SELECT COUNT(j.id) FROM IbwJobeetBundle:Job j WHERE j.category = :category AND j.expires_at > :date'); $query->setParameter('category', $category->getId()); $query->setParameter('date', date('Y-m-d H:i:s', time())); $jobs_db = $query->getSingleScalarResult(); $jobs_rep = $this->em->getRepository('IbwJobeetBundle:Job')->countActiveJobs($category->getId()); // This test will verify if the value returned by the countActiveJobs() function // coincides with the number of active jobs for a given category from the database $this->assertEquals($jobs_rep, $jobs_db); } } public function testGetActiveJobs() { $query = $this->em->createQuery('SELECT c from IbwJobeetBundle:Category c'); $categories = $query->getResult(); foreach ($categories as $category) { $query = $this->em->createQuery('SELECT COUNT(j.id) from IbwJobeetBundle:Job j WHERE j.expires_at > :date AND j.category = :category'); $query->setParameter('date', date('Y-m-d H:i:s', time())); $query->setParameter('category', $category->getId()); $jobs_db = $query->getSingleScalarResult(); $jobs_rep = $this->em->getRepository('IbwJobeetBundle:Job')->getActiveJobs($category->getId(), null, null); // This test tells if the number of active jobs for a given category from // the database is the same as the value returned by the function $this->assertEquals($jobs_db, count($jobs_rep)); } } public function testGetActiveJob() { $query = $this->em->createQuery('SELECT j FROM IbwJobeetBundle:Job j WHERE j.expires_at > :date'); $query->setParameter('date', date('Y-m-d H:i:s', time())); $query->setMaxResults(1); $job_db = $query->getSingleResult(); $job_rep = $this->em->getRepository('IbwJobeetBundle:Job')->getActiveJob($job_db->getId()); // If the job is active, the getActiveJob() method should return a non-null value $this->assertNotNull($job_rep); $query = $this->em->createQuery('SELECT j FROM IbwJobeetBundle:Job j WHERE j.expires_at < :date'); $query->setParameter('date', date('Y-m-d H:i:s', time())); $query->setMaxResults(1); $job_expired = $query->getSingleResult(); $job_rep = $this->em->getRepository('IbwJobeetBundle:Job')->getActiveJob($job_expired->getId()); // If the job is expired, the getActiveJob() method should return a null value $this->assertNull($job_rep); } protected function tearDown() { parent::tearDown(); $this->em->close(); } }
We will do the same thing for CategoryRepository
class:
namespace IbwJobeetBundleTestsRepository; use SymfonyBundleFrameworkBundleTestWebTestCase; use SymfonyBundleFrameworkBundleConsoleApplication; use SymfonyComponentConsoleOutputNullOutput; use SymfonyComponentConsoleInputArrayInput; use DoctrineBundleDoctrineBundleCommandDropDatabaseDoctrineCommand; use DoctrineBundleDoctrineBundleCommandCreateDatabaseDoctrineCommand; use DoctrineBundleDoctrineBundleCommandProxyCreateSchemaDoctrineCommand; class CategoryRepositoryTest extends WebTestCase { private $em; private $application; public function setUp() { static::$kernel = static::createKernel(); static::$kernel->boot(); $this->application = new Application(static::$kernel); // drop the database $command = new DropDatabaseDoctrineCommand(); $this->application->add($command); $input = new ArrayInput(array( 'command' => 'doctrine:database:drop', '--force' => true )); $command->run($input, new NullOutput()); // we have to close the connection after dropping the database so we don't get "No database selected" error $connection = $this->application->getKernel()->getContainer()->get('doctrine')->getConnection(); if ($connection->isConnected()) { $connection->close(); } // create the database $command = new CreateDatabaseDoctrineCommand(); $this->application->add($command); $input = new ArrayInput(array( 'command' => 'doctrine:database:create', )); $command->run($input, new NullOutput()); // create schema $command = new CreateSchemaDoctrineCommand(); $this->application->add($command); $input = new ArrayInput(array( 'command' => 'doctrine:schema:create', )); $command->run($input, new NullOutput()); // get the Entity Manager $this->em = static::$kernel->getContainer() ->get('doctrine') ->getManager(); // load fixtures $client = static::createClient(); $loader = new SymfonyBridgeDoctrineDataFixturesContainerAwareLoader($client->getContainer()); $loader->loadFromDirectory(static::$kernel->locateResource('@IbwJobeetBundle/DataFixtures/ORM')); $purger = new DoctrineCommonDataFixturesPurgerORMPurger($this->em); $executor = new DoctrineCommonDataFixturesExecutorORMExecutor($this->em, $purger); $executor->execute($loader->getFixtures()); } public function testGetWithJobs() { $query = $this->em->createQuery('SELECT c FROM IbwJobeetBundle:Category c LEFT JOIN c.jobs j WHERE j.expires_at > :date'); $query->setParameter('date', date('Y-m-d H:i:s', time())); $categories_db = $query->getResult(); $categories_rep = $this->em->getRepository('IbwJobeetBundle:Category')->getWithJobs(); // This test verifies if the number of categories having active jobs, returned // by the getWithJobs() function equals the number of categories having active jobs from database $this->assertEquals(count($categories_rep), count($categories_db)); } protected function tearDown() { parent::tearDown(); $this->em->close(); } }
After you finish writing the tests, run them with the following command, in order to generate the code coverage percent for the whole functions :
phpunit --coverage-html=web/cov/ -c app src/Ibw/JobeetBundle/Tests/Repository/
Now, if you go to http://jobeet.local/cov/Repository.html you will see that the code coverage for Repository Tests
is not 100% complete.
Let’s add some tests for the JobRepository
to achieve 100% code coverage. At the moment, in our database, we have two job categories having 0 active jobs and one job category having just one active job. That why, when we will test the $max
and $offset
parameters, we will run the following tests just on the categories with at least 3 active jobs. In order to do that, add this inside your foreach
statement, from your testGetActiveJobs()
function:
// ... foreach ($categories as $category) { // ... // If there are at least 3 active jobs in the selected category, we will // test the getActiveJobs() method using the limit and offset parameters too // to get 100% code coverage if($jobs_db > 2 ) { $jobs_rep = $this->em->getRepository('IbwJobeetBundle:Job')->getActiveJobs($category->getId(), 2); // This test tells if the number of returned active jobs is the one $max parameter requires $this->assertEquals(2, count($jobs_rep)); $jobs_rep = $this->em->getRepository('IbwJobeetBundle:Job')->getActiveJobs($category->getId(), 2, 1); // We set the limit to 2 results, starting from the second job and test if the result is as expected $this->assertEquals(2, count($jobs_rep)); } } // ...
Run the code coverage
command again :
phpunit --coverage-html=web/cov/ -c app src/Ibw/JobeetBundle/Tests/Repository/
This time, if you check your code coverage, you will see that it 100% complete.
That’s all for today! See you tomorrow, when we will talk about functional tests.
This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 Unported License.
Symfony2 Jobeet Day 7: Playing With the Category Page
* This article is part of the original Jobeet Tutorial, created by Fabien Potencier, for Symfony 1.4.
Category page in jobeet
Today we will make the Category page like it is described in the second day’s requirements:
“The user sees a list of all the jobs from the category sorted by date and paginated with 20 jobs per page“
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:
# ... IbwJobeetBundle_category: pattern: /category/{slug} defaults: { _controller: IbwJobeetBundle:Category:show }
To get the slug of a category we need to add the getSlug()
method to our category
class:
use IbwJobeetBundleUtilsJobeet as Jobeet; class Category { // ... public function getSlug() { return Jobeet::slugify($this->getName()); } }
The Category Link
Now, edit the index.html.twig
template of the job
controller to add the link to the category page:
<!-- some HTML code --> <h1><a href="{{ path('IbwJobeetBundle_category', { 'slug': category.slug }) }}">{{ category.name }}</a></h1> <!-- some HTML code --> </table> {% if category.morejobs %} <div class="more_jobs"> and <a href="{{ path('IbwJobeetBundle_category', { 'slug': category.slug }) }}">{{ category.morejobs }}</a> more... </div> {% endif %} </div> {% endfor %} </div> {% endblock %}
In the template above we used category.morejobs
, so let’s define it:
class Category { // ... private $more_jobs; // ... public function setMoreJobs($jobs) { $this->more_jobs = $jobs >= 0 ? $jobs : 0; } public function getMoreJobs() { return $this->more_jobs; } }
The more_jobs
property will hold the number of active jobs for the category minus the number of jobs listed on the homepage. Now, in JobController
, we need to set the more_jobs
value for each category:
public function indexAction() { $em = $this->getDoctrine()->getManager(); $categories = $em->getRepository('IbwJobeetBundle:Category')->getWithJobs(); foreach($categories as $category) { $category->setActiveJobs($em->getRepository('IbwJobeetBundle:Job')->getActiveJobs($category->getId(), $this->container->getParameter('max_jobs_on_homepage'))); $category->setMoreJobs($em->getRepository('IbwJobeetBundle:Job')->countActiveJobs($category->getId()) - $this->container->getParameter('max_jobs_on_homepage')); } return $this->render('IbwJobeetBundle:Job:index.html.twig', array( 'categories' => $categories )); }
The countActiveJobs
function has to be added to the JobRepository
:
// ... public function countActiveJobs($category_id = null) { $qb = $this->createQueryBuilder('j') ->select('count(j.id)') ->where('j.expires_at > :date') ->setParameter('date', date('Y-m-d H:i:s', time())); if($category_id) { $qb->andWhere('j.category = :category_id') ->setParameter('category_id', $category_id); } $query = $qb->getQuery(); return $query->getSingleScalarResult(); } // ...
Now you should see the result in your browser:
Category Controller Creation
It’s now time to create the Category
controller. Create a new CategoryController.php
file in your Controller
directory:
namespace IbwJobeetBundleController; use SymfonyBundleFrameworkBundleControllerController; use IbwJobeetBundleEntityCategory; /** * Category controller * */ class CategoryController extends Controller { }
We could use the doctrine:generate:crud
command like we did for the job
controller, but we won’t need 90% of the generated code, so we can just create a new controller from scratch.
Update the Database
We need to add a slug
column for the category table and lifecycle callbacks for setting this column value:
IbwJobeetBundleEntityCategory: type: entity repositoryClass: IbwJobeetBundleRepositoryCategoryRepository table: category id: id: type: integer generator: { strategy: AUTO } fields: name: type: string length: 255 unique: true slug: type: string length: 255 unique: true oneToMany: jobs: targetEntity: Job mappedBy: category manyToMany: affiliates: targetEntity: Affiliate mappedBy: categories lifecycleCallbacks: prePersist: [ setSlugValue ] preUpdate: [ setSlugValue ]
Remove from the Category
entity (src/Ibw/JobeetBundle/Entity/Category.php)
the getSlug
method we created earlier and run the doctrine command to update the Category
entity class:
php app/console doctrine:generate:entities
Now you should have the following added to Category.php
:
// ... /** * @var string */ private $slug; /** * Set slug * * @param string $slug * @return Category */ public function setSlug($slug) { $this->slug = $slug; return $this; } /** * Get slug * * @return string */ public function getSlug() { return $this->slug; }
Change the setSlugValue()
function:
// ... class Category { // ... public function setSlugValue() { $this->slug = Jobeet::slugify($this->getName()); } }
Now we have to drop the database and create it again with the new Category
column and load the fixtures
:
php app/console doctrine:database:drop --force php app/console doctrine:database:create php app/console doctrine:schema:update --force php app/console doctrine:fixtures:load
Category Page
We have now everything in place to create the showAction()
method. Add the following code to the CategoryController.php
file:
// ... public function showAction($slug) { $em = $this->getDoctrine()->getManager(); $category = $em->getRepository('IbwJobeetBundle:Category')->findOneBySlug($slug); if (!$category) { throw $this->createNotFoundException('Unable to find Category entity.'); } $category->setActiveJobs($em->getRepository('IbwJobeetBundle:Job')->getActiveJobs($category->getId())); return $this->render('IbwJobeetBundle:Category:show.html.twig', array( 'category' => $category, )); } // ...
The last step is to create the show.html.twig
template:
{% extends 'IbwJobeetBundle::layout.html.twig' %} {% block title %} Jobs in the {{ category.name }} category {% endblock %} {% block stylesheets %} {{ parent() }} <link rel="stylesheet" href="{{ asset('bundles/ibwjobeet/css/jobs.css') }}" type="text/css" media="all" /> {% endblock %} {% block content %} <div class="category"> <div class="feed"> <a href="">Feed</a> </div> <h1>{{ category.name }}</h1> </div> <table class="jobs"> {% for entity in category.activejobs %} <tr class="{{ cycle(['even', 'odd'], loop.index) }}"> <td class="location">{{ entity.location }}</td> <td class="position"> <a href="{{ path('ibw_job_show', { 'id': entity.id, 'company': entity.companyslug, 'location': entity.locationslug, 'position': entity.positionslug }) }}"> {{ entity.position }} </a> </td> <td class="company">{{ entity.company }}</td> </tr> {% endfor %} </table> {% endblock %}
Including Other Twig Templates
Notice that we have copied and pasted the tag that create a list of jobs from the job
index.html.twig
template. That’s bad. When you need to reuse some portion of a template, you need to create a new twig template with that code and include it where you need. Create the list.html.twig
file:
<table class="jobs"> {% for entity in jobs %} <tr class="{{ cycle(['even', 'odd'], loop.index) }}"> <td class="location">{{ entity.location }}</td> <td class="position"> <a href="{{ path('ibw_job_show', { 'id': entity.id, 'company': entity.companyslug, 'location': entity.locationslug, 'position': entity.positionslug }) }}"> {{ entity.position }} </a> </td> <td class="company">{{ entity.company }}</td> </tr> {% endfor %} </table>
You can include a template by using the include
function. Replace the HTML
<table>
code from both templates with the mentioned function:
{{ include ('IbwJobeetBundle:Job:list.html.twig', {'jobs': category.activejobs}) }}
{{ include ('IbwJobeetBundle:Job:list.html.twig', {'jobs': category.activejobs}) }}
List Pagination
At the moment of writing this, Symfony2 doesn’t provide any good pagination tools out of the box so to solve this problem we will use the old classic method. First, let’s add a page parameter to the IbwJobeetBundle_category
route. The page parameter will have a default value of 1, so it will not be required:
IbwJobeetBundle_category: pattern: /category/{slug}/{page} defaults: { _controller: IbwJobeetBundle:Category:show, page: 1 } # ...
Clear the cache after modifying the routing
file:
php app/console cache:clear --env=dev php app/console cache:clear --env=prod
The number of jobs on each page will be defined as a custom parameter in the app/config/config.yml
file:
# ... parameters: max_jobs_on_homepage: 10 max_jobs_on_category: 20
Change the JobRepository getActiveJobs
method to include an $offset
parameter to be used by doctrine when retrieving jobs:
// ... public function getActiveJobs($category_id = null, $max = null, $offset = null) { $qb = $this->createQueryBuilder('j') ->where('j.expires_at > :date') ->setParameter('date', date('Y-m-d H:i:s', time())) ->orderBy('j.expires_at', 'DESC'); if($max) { $qb->setMaxResults($max); } if($offset) { $qb->setFirstResult($offset); } if($category_id) { $qb->andWhere('j.category = :category_id') ->setParameter('category_id', $category_id); } $query = $qb->getQuery(); return $query->getResult(); } //
Change the CategoryController
showAction
to the following:
public function showAction($slug, $page) { $em = $this->getDoctrine()->getManager(); $category = $em->getRepository('IbwJobeetBundle:Category')->findOneBySlug($slug); if (!$category) { throw $this->createNotFoundException('Unable to find Category entity.'); } $total_jobs = $em->getRepository('IbwJobeetBundle:Job')->countActiveJobs($category->getId()); $jobs_per_page = $this->container->getParameter('max_jobs_on_category'); $last_page = ceil($total_jobs / $jobs_per_page); $previous_page = $page > 1 ? $page - 1 : 1; $next_page = $page < $last_page ? $page + 1 : $last_page; $category->setActiveJobs($em->getRepository('IbwJobeetBundle:Job')->getActiveJobs($category->getId(), $jobs_per_page, ($page - 1) * $jobs_per_page)); return $this->render('IbwJobeetBundle:Category:show.html.twig', array( 'category' => $category, 'last_page' => $last_page, 'previous_page' => $previous_page, 'current_page' => $page, 'next_page' => $next_page, 'total_jobs' => $total_jobs )); }
Finally, let’s update the template
{% extends 'IbwJobeetBundle::layout.html.twig' %} {% block title %} Jobs in the {{ category.name }} category {% endblock %} {% block stylesheets %} {{ parent() }} <link rel="stylesheet" href="{{ asset('bundles/ibwjobeet/css/jobs.css') }}" type="text/css" media="all" /> {% endblock %} {% block content %} <div class="category"> <div class="feed"> <a href="">Feed </a> </div> <h1>{{ category.name }}</h1> </div> {{ include ('IbwJobeetBundle:Job:list.html.twig', {'jobs': category.activejobs}) }} {% if last_page > 1 %} <div class="pagination"> <a href="{{ path('IbwJobeetBundle_category', { 'slug': category.slug, 'page': 1 }) }}"> <img src="{{ asset('bundles/ibwjobeet/images/first.png') }}" alt="First page" title="First page" /> </a> <a href="{{ path('IbwJobeetBundle_category', { 'slug': category.slug, 'page': previous_page }) }}"> <img src="{{ asset('bundles/ibwjobeet/images/previous.png') }}" alt="Previous page" title="Previous page" /> </a> {% for page in 1..last_page %} {% if page == current_page %} {{ page }} {% else %} <a href="{{ path('IbwJobeetBundle_category', { 'slug': category.slug, 'page': page }) }}">{{ page }}</a> {% endif %} {% endfor %} <a href="{{ path('IbwJobeetBundle_category', { 'slug': category.slug, 'page': next_page }) }}"> <img src="{{ asset('bundles/ibwjobeet/images/next.png') }}" alt="Next page" title="Next page" /> </a> <a href="{{ path('IbwJobeetBundle_category', { 'slug': category.slug, 'page': last_page }) }}"> <img src="{{ asset('bundles/ibwjobeet/images/last.png') }}" alt="Last page" title="Last page" /> </a> </div> {% endif %} <div class="pagination_desc"> <strong>{{ total_jobs }}</strong> jobs in this category {% if last_page > 1 %} - page <strong>{{ current_page }}/{{ last_page }}</strong> {% endif %} </div> {% endblock %}
The result:
This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 Unported License.
Symfony2 Jobeet Day 6: More with the Model
* This article is part of the original Jobeet Tutorial, created by Fabien Potencier, for Symfony 1.4.
The Doctrine Query Object
From the second day’s requirements: “On the homepage, the user sees the latest active jobs”. But as of now, all jobs are displayed, whether they are active or not:
// ... class JobController extends Controller { public function indexAction() { $em = $this->getDoctrine()->getManager(); $entities = $em->getRepository('IbwJobeetBundle:Job')->findAll(); return $this->render('IbwJobeetBundle:Job:index.html.twig', array( 'entities' => $entities )); // ... }
An active job is one that was posted less than 30 days ago. The $entities = $em->getRepository('IbwJobeetBundle')->findAll()
method will make a request to the database to get all the jobs. We are not specifying any condition, which means that all the records are retrieved from the database.
Let’s change it to only select active jobs:
public function indexAction() { $em = $this->getDoctrine()->getManager(); $query = $em->createQuery( 'SELECT j FROM IbwJobeetBundle:Job j WHERE j.created_at > :date' )->setParameter('date', date('Y-m-d H:i:s', time() - 86400 * 30)); $entities = $query->getResult(); return $this->render('IbwJobeetBundle:Job:index.html.twig', array( 'entities' => $entities )); }
Debugging Doctrine generated SQL
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, thanks to the Symfony Web Debug Toolbar, all the information you need is available within the comfort of your browser (http://jobeet.local/app_dev.php
):
Object Serialization
Even if the code above 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 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.
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 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 add a new action to the lifecycle callbacks
in the file that maps objects to the database, like we did earlier for the created_at
column:
# ... # ... lifecycleCallbacks: prePersist: [ setCreatedAtValue, setExpiresAtValue ] preUpdate: [ setUpdatedAtValue ]
Now, we have to rebuild the entities classes so Doctrine will add the new function:
php app/console doctrine:generate:entities IbwJobeetBundle
Open the src/Ibw/JobeetBundle/Entity/Job.php
file and edit the new added function:
// ... class Job { // ... public function setExpiresAtValue() { if(!$this->getExpiresAt()) { $now = $this->getCreatedAt() ? $this->getCreatedAt()->format('U') : time(); $this->expires_at = new DateTime(date('Y-m-d H:i:s', $now + 86400 * 30)); } } }
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 indexAction() { $em = $this->getDoctrine()->getManager(); $query = $em->createQuery( 'SELECT j FROM IbwJobeetBundle:Job j WHERE j.expires_at > :date' )->setParameter('date', date('Y-m-d H:i:s', time())); $entities = $query->getResult(); return $this->render('IbwJobeetBundle:Job:index.html.twig', array( 'entities' => $entities )); } // ...
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:
// ... public function load(ObjectManager $em) { $job_expired = new Job(); $job_expired->setCategory($em->merge($this->getReference('category-programming'))); $job_expired->setType('full-time'); $job_expired->setCompany('Sensio Labs'); $job_expired->setLogo('sensio-labs.gif'); $job_expired->setUrl('http://www.sensiolabs.com/'); $job_expired->setPosition('Web Developer Expired'); $job_expired->setLocation('Paris, France'); $job_expired->setDescription('Lorem ipsum dolor sit amet, consectetur adipisicing elit.'); $job_expired->setHowToApply('Send your resume to lorem.ipsum [at] dolor.sit'); $job_expired->setIsPublic(true); $job_expired->setIsActivated(true); $job_expired->setToken('job_expired'); $job_expired->setEmail('job@example.com'); $job_expired->setCreatedAt(new DateTime('2005-12-01')); // ... $em->persist($job_expired); // ... } // ...
Reload the fixtures and refresh your browser to ensure that the old job does not show up:
php app/console doctrine:fixtures:load
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 model. For that we will need to create a custom repository class for Job
entity and to add the query to that class.
Open /src/Ibw/JobeetBundle/Resources/config/doctrine/Job.orm.yml
and add the following to it:
IbwJobeetBundleEntityJob: type: entity repositoryClass: IbwJobeetBundleRepositoryJobRepository # ...
Doctrine can generate the repository class for you by running the generate:entities
command used earlier:
php app/console doctrine:generate:entities IbwJobeetBundle
Next, add a new method – getActiveJobs()
– to the newly generated repository class. This method will query for all of the active Job entities sorted by the expires_at
column (and filtered by category, if it receives the $category_id
parameter).
namespace IbwJobeetBundleRepository; use DoctrineORMEntityRepository; /** * JobRepository * * This class was generated by the Doctrine ORM. Add your own custom * repository methods below. */ class JobRepository extends EntityRepository { public function getActiveJobs($category_id = null) { $qb = $this->createQueryBuilder('j') ->where('j.expires_at > :date') ->setParameter('date', date('Y-m-d H:i:s', time())) ->orderBy('j.expires_at', 'DESC'); if($category_id) { $qb->andWhere('j.category = :category_id') ->setParameter('category_id', $category_id); } $query = $qb->getQuery(); return $query->getResult(); } }
Now the action code can use this new method to retrieve the active jobs.
// ... public function indexAction() { $em = $this->getDoctrine()->getManager(); $entities = $em->getRepository('IbwJobeetBundle:Job')->getActiveJobs(); return $this->render('IbwJobeetBundle:Job:index.html.twig', array( 'entities' => $entities )); } // ...
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
Categories on the Homepage
According to the second day’s requirements we need to have jobs sorted by categories. 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.
Create a repository class for the Category
entity like we did for Job:
IbwJobeetBundleEntityCategory: type: entity repositoryClass: IbwJobeetBundleRepositoryCategoryRepository #...
Generate the repository class:
php app/console doctrine:generate:entities IbwJobeetBundle
Open the CategoryRepository
class and add a getWithJobs()
method:
namespace IbwJobeetBundleRepository; use DoctrineORMEntityRepository; /** * CategoryRepository * * This class was generated by the Doctrine ORM. Add your own custom * repository methods below. */ class CategoryRepository extends EntityRepository { public function getWithJobs() { $query = $this->getEntityManager()->createQuery( 'SELECT c FROM IbwJobeetBundle:Category c LEFT JOIN c.jobs j WHERE j.expires_at > :date' )->setParameter('date', date('Y-m-d H:i:s', time())); return $query->getResult(); } }
Change the index
action accordingly:
// ... public function indexAction() { $em = $this->getDoctrine()->getManager(); $categories = $em->getRepository('IbwJobeetBundle:Category')->getWithJobs(); foreach($categories as $category) { $category->setActiveJobs($em->getRepository('IbwJobeetBundle:Job')->getActiveJobs($category->getId())); } return $this->render('IbwJobeetBundle:Job:index.html.twig', array( 'categories' => $categories )); } // ...
For this to work, we have to add a new property to our Category
class, the active_jobs
:
class Category { // ... private $active_jobs; // ... public function setActiveJobs($jobs) { $this->active_jobs = $jobs; } public function getActiveJobs() { return $this->active_jobs; } }
In the template, we need to iterate through all categories and display the active jobs:
<!-- ... --> {% block content %} <div id="jobs"> {% for category in categories %} <div> <div class="category"> <div class="feed"> <a href="">Feed</a> </div> <h1>{{ category.name }}</h1> </div> <table class="jobs"> {% for entity in category.activejobs %} <tr class="{{ cycle(['even', 'odd'], loop.index) }}"> <td class="location">{{ entity.location }}</td> <td class="position"> <a href="{{ path('ibw_job_show', { 'id': entity.id, 'company': entity.companyslug, 'location': entity.locationslug, 'position': entity.positionslug }) }}"> {{ entity.position }} </a> </td> <td class="company">{{ entity.company }}</td> </tr> {% endfor %} </table> </div> {% endfor %} </div> {% endblock %}
Limit the results
There is still one requirement to implement for the homepage job list: we have to limit the job list to 10 items. That’s simple enough to add the $max
parameter to the JobRepository::getActiveJobs()
method:
public function getActiveJobs($category_id = null, $max = null) { $qb = $this->createQueryBuilder('j') ->where('j.expires_at > :date') ->setParameter('date', date('Y-m-d H:i:s', time())) ->orderBy('j.expires_at', 'DESC'); if($max) { $qb->setMaxResults($max); } if($category_id) { $qb->andWhere('j.category = :category_id') ->setParameter('category_id', $category_id); } $query = $qb->getQuery(); return $query->getResult(); }
Change the call to getActiveJobs()
to include the $max
parameter:
// ... public function indexAction() { $em = $this->getDoctrine()->getManager(); $categories = $em->getRepository('IbwJobeetBundle:Category')->getWithJobs(); foreach($categories as $category) { $category->setActiveJobs($em->getRepository('IbwJobeetBundle:Job')->getActiveJobs($category->getId(), 10)); } return $this->render('IbwJobeetBundle:Job:index.html.twig', array( 'categories' => $categories )); } // ...
Custom Configuration
In the JobController
, indexAction
method, we have hardcoded the number of max jobs returned for a category. It would have been better to make the 10 limit configurable. In Symfony, you can define custom parameters for your application in the app/config/config.yml
file, under the parameters
key (if the parameters
key doesn’t exist, create it):
# ... parameters: max_jobs_on_homepage: 10
This can now be accessed from a controller:
// ... public function indexAction() { $em = $this->getDoctrine()->getManager(); $categories = $em->getRepository('IbwJobeetBundle:Category')->getWithJobs(); foreach($categories as $category) { $category->setActiveJobs($em->getRepository('IbwJobeetBundle:Job')->getActiveJobs($category->getId(), $this->container->getParameter('max_jobs_on_homepage'))); } return $this->render('IbwJobeetBundle:Job:index.html.twig', array( 'categories' => $categories )); } // ...
Dinamic Fixtures
For now, you won’t see any difference because we have a very small amount of jobs in our database. 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:
// ... public function load(ObjectManager $em) { // ... for($i = 100; $i <= 130; $i++) { $job = new Job(); $job->setCategory($em->merge($this->getReference('category-programming'))); $job->setType('full-time'); $job->setCompany('Company '.$i); $job->setPosition('Web Developer'); $job->setLocation('Paris, France'); $job->setDescription('Lorem ipsum dolor sit amet, consectetur adipisicing elit.'); $job->setHowToApply('Send your resume to lorem.ipsum [at] dolor.sit'); $job->setIsPublic(true); $job->setIsActivated(true); $job->setToken('job_'.$i); $job->setEmail('job@example.com'); $em->persist($job); } // ... $em->flush(); } // ...
You can now reload the fixtures with the doctrine:fixtures:load
task and see if only 10 jobs are displayed on the homepage for the Programming category:
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 job WHERE expires_at < NOW()
):
/app_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. For this we will create a new function in the JobRepository
:
// ... public function getActiveJob($id) { $query = $this->createQueryBuilder('j') ->where('j.id = :id') ->setParameter('id', $id) ->andWhere('j.expires_at > :date') ->setParameter('date', date('Y-m-d H:i:s', time())) ->setMaxResults(1) ->getQuery(); try { $job = $query->getSingleResult(); } catch (DoctrineOrmNoResultException $e) { $job = null; } return $job; }
The
getSingleResult()
method throws aDoctrineORMNoResultException
exception if no results are returned and aDoctrineORMNonUniqueResultException
if more than one result is returned. If you use this method, you may need to wrap it in atry-catch block
and ensure that only one result is returned.
Now change the showAction()
from the JobController
to use the new repository method:
// ... $entity = $em->getRepository('IbwJobeetBundle:Job')->getActiveJob($id); // ...
Now, if you try to get an expired job, you will be forwarded to a 404 page:
That’s all for today! We will see you again tomorrow, when we’ll be playing with the category page.
This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 Unported License.
Symfony2 Jobeet Day 5: The Routing
* This article is part of the original Jobeet Tutorial, created by Fabien Potencier, for Symfony 1.4.
URLs
If you click on a job on the Jobeet homepage, the URL looks like this: /job/1/show
. 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 the $id
parameter in the action? Here, we will answer all these questions.
You have already seen the following code in the src/Ibw/JobeetBundle/Resources/views/Job/index.html.twig
template:
{{ path('ibw_job_show', { 'id': entity.id }) }}
This uses the path
template helper function to generate the url for the job which has the id 1
. The ibw_job_show
is the name of the route used, defined in the configuration as you will see below.
Routing Configuration
In Symfony2, routing configuration is usually done in the app/config/routing.yml
. This imports specific bundle routing configuration. In our case, the src/Ibw/JobeetBundle/Resources/config/routing.yml
file is imported:
ibw_jobeet: resource: "@IbwJobeetBundle/Resources/config/routing.yml" prefix: /
Now, if you look in the JobeetBundle routing.yml
you will see that it imports another routing file, the one for the Job controller and defines a route called ibw_jobeet_homepage
for the /hello/{name}
URL pattern:
IbwJobeetBundle_job: resource: "@IbwJobeetBundle/Resources/config/routing/job.yml" prefix: /job ibw_jobeet_homepage: pattern: /hello/{name} defaults: { _controller: IbwJobeetBundle:Default:index }
ibw_job: pattern: / defaults: { _controller: "IbwJobeetBundle:Job:index" } ibw_job_show: pattern: /{id}/show defaults: { _controller: "IbwJobeetBundle:Job:show" } ibw_job_new: pattern: /new defaults: { _controller: "IbwJobeetBundle:Job:new" } ibw_job_create: pattern: /create defaults: { _controller: "IbwJobeetBundle:Job:create" } requirements: { _method: post } ibw_job_edit: pattern: /{id}/edit defaults: { _controller: "IbwJobeetBundle:Job:edit" } ibw_job_update: pattern: /{id}/update defaults: { _controller: "IbwJobeetBundle:Job:update" } requirements: { _method: post|put } ibw_job_delete: pattern: /{id}/delete defaults: { _controller: "IbwJobeetBundle:Job:delete" } requirements: { _method: post|delete }
Let’s have a closer look to the ibw_job_show
route. The pattern defined by the ibw_job_show
route acts like /*/show
where the wildcard is given the name id
. For the URL /1/show
, the id
variable gets a value of 1, which is available for you to use in your controller. The _controller
parameter is a special key that tells Symfony which controller/action should be executed when a URL matches this route, in our case it should execute the showAction
from the JobController
in the IbwJobeetBundle
.
The route parameters (e.g. {id}
) are especially important because each is made available as an argument to the controller method.
Routing Configuration in Dev Environment
The dev environment loads the app/config/routing_dev.yml
file that contains the routes used by the Web Debug Toolbar (you already deleted the routes for the AcmeDemoBundle
from /app/config/routing_dev.php
– see Day 1, How to remove the AcmeDemoBundle). This file loads, at the end, the main routing.yml
configuration file.
Route Customizations
For now, when you request the / URL in a browser, you will get a 404 Not Found error. That’s because this URL does not match any routes defined. We have a ibw_jobeet_homepage
route that matches the /hello/jobeet
URL and sends us to the DefaultController
, index
action. Let’s change it to match the /
URL and to call the index action from the JobController
. To make the change, modify it to the following:
# ... ibw_jobeet_homepage: pattern: / defaults: { _controller: IbwJobeetBundle:Job:index }
Now, if you clear the cache and go to http://jobeet.local from your browser, you will see the Job
homepage. We can now change the link of the Jobeet logo in the layout to use the ibw_jobeet_homepage
route:
<!-- ... --> <h1><a href="{{ path('ibw_jobeet_homepage') }}"> <img alt="Jobeet Job Board" src="{{ asset('bundles/ibwjobeet/images/logo.jpg') }}" /> </a></h1> <!-- ... -->
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.
The following pattern matches such a URL:
/job/{company}/{location}/{id}/{position}
Edit the ibw_job_show
route from the job.yml
file:
# ... ibw_job_show: pattern: /{company}/{location}/{id}/{position} defaults: { _controller: "IbwJobeetBundle:Job:show" }
Now, we need to pass all the parameters for the changed route for it to work:
<!-- ... --> <a href="{{ path('ibw_job_show', { 'id': entity.id, 'company': entity.company, 'location': entity.location, 'position': entity.position }) }}"> {{ entity.position }} </a> <!-- ... -->
If you have a look at generated URLs, they are not quite yet as we want them to be:
|
Job.php
file and add the following methods to the class:// ... use IbwJobeetBundleUtilsJobeet as Jobeet; class Job { // ... public function getCompanySlug() { return Jobeet::slugify($this->getCompany()); } public function getPositionSlug() { return Jobeet::slugify($this->getPosition()); } public function getLocationSlug() { return Jobeet::slugify($this->getLocation()); } }
You must also add the use
statement before the Job
class definition.
After that, create the src/Ibw/JobeetBundle/Utils/Jobeet.php
file and add the slugify
method in it:
namespace IbwJobeetBundleUtils; 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; } }
We have defined three new “virtual” accessors: getCompanySlug()
, getPositionSlug()
, and getLocationSlug()
. They return their corresponding column value after applying it the slugify()
method. Now, you can replace the real column names by these virtual ones in the template:
<!-- ... --> <a href="{{ path('ibw_job_show', { 'id': entity.id, 'company': entity.companyslug, 'location': entity.locationslug, 'position': entity.positionslug}) }}"> {{ entity.position }} </a> <!-- ... -->
Route Requirements
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:
# ... ibw_job_show: pattern: /{company}/{location}/{id}/{position} defaults: { _controller: "IbwJobeetBundle:Job:show" } requirements: id: d+ # ...
The above requirements entry forces the id to be a numeric value. If not, the route won’t match.
Route Debugging
While adding and customizing routes, it’s helpful to be able to visualize and get detailed information about your routes. A great way to see every route in your application is via the router:debug
console command. Execute the command by running the following from the root of your project:
php app/console router:debug
The command will print a helpful list of all the configured routes in your application. You can also get very specific information on a single route by including the route name after the command:
php app/console router:debug ibw_job_show
Final Thoughts
That’s all for today! To learn more about the Symfony2 routing system read the Routing chapter form the book.
This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 Unported License.
Symfony2 Jobeet Day 4: The Controller and the View
* This article is part of the original Jobeet Tutorial, created by Fabien Potencier, for Symfony 1.4.
Today, we are going to customize the basic job controller we created yesterday. It already has most of 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 Arhitecture
For web development, the most common solution for organizing your code nowadays is the MVC design pattern. 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
Entity/
directory of your bundles. - The View is what the user interacts with (a template engine is part of this layer). In Symfony 2.3.2, the View layer is mainly made of Twig templates. They are stored in various
Resources/views/
directories as we will see later in these lines. - 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 tutorial, we saw that all requests are managed by front controllers (
app.php
andapp_dev.php
). These front controllers delegate the real work to actions.
The Layout
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. A better way is to use another design pattern to solve this problem: the decorator design pattern. 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.
Symfony2 does not came with a default layout, so we will create one and use it to decorate our application pages.
Create a new file layout.html.twig
in the src/Ibw/JobeetBundle/Resources/views/
directory and put in the following code:
<!DOCTYPE html> <html> <head> <title> {% block title %} Jobeet - Your best job board {% endblock %} </title> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> {% block stylesheets %} <link rel="stylesheet" href="{{ asset('bundles/ibwjobeet/css/main.css') }}" type="text/css" media="all" /> {% endblock %} {% block javascripts %} {% endblock %} <link rel="shortcut icon" href="{{ asset('bundles/ibwjobeet/images/favicon.ico') }}" /> </head> <body> <div id="container"> <div id="header"> <div class="content"> <h1><a href="{{ path('ibw_job') }}"> <img src="{{ asset('bundles/ibwjobeet/images/logo.jpg') }}" alt="Jobeet Job Board" /> </a></h1> <div id="sub_header"> <div class="post"> <h2>Ask for people</h2> <div> <a href="{{ path('ibw_job') }}">Post a Job</a> </div> </div> <div class="search"> <h2>Ask for a job</h2> <form action="" method="get"> <input type="text" name="keywords" id="search_keywords" /> <input type="submit" value="search" /> <div class="help"> Enter some keywords (city, country, position, ...) </div> </form> </div> </div> </div> </div> <div id="content"> {% for flashMessage in app.session.flashbag.get('notice') %} <div class="flash_notice"> {{ flashMessage }} </div> {% endfor %} {% for flashMessage in app.session.flashbag.get('error') %} <div class="flash_error"> {{ flashMessage }} </div> {% endfor %} <div class="content"> {% block content %} {% endblock %} </div> </div> <div id="footer"> <div class="content"> <span class="symfony"> <img src="{{ asset('bundles/ibwjobeet/images/jobeet-mini.png') }}" /> powered by <a href="http://www.symfony.com/"> <img src="{{ asset('bundles/ibwjobeet/images/symfony.gif') }}" alt="symfony framework" /> </a> </span> <ul> <li><a href="">About Jobeet</a></li> <li class="feed"><a href="">Full feed</a></li> <li><a href="">Jobeet API</a></li> <li class="last"><a href="">Affiliates</a></li> </ul> </div> </div> </div> </body> </html>
Twig Blocks
In Twig, the default Symfony template engine, you can define blocks as we did above. A twig block can have a default content (look at the title block
, for example) that can be replaced or extended in the child template as you will see in a moment.
Now, to make use of the layout we created, we will need to edit all the job templates (index, edit, new
and show
from src/Ibw/JobeetBundle/Resources/views/Job/
) to extend the parent template (the layout) and to overwrite the content
block we defined with the body
block content from the original template
{% extends 'IbwJobeetBundle::layout.html.twig' %} {% block content %} <!-- original body block code goes here --> {% endblock %}
The Stylesheets, Images and JavaScripts
As this is not about web design, we have already prepared all the needed assets we will use for Jobeet: download the image files archive and put them into the src/Ibw/JobeetBundle/Resources/public/images/
directory; download the stylesheet files archive and put them into the src/Ibw/JobeetBundle/Resources/public/css/
directory.
Now run
php app/console assets:install web --symlink
to tell Symfony to make them available to the public.
If you look in the css
folder, you will notice that we have four css files: admin.css
, job.css
,jobs.css
and main.css
. The main.css
is needed in all Jobeet pages, so we included it in the layout in the stylesheet twig block
. The rest are more specialized css files and we need them only in specific pages.
To add a new css file in a template, we will overwrite the stylesheet block, but call the parent before adding the new css file (so we would have the main.css and the additional css files we need).
{% extends 'IbwJobeetBundle::layout.html.twig' %} {% block stylesheets %} {{ parent() }} <link rel="stylesheet" href="{{ asset('bundles/ibwjobeet/css/jobs.css') }}" type="text/css" media="all" /> {% endblock %} <!-- rest of the code -->
{% extends 'IbwJobeetBundle::layout.html.twig' %} {% block stylesheets %} {{ parent() }} <link rel="stylesheet" href="{{ asset('bundles/ibwjobeet/css/job.css') }}" type="text/css" media="all" /> {% endblock %} <!-- rest of the code -->
The Job Homepage Action
Each action is represented by a method of a class. For the job homepage, the class is JobController
and the method is indexAction()
. It retrieves all the jobs from the database.
// ... public function indexAction() { $em = $this->getDoctrine()->getManager(); $entities = $em->getRepository('IbwJobeetBundle:Job')->findAll(); return $this->render('IbwJobeetBundle:Job:index.html.twig', array( 'entities' => $entities )); } // ...
Let’s have a closer look at the code: the indexAction()
method gets the Doctrine entity manager object, which is responsible for handling the process of persisting and fetching objects to and from database, and then the repository, that will create a query to retrieve all the jobs. It returns a Doctrine ArrayCollection
of Job
objects that are passed to the template (the View).
The Job Homepage Template
The index.html.twig
template generates an HTML table for all the jobs. Here is the current template code:
{% extends 'IbwJobeetBundle::layout.html.twig' %} {% block stylesheets %} {{ parent() }} <link rel="stylesheet" href="{{ asset('bundles/ibwjobeet/css/jobs.css') }}" type="text/css" media="all" /> {% endblock %} {% block content %} <h1>Job list</h1> <table class="records_list"> <thead> <tr> <th>Id</th> <th>Type</th> <th>Company</th> <th>Logo</th> <th>Url</th> <th>Position</th> <th>Location</th> <th>Description</th> <th>How_to_apply</th> <th>Token</th> <th>Is_public</th> <th>Is_activated</th> <th>Email</th> <th>Expires_at</th> <th>Created_at</th> <th>Updated_at</th> <th>Actions</th> </tr> </thead> <tbody> {% for entity in entities %} <tr> <td><a href="{{ path('ibw_job_show', { 'id': entity.id }) }}">{{ entity.id }}</a></td> <td>{{ entity.type }}</td> <td>{{ entity.company }}</td> <td>{{ entity.logo }}</td> <td>{{ entity.url }}</td> <td>{{ entity.position }}</td> <td>{{ entity.location }}</td> <td>{{ entity.description }}</td> <td>{{ entity.howtoapply }}</td> <td>{{ entity.token }}</td> <td>{{ entity.ispublic }}</td> <td>{{ entity.isactivated }}</td> <td>{{ entity.email }}</td> <td>{% if entity.expiresat %}{{ entity.expiresat|date('Y-m-d H:i:s') }}{% endif%}</td> <td>{% if entity.createdat %}{{ entity.createdat|date('Y-m-d H:i:s') }}{% endif%}</td> <td>{% if entity.updatedat %}{{ entity.updatedat|date('Y-m-d H:i:s') }}{% endif%}</td> <td> <ul> <li> <a href="{{ path('ibw_job_show', { 'id': entity.id }) }}">show</a> </li> <li> <a href="{{ path('ibw_job_edit', { 'id': entity.id }) }}">edit </a> </li> </ul> </td> </tr> {% endfor %} </tbody> </table> <ul> <li> <a href="{{ path('ibw_job_new') }}"> Create a new entry </a> </li> </ul> {% endblock %}
Let’s clean this up a bit to only display a sub-set of the available columns. Replace the twig block content
with the one below:
{% block content %} <div id="jobs"> <table class="jobs"> {% for entity in entities %} <tr class="{{ cycle(['even', 'odd'], loop.index) }}"> <td class="location">{{ entity.location }}</td> <td class="position"> <a href="{{ path('ibw_job_show', { 'id': entity.id }) }}"> {{ entity.position }} </a> </td> <td class="company">{{ entity.company }}</td> </tr> {% endfor %} </table> </div> {% endblock %}
The Job Page Template
Now let’s customize the template of the job page. Open the show.html.twig
file and replace its content with the following code:
{% extends 'IbwJobeetBundle::layout.html.twig' %} {% block title %} {{ entity.company }} is looking for a {{ entity.position }} {% endblock %} {% block stylesheets %} {{ parent() }} <link rel="stylesheet" href="{{ asset('bundles/ibwjobeet/css/job.css') }}" type="text/css" media="all" /> {% endblock %} {% block content %} <div id="job"> <h1>{{ entity.company }}</h1> <h2>{{ entity.location }}</h2> <h3> {{ entity.position }} <small> - {{ entity.type }}</small> </h3> {% if entity.logo %} <div class="logo"> <a href="{{ entity.url }}"> <img src="/uploads/jobs/{{ entity.logo }}" alt="{{ entity.company }} logo" /> </a> </div> {% endif %} <div class="description"> {{ entity.description|nl2br }} </div> <h4>How to apply?</h4> <p class="how_to_apply">{{ entity.howtoapply }}</p> <div class="meta"> <small>posted on {{ entity.createdat|date('m/d/Y') }}</small> </div> <div style="padding: 20px 0"> <a href="{{ path('ibw_job_edit', { 'id': entity.id }) }}"> Edit </a> </div> </div> {% endblock %}
The Job Page Action
The job page is generated by the show
action, defined in the showAction()
method of the JobController
:
public function showAction($id) { $em = $this->getDoctrine()->getManager(); $entity = $em->getRepository('IbwJobeetBundle:Job')->find($id); if (!$entity) { throw $this->createNotFoundException('Unable to find Job entity.'); } $deleteForm = $this->createDeleteForm($id); return $this->render('IbwJobeetBundle:Job:show.html.twig', array( 'entity' => $entity, 'delete_form' => $deleteForm->createView(), )); }
As in the index
action, the IbwJobeetBundle
repository class is used to retrieve a job, this time 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 $id
parameter of the actionShow()
function contains 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 throw $this->createNotFoundException()
does.
As for exceptions, the page displayed to the user is different in the prod
environment and in the dev
ennvironment.
That’s all for today! Tomorrow we will get you familiar with the routing features.
This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 Unported License.
Symfony2 Jobeet Day 3: The Data Model
* This article is part of the original Jobeet Tutorial, created by Fabien Potencier, for Symfony 1.4.
If you’re itching to open your text editor and lay down some PHP, you will be happy to know that 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 work for us, we will have a fully functional web module without writing too much PHP code.
The Relational Model
The user stories from the previous day 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 created_at
and updated_at
columns. We will configure Symfony to set their value automatically when an object is saved or updated.
The Database
To store the jobs, affiliates and categories in the database, Symfony 2.3.2 uses Doctrine ORM. To define the database connection parameters, you have to edit the app/config/parameters.yml
file (for this tutorial we will use MySQL):
parameters: database_driver: pdo_mysql database_host: localhost database_port: null database_name: jobeet database_user: root database_password: password # ...
Now that Doctrine knows about your database, you can have it create the database for you by typing the following command in your terminal:
php app/console doctrine:database:create
The Schema
To tell Doctrine about our objects, we will create “metadata” files that will describe how our objects will be stored in the database. Now go to your code editor and create a directory named doctrine
, inside src/Ibw/JobeetBundle/Resources/config
directory. Doctrine will contain three files: Category.orm.yml
, Job.orm.yml
and Affiliate.orm.yml.
IbwJobeetBundleEntityCategory: type: entity table: category id: id: type: integer generator: { strategy: AUTO } fields: name: type: string length: 255 unique: true oneToMany: jobs: targetEntity: Job mappedBy: category manyToMany: affiliates: targetEntity: Affiliate mappedBy: categories
IbwJobeetBundleEntityJob: type: entity table: job id: id: type: integer generator: { strategy: AUTO } fields: type: type: string length: 255 nullable: true company: type: string length: 255 logo: type: string length: 255 nullable: true url: type: string length: 255 nullable: true position: type: string length: 255 location: type: string length: 255 description: type: text how_to_apply: type: text token: type: string length: 255 unique: true is_public: type: boolean nullable: true is_activated: type: boolean nullable: true email: type: string length: 255 expires_at: type: datetime created_at: type: datetime updated_at: type: datetime nullable: true manyToOne: category: targetEntity: Category inversedBy: jobs joinColumn: name: category_id referencedColumnName: id lifecycleCallbacks: prePersist: [ setCreatedAtValue ] preUpdate: [ setUpdatedAtValue ]
IbwJobeetBundleEntityAffiliate: type: entity table: affiliate id: id: type: integer generator: { strategy: AUTO } fields: url: type: string length: 255 email: type: string length: 255 unique: true token: type: string length: 255 is_active: type: boolean nullable: true created_at: type: datetime manyToMany: categories: targetEntity: Category joinTable: name: category_affiliate joinColumns: affiliate_id: referencedColumnName: id inverseJoinColumns: category_id: referencedColumnName: id lifecycleCallbacks: prePersist: [ setCreatedAtValue ]
The ORM
Now Doctrine can generate the classes that define our objects for us with the command:
php app/console doctrine:generate:entities IbwJobeetBundle
If you take a look into Entity
directory from IbwJobeetBundle
, you will find the newly generated classes in there: Category.php
, Job.php
and Affiliate.php.
Open Job.php
and set the created_at
and updated_at
values as below:
// ... /** * @ORMPrePersist */ public function setCreatedAtValue() { if(!$this->getCreatedAt()) { $this->created_at = new DateTime(); } } /** * @ORMPreUpdate */ public function setUpdatedAtValue() { $this->updated_at = new DateTime(); }
You will do the same for created_at
value of the Affiliate
class:
// ... /** * @ORMPrePersist */ public function setCreatedAtValue() { $this->created_at = new DateTime(); } // ...
This will make Doctrine to set the created_at and updated_at values when saving or updating objects. This behaviour was defined in the Affiliate.orm.yml
and Job.orm.yml
files listed above.
We will also ask Doctrine to create our database tables with the command below:
php app/console doctrine:schema:update --force
This task should only be used during the development. For a more robust method of systematically updating your production database, read about Doctrine migrations.
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 (this is needed for the application to work, in our case we will have some initial categories and an admin user), test data (needed for the application to be tested) and user data (created by users during the normal life of the application).
To populate the database with some initial data, we will use DoctrineFixturesBundle. To setup this bundle, we have to follow the next steps:
1. Add the following to your composer.json
file, in the require
section:
// ... "require": { // ... "doctrine/doctrine-fixtures-bundle": "dev-master", "doctrine/data-fixtures": "dev-master" }, // ...
2. Update the vendor
libraries:
php composer.phar update
3. Register the bundle DoctrineFixturesBundle in app/AppKernel.php
:
// ... public function registerBundles() { $bundles = array( // ... new DoctrineBundleFixturesBundleDoctrineFixturesBundle() ); // ... }
Now that everything is set up, we will create some new classes to load data in a new folder, named src/Ibw/JobeetBundle/DataFixtures/ORM
, in our bundle:
<?php namespace IbwJobeetBundleDataFixturesORM; use DoctrineCommonPersistenceObjectManager; use DoctrineCommonDataFixturesAbstractFixture; use DoctrineCommonDataFixturesOrderedFixtureInterface; use IbwJobeetBundleEntityCategory; class LoadCategoryData extends AbstractFixture implements OrderedFixtureInterface { public function load(ObjectManager $em) { $design = new Category(); $design->setName('Design'); $programming = new Category(); $programming->setName('Programming'); $manager = new Category(); $manager->setName('Manager'); $administrator = new Category(); $administrator->setName('Administrator'); $em->persist($design); $em->persist($programming); $em->persist($manager); $em->persist($administrator); $em->flush(); $this->addReference('category-design', $design); $this->addReference('category-programming', $programming); $this->addReference('category-manager', $manager); $this->addReference('category-administrator', $administrator); } public function getOrder() { return 1; // the order in which fixtures will be loaded } }
<?php namespace IbwJobeetBundleDataFixturesORM; use DoctrineCommonPersistenceObjectManager; use DoctrineCommonDataFixturesAbstractFixture; use DoctrineCommonDataFixturesOrderedFixtureInterface; use IbwJobeetBundleEntityJob; class LoadJobData extends AbstractFixture implements OrderedFixtureInterface { public function load(ObjectManager $em) { $job_sensio_labs = new Job(); $job_sensio_labs->setCategory($em->merge($this->getReference('category-programming'))); $job_sensio_labs->setType('full-time'); $job_sensio_labs->setCompany('Sensio Labs'); $job_sensio_labs->setLogo('sensio-labs.gif'); $job_sensio_labs->setUrl('http://www.sensiolabs.com/'); $job_sensio_labs->setPosition('Web Developer'); $job_sensio_labs->setLocation('Paris, France'); $job_sensio_labs->setDescription('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.'); $job_sensio_labs->setHowToApply('Send your resume to fabien.potencier [at] sensio.com'); $job_sensio_labs->setIsPublic(true); $job_sensio_labs->setIsActivated(true); $job_sensio_labs->setToken('job_sensio_labs'); $job_sensio_labs->setEmail('job@example.com'); $job_sensio_labs->setExpiresAt(new DateTime('+30 days')); $job_extreme_sensio = new Job(); $job_extreme_sensio->setCategory($em->merge($this->getReference('category-design'))); $job_extreme_sensio->setType('part-time'); $job_extreme_sensio->setCompany('Extreme Sensio'); $job_extreme_sensio->setLogo('extreme-sensio.gif'); $job_extreme_sensio->setUrl('http://www.extreme-sensio.com/'); $job_extreme_sensio->setPosition('Web Designer'); $job_extreme_sensio->setLocation('Paris, France'); $job_extreme_sensio->setDescription('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.'); $job_extreme_sensio->setHowToApply('Send your resume to fabien.potencier [at] sensio.com'); $job_extreme_sensio->setIsPublic(true); $job_extreme_sensio->setIsActivated(true); $job_extreme_sensio->setToken('job_extreme_sensio'); $job_extreme_sensio->setEmail('job@example.com'); $job_extreme_sensio->setExpiresAt(new DateTime('+30 days')); $em->persist($job_sensio_labs); $em->persist($job_extreme_sensio); $em->flush(); } public function getOrder() { return 2; // the order in which fixtures will be loaded } }
Once your fixtures have been written, you can load them via the command line by using thedoctrine:fixtures:load
command:
php app/console doctrine:fixtures:load
Now, if you check your database, you should see the data loaded into tables.
See it in the browser
If you run the command below, it will create a new controller src/Ibw/JobeetBundle/Controllers/JobController.php
with actions for listing, creating, editing and deleting jobs (and their corresponding templates, form and routes):
php app/console doctrine:generate:crud --entity=IbwJobeetBundle:Job --route-prefix=ibw_job --with-write --format=yml
After running this command, you will need to do some configurations the prompter requires you to. So just select the default answers for them.
To view this in the browser, we must import the new routes that were created in src/Ibw/JobeetBundle/Resources/config/routing/job.yml
into our bundle main routing file:
IbwJobeetBundle_job: resource: "@IbwJobeetBundle/Resources/config/routing/job.yml" prefix: /job # ...
We will also need to add a _toString()
method to our Category
class to be used by the category drop down from the edit job form:
// ... public function __toString() { return $this->getName() ? $this->getName() : ""; } // ...
Clear the cache:
php app/console cache:clear --env=dev php app/console cache:clear --env=prod
You can now test the job controller in a browser: http://jobeet.local/job/
or, in development environment, http://jobeet.local/app_dev.php/job/
.
You can now create and edit jobs. Try to leave a required field blank, or try to enter invalid data. That’s right, Symfony has created basic validation rules by introspecting the database schema.
That’s all. Today, we have barely written PHP code but we have a working web module for the job model, ready to be tweaked and customized. Tomorrow, we will get familiar with the controller and the view. See you next time!
This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 Unported License.
Symfony2 Jobeet Day 2: The Project
* This article is part of the original Jobeet Tutorial, created by Fabien Potencier, for Symfony 1.4.
We have not written a single line of code yet, but, in Day 1, we setup the environment and created an empty Symfony project.
This day is about the project specifications. 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.
Symfony2 Jobeet - User Stories
The Jobeet website will have four type of users: admin (owns and manages the website), user (visits the website looking for a job), poster (visits the website to post jobs) and affiliate (re-publishes jobs on his website).
In the original tutorial, we had to make two applications, the frontend, where the users interact with the website, and the backend, where admins manage the website. Using Symfony 2.3.2, we would not do this anymore. We will have only one application and, in it, a separate secured section for admins.
Story F1: On the homepage, the user sees the latest active jobs
When a user comes to 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 available and the company are displayed.
For each category, the list shows the first 10 jobs and a link that 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).
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.
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 a list to see more detailed information.
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 (wether the job can also be published on affiliate websites)
- Email (email of poster)
The process has only two steps: first, the user fills in the form with all the needed information to describe the job, then validates the information by previewing the final job page.
There is no need to create an acount to post a job. A job can be modified afterwards thanks to a specific URL (protected by a token given to the user when the job is created).
Each job post is online for 30 days (this is configurable by admin). 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 Jobeet API. To apply, he must give the following information:
- Name
- 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.
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. The affiliate can limit the number of jobs to be returned and, also, 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.
As a developer, you never start coding from the first day. Firstly, you need to gather the requirements of your project and understand how your project is supposed to work. That’s what you have done today. See you tomorrow!
This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 Unported License.
Symfony2 Jobeet Day 1: Starting up the Project
* This article is part of the original Jobeet Tutorial, created by Fabien Potencier, for Symfony 1.4.
What is Jobeet?
Jobeet is an Open-Source job board software which provides you day-by-day tutorials, that will help you learn the latest Web Technolgy, Symfony 2.3.2
(for those who don’t know yet, Symfony is a framework for PHP).
Each chapter/day is meant to last about one hour, 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.
Today, being your first day, you won’t be writing any code. Instead, you will setup a working development environment.
Setting up the working development environment
First of all, you need to check that your computer has a friendly working environment for web development. We will use Ubuntu 12.04 LTS Server installed in a VMware Player virtual machine. At a minimum, you need a web server (Apache, for instance), a database engine (MySQL) and PHP 5.3.3 or later.
1. Install Apache
, your web server:
sudo apt-get install apache2
and enable Apache mod-rewrite:
sudo a2enmod rewrite
2, Install the MySQL Server
:
sudo apt-get install mysql-server mysql-client
3. Install PHP
, the server scripting language
sudo apt-get install php5 libapache2-mod-php5 php5-mysql
4. Install Intl
extension:
sudo apt-get install php5-intl
5. Now, you need to restart Apache service:
sudo service apache2 restart
Download and install Symfony 2.3.2
The first thing to do is to prepare a directory on your web server where you want to install the new project. Let’s call it jobeet
: /var/www/jobeet
.
mkdir /var/www/jobeet
We have a directory prepared, but what to put in it? Go to http://symfony.com/download, choose Symfony Standard 2.3.2 without vendors
and download it. Now, unzip the files inside the Symfony
directory to your prepared directory, jobeet
.
Updating Vendors
At this point, you’ve downloaded a fully-functional Symfony project in which you’ll start to develop your own application. A Symfony project depends on a number of external libraries. These are downloaded into the vendor/
directory of your project via a library called Composer
.
Composer is a dependency management library for PHP, which you can use to download the Symfony 2.3.2 Standard Edition. Start by downloading Composer onto your jobeet
directory:
curl -s https://getcomposer.org/installer | php
If you don’t have
curl
extension installed, you can install it using this command:sudo apt-get install curl
Next, type the following command to start downloading all the necessary vendor libraries:
php composer.phar install
Web Server Configuration
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, it’s recommended to store these files under the web/
sub-directory of a symfony project.
To configure Apache for your new project, you will create a virtual host. In order to do that, go to your terminal and type in the next command :
sudo nano /etc/apache2/sites-available/jobeet.local
Now, a file named jobeet.local
is created. Put the following inside that file, then hit Control – O and Enter to save it, then Control – X to exit the editor.
<VirtualHost *:80> ServerName jobeet.local DocumentRoot /var/www/jobeet/web DirectoryIndex app.php ErrorLog /var/log/apache2/jobeet-error.log CustomLog /var/log/apache2/jobeet-access.log combined <Directory "/var/www/jobeet/web"> AllowOverride All Allow from All </Directory> </VirtualHost>
The domain name jobeet.local
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, this file is located in the C:WindowsSystem32driversetc
directory. Add the following line:
127.0.0.1 jobeet.local
Replace 127.0.0.1 with the ip of your web server machine in case you are working on a remote server.
If you want this to work, you need to enable the newly created virtual host and restart your Apache. So go to your terminal and type:
sudo a2ensite jobeet.local sudo service apache2 restart
Symfony comes with a visual server configuration tester to help make sure your Web server and PHP are correctly configured to use Symfony. Use the following URL to check your configuration:
http://jobeet.local/config.php
If you don’t run this from your localhost, you should locate and openweb/config.php
file and comment the lines that restrict the access outside localhost:if (!isset($_SERVER['HTTP_HOST'])) { exit('This script cannot be run from the CLI. Run it from a browser.'); } /* if (!in_array(@$_SERVER['REMOTE_ADDR'], array( '127.0.0.1', '::1', ))) { header('HTTP/1.0 403 Forbidden'); exit('This script is only accessible from localhost.'); } */ // ...Do the same for
web/app_dev.php
:use SymfonyComponentHttpFoundationRequest; use SymfonyComponentDebugDebug; // If you don't want to setup permissions the proper way, just uncomment the following PHP line // read http://symfony.com/doc/current/book/installation.html#configuration-and-setup for more information //umask(0000); // This check prevents access to debug front controllers that are deployed by accident to production servers. // Feel free to remove this, extend it, or make something more sophisticated. /* if (isset($_SERVER['HTTP_CLIENT_IP']) || isset($_SERVER['HTTP_X_FORWARDED_FOR']) || !in_array(@$_SERVER['REMOTE_ADDR'], array('127.0.0.1', 'fe80::1', '::1')) ) { header('HTTP/1.0 403 Forbidden'); exit('You are not allowed to access this file. Check '.basename(__FILE__).' for more information.'); } */ $loader = require_once __DIR__.'/../app/bootstrap.php.cache'; Debug::enable(); require_once __DIR__.'/../app/AppKernel.php'; // ...
Probably, you will get all kind of requirements when you go to config.php.
Below, is a list of things to do for not getting all those “warnings”.
1. Change the permissions of app/cache
and app/logs:
sudo chmod -R 777 app/cache sudo chmod -R 777 app/logs sudo setfacl -dR -m u::rwX app/cache app/logs
Install ACL if you don’t have it yet:
sudo apt-get install acl
2. Set the date.timezone
setting in php.ini
date.timezone = Europe/Bucharest
sudo nano /etc/php5/apache2/php.ini
date.timezone
setting for [date]
section and set it to your timezone. After that, erase “;”, placed at the beginning of the line.short_open_tag
setting to off
in the same php.ini
fileshort_open_tag Default Value: Off
4. Install and enable a PHP Accelerator (APC recommended)
sudo apt-get install php-apc sudo service apache2 restart
After restarting Apache, open a browser window and type in http://jobeet.local/app_dev.php. You should see the following page:
Symfony2 Console
Symfony2 comes with the console component tool that you will use for different tasks. To see a list of things it can do for you type at the command prompt:
php app/console list
Creating the Application Bundle
What exactly is a bundle?
Is similar to a plugin in other software, but even better. The key difference is that everything is a bundle in Symfony 2.3.2, including both core framework functionality and the code written for your application.
A bundle is a structured set of files within a directory that implement a single feature.
Tips: A bundle can live anywhere as long as it can be autoloaded (app/autoload.php
).
You can read more here: http://symfony.com/doc/current/book/page_creation.html#the-bundle-system – The Bundle System.
Creating a basic bundle skeleton
Run the following command to start the Symfony’s bundle generator:
php app/console generate:bundle --namespace=Ibw/JobeetBundle
The generator will ask you some questions before generating the bundle. Here are the questions and answers (all, except one, are the default answers):
Bundle name [IbwJobeetBundle]: IbwJobeetBundle Target directory [/var/www/jobeet/src]: /var/www/jobeet/src Configuration format (yml, xml, php, or annotation) [yml]: yml Do you want to generate the whole directory structure [no]? yes Do you confirm generation [yes]? yes Confirm automatic update of your Kernel [yes]? yes Confirm automatic update of the Routing [yes]? yes
Clear the cache after generating the new bundle with:
php app/console cache:clear --env=prod php app/console cache:clear --env=dev
The new Jobeet bundle can be now found in the src
directory of your project: src/Ibw/JobeetBundle
. The bundle generator made a DefaultController
with an index action. You can access this in your browser: http://jobeet.local/hello/jobeet or http://jobeet.local/app_dev.php/hello/jobeet.
How to remove the AcmeDemoBundle
The Symfony 2.3.2 Standard Edition comes with a complete demo that lives inside a bundle called AcmeDemoBundle. It is a great boilerplate to refer to while starting a project, but you’ll probably want to eventually remove it.
1. Type the command to delete Acme
directory:
rm -rf /var/www/jobeet/src/Acme
2. Go to: /var/www/jobeet/app/AppKernel.php
and delete:
// ... $bundles[] = new AcmeDemoBundleAcmeDemoBundle(); // ...
and now delete from app/config/routing_dev.yml:
# ... # AcmeDemoBundle routes (to be removed) _acme_demo: resource: "@AcmeDemoBundle/Resources/config/routing.yml"
3. Finally, clear the cache.
The Environments
Symfony 2.3.2 has different environments. If you look in the project’s web directory, you will see two php files: app.php
and app_dev.php
. These files are called front controllers; all requests to the application are made through them. The app.php
file is for production environment and app_dev.php
is used by web developers when they work on the application in the development environment. The development environment will prove very handy because it will show you all the errors and warnings and the Web Debug Toolbar – the developer’s best friend.
That’s all for today. See you on the next day of this tutorial, when we will talk about what exactly the Jobeet website will be about!
This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 Unported License.