Symfony2 Jobeet Day 10: The Forms

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 out Job entity. This also generated a Job form that you can find in /src/Ens/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:

<!-- src/Ens/JobeetBundle/Resources/views/layout.html.twig -->
<a href="{{ path('ens_job_new') }}">Post a Job</a>

Then, change the ens_job_show route parameters in createAction of the JobController to match the new route we created in day 5 of this tutorial:

// src/Ens/JobeetBundle/Controller/JobController.php
// ...

public function createAction()
{
  $entity  = new Job();
  $request = $this->getRequest();
  $form    = $this->createForm(new JobType(), $entity);
  $form->bindRequest($request);

  if ($form->isValid()) {
    $em = $this->getDoctrine()->getEntityManager();

    $em->persist($entity);
    $em->flush();

    return $this->redirect($this->generateUrl('ens_job_show', array(
      'company' => $entity->getCompanySlug(),
      'location' => $entity->getLocationSlug(),
      'id' => $entity->getId(),
      'position' => $entity->getPositionSlug()
    )));
  }

  return $this->render('EnsJobeetBundle: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:

// src/Ens/JobeetBundle/Form/JobType.php

namespace Ens\JobeetBundle\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilder;

class JobType extends AbstractType
{
    public function buildForm(FormBuilder $builder, array $options)
    {
        $builder->add('category');
        $builder->add('type');
        $builder->add('company');
        $builder->add('logo');
        $builder->add('url');
        $builder->add('position');
        $builder->add('location');
        $builder->add('description');
        $builder->add('how_to_apply');
        $builder->add('token');
        $builder->add('is_public');
        $builder->add('email');
    }

    public function getName()
    {
        return 'ens_jobeetbundle_jobtype';
    }
}

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:

# src/Ens/JobeetBundle/Resources/config/validation.yml

Ens\JobeetBundle\Entity\Job:
    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.

// src/Ens/JobeetBundle/Form/JobType.php
// ...

use Ens\JobeetBundle\Entity\Job;

class JobType extends AbstractType
{
    public function buildForm(FormBuilder $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:

// src/Ens/JobeetBundle/Entity/Job.php
// ...

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.

# src/Ens/JobeetBundle/Resources/config/validation.yml

Ens\JobeetBundle\Entity\Job:
    properties:
        type:
            - NotBlank: ~
            - Choice: { callback: getTypeValues }
        email:
            - NotBlank: ~
            - Email: ~

For each field, symfony automatically generates a label (which will be used in the rendered <label> tag). This can be changed with the label option:

$builder->add('logo', null, array('label' => 'Company logo'));
$builder->add('how_to_apply', null, array('label' => 'How to apply?'));
$builder->add('is_public', null, array('label' => 'Public?'));

You should also add validation constraints for the rest of the fields:

# src/Ens/JobeetBundle/Resources/config/validation.yml

Ens\JobeetBundle\Entity\Job:
    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: ~

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:

// src/Ens/JobeetBundle/Entity/Job.php
// ...

public $file;

Now we need to replace the logo with the file widget and change it to a file input tag:

// src/Ens/JobeetBundle/Form/JobType.php
// ...

$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:

# src/Ens/JobeetBundle/Resources/config/validation.yml
Ens\JobeetBundle\Entity\Job:
    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.

// src/Ens/JobeedBundle/Controller/JobController.php
// ...

public function createAction()
{
  // ...

  if ($form->isValid()) {
    $em = $this->getDoctrine()->getEntityManager();

    $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('ens_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:

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:

# src/Ens/JobeetBundle/Resources/config/doctrine/Job.orm.yml
Ens\JobeetBundle\Entity\Job:
# ...

  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 EnsJobeetBundle

Edit the Job entity and change the added methods to the following:

// src/Ens/JobeetBundle/Entity/Job.php
// ...

/**
* @ORM\prePersist
*/
public function preUpload()
{
  if (null !== $this->file) {
    // do whatever you want to generate a unique name
    $this->logo = uniqid().'.'.$this->file->guessExtension();
  }
}

/**
* @ORM\postPersist
*/
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);
}

/**
* @ORM\postRemove
*/
public function removeUpload()
{
  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 the upload:

// src/Ens/JobeetBundle/Controller/JobController.php
// ...

public function createAction()
{
  $entity  = new Job();
  $request = $this->getRequest();
  $form    = $this->createForm(new JobType(), $entity);
  $form->bindRequest($request);

  if ($form->isValid()) {
    $em = $this->getDoctrine()->getEntityManager();

    $em->persist($entity);
    $em->flush();

    return $this->redirect($this->generateUrl('ens_job_show', array(
      'company' => $entity->getCompanySlug(),
      'location' => $entity->getLocationSlug(),
      'id' => $entity->getId(),
      'position' => $entity->getPositionSlug()
    )));
  }

  return $this->render('EnsJobeetBundle: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:

<!-- /src/Ens/JobeetBundle/Resources/views/Job/new.html.twig -->
{% extends 'EnsJobeetBundle::layout.html.twig' %}

{% form_theme form _self %}

{% block field_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 field_errors %}

{% block stylesheets %}
  {{ parent() }}
  <link rel="stylesheet" href="{{ asset('bundles/ensjobeet/css/job.css') }}" type="text/css" media="all" />
{% endblock %}

{% block content %}
  <h1>Job creation</h1>
  <form action="{{ path('ens_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_rest(form) }}
  </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_widget(form) }}

By printing form_widget(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:

<!-- /src/Ens/JobeetBundle/Resources/views/Job/edit.html.twig -->
{% extends 'EnsJobeetBundle::layout.html.twig' %}

{% form_theme edit_form _self %}

{% block field_errors %}
{% spaceless %}
  {% if errors|length > 0 %}
    <ul>
      {% for error in errors %}
        <li>{{ error.messageTemplate|trans(error.messageParameters, 'validators') }}</li>
      {% endfor %}
    </ul>
  {% endif %}
{% endspaceless %}
{% endblock field_errors %}

{% block stylesheets %}
  {{ parent() }}
  <link rel="stylesheet" href="{{ asset('bundles/ensjobeet/css/job.css') }}" type="text/css" media="all" />
{% endblock %}

{% block content %}
  <h1>Job edit</h1>
  <form action="{{ path('ens_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_widget(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_rest(edit_form) }}
  </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 job
  • createAction: Processes the form (validation, form repopulation) and creates a new job with the user submitted values
  • editAction: Displays a form to edit an existing job
  • updateAction: 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 (bindRequest() 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:

// src/Ens/JobeetBundle/Controller/JobController.php
// ...

public function newAction()
{
  $entity = new Job();
  $entity->setType('full-time');
  $form   = $this->createForm(new JobType(), $entity);

  return $this->render('EnsJobeetBundle: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. Create a new setTokenValue() method of the Job entity to add the logic that generates the token before a new job is saved:

// src/Ens/JobeetBundle/Entity/Job.php
// ...

public function setTokenValue()
{
  if(!$this->getToken())
  {
    $this->token = sha1($this->getEmail().rand(11111, 99999));
  }
}

// ...

Add this method to the prePersist lifecycleCallbacks for the Job entity:

# src/End/JobeetBundle/Resources/config/doctrine/Job.orm.yml
# ...

  lifecycleCallbacks:
    prePersist: [ setTokenValue, preUpload, setCreatedAtValue, setExpiresAtValue ]
    # ...

Regenerate the doctrine entities to apply this modification:

php app/console doctrine:generate:entities EnsJobeetBundle

You can now remove the token field from the form:

// src/Ens/JobeetBundle/Form/JobType.php
// ...

public function buildForm(FormBuilder $builder, array $options)
{
    $builder->add('category');
    $builder->add('type', 'choice', array('choices' => Job::getTypes(), 'expanded' => true));
    $builder->add('company');
    $builder->add('file', 'file', array('label' => 'Company logo', 'required' => false));
    $builder->add('url');
    $builder->add('position');
    $builder->add('location');
    $builder->add('description');
    $builder->add('how_to_apply', null, array('label' => 'How to apply?'));
    $builder->add('is_public', null, array('label' => 'Public?'));
    $builder->add('email');
}

// ...

Remove it from the new.html.twig and edit.html.twig templates also:

<!-- src/Ens/JobeetBundle/Resources/views/Job/new.html/twig -->
<tr>
  <th>{{ form_label(form.token) }}</th>
  <td>
    {{ form_errors(form.token) }}
    {{ form_widget(form.token) }}
  </td>
</tr>

<!-- src/Ens/JobeetBundle/Resources/views/Job/edit.html/twig -->
<tr>
  <th>{{ form_label(edit_form.token) }}</th>
  <td>
    {{ form_errors(edit_form.token) }}
    {{ form_widget(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:

# src/End/JobeetBundle/Resources/config/routing/job.yml
# ...

ens_job_edit:
    pattern:  /{token}/edit
    defaults: { _controller: "EnsJobeetBundle:Job:edit" }

ens_job_update:
    pattern:  /{token}/update
    defaults: { _controller: "EnsJobeetBundle:Job:update" }
    requirements: { _method: post }

ens_job_delete:
    pattern:  /{token}/delete
    defaults: { _controller: "EnsJobeetBundle:Job:delete" }
    requirements: { _method: post }

Now edit the JobController to use the token instead of the id:

// src/Ens/JobeetBundle/Controller/JobController.php
// ...

public function editAction($token)
{
  $em = $this->getDoctrine()->getEntityManager();

  $entity = $em->getRepository('EnsJobeetBundle: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('EnsJobeetBundle:Job:edit.html.twig', array(
    'entity'      => $entity,
    'edit_form'   => $editForm->createView(),
    'delete_form' => $deleteForm->createView(),
  ));
}

public function updateAction($token)
{
  $em = $this->getDoctrine()->getEntityManager();

  $entity = $em->getRepository('EnsJobeetBundle:Job')->findOneByToken($token);

  if (!$entity) {
    throw $this->createNotFoundException('Unable to find Job entity.');
  }

  $editForm   = $this->createForm(new JobType(), $entity);
  $deleteForm = $this->createDeleteForm($token);

  $request = $this->getRequest();

  $editForm->bindRequest($request);

  if ($editForm->isValid()) {
    $em->persist($entity);
    $em->flush();

    return $this->redirect($this->generateUrl('ens_job_edit', array('token' => $token)));
  }

  return $this->render('EnsJobeetBundle:Job:edit.html.twig', array(
    'entity'      => $entity,
    'edit_form'   => $editForm->createView(),
    'delete_form' => $deleteForm->createView(),
  ));
}

public function deleteAction($token)
{
  $form = $this->createDeleteForm($token);
  $request = $this->getRequest();

  $form->bindRequest($request);

  if ($form->isValid()) {
    $em = $this->getDoctrine()->getEntityManager();
    $entity = $em->getRepository('EnsJobeetBundle:Job')->findOneByToken($token);

    if (!$entity) {
      throw $this->createNotFoundException('Unable to find Job entity.');
    }

    $em->remove($entity);
    $em->flush();
  }

  return $this->redirect($this->generateUrl('ens_job'));
}

private function createDeleteForm($token)
{
  return $this->createFormBuilder(array('token' => $token))
    ->add('token', 'hidden')
    ->getForm()
  ;
}

In the job show template show.html.twig, change the ens_job_edit route parameter:

<a href="{{ path('ens_job_edit', { 'token': entity.token }) }}">

Do the same for ens_job_update route in edit.html.twig job template:

<form action="{{ path('ens_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:

# src/End/JobeetBundle/Resources/config/routing/job.yml
# ...

ens_job_show:
    pattern:  /{company}/{location}/{id}/{position}
    defaults: { _controller: "EnsJobeetBundle:Job:show" }
    requirements:
        id:  \d+

ens_job_preview:
    pattern:  /{company}/{location}/{token}/{position}
    defaults: { _controller: "EnsJobeetBundle: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):

// src/Ens/JobeetBundle/Controller/JobController.php
// ...

public function previewAction($token)
{
  $em = $this->getDoctrine()->getEntityManager();

  $entity = $em->getRepository('EnsJobeetBundle:Job')->findOneByToken($token);

  if (!$entity) {
    throw $this->createNotFoundException('Unable to find Job entity.');
  }

  $deleteForm = $this->createDeleteForm($entity->getId());

  return $this->render('EnsJobeetBundle: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:

<!-- /src/Ens/JobeetBundle/Resources/views/Job/show.html.twig -->
<!-- ... -->

{% block content %}
  {% if app.request.get('token') %}
    {% include 'EnsJobeetBundle:Job:admin.html.twig' with {'job': entity} %}
  {% endif %}

  <!-- ... -->

{% endblock %}

Then, create the admin.html.twig template:

<!-- /src/Ens/JobeetBundle/Resources/views/Job/admin.html.twig -->

<div id="job_actions">
  <h3>Admin</h3>
  <ul>
    {% if not job.isActivated %}
      <li><a href="{{ path('ens_job_edit', { 'token': job.token }) }}">Edit</a></li>
      <li><a href="{{ path('ens_job_edit', { 'token': job.token }) }}">Publish</a></li>
    {% endif %}
    <li>
      <form action="{{ path('ens_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('ens_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:

// src/Ens/JobeetBundle/Entity/Job.php
// ...

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()
{
  // ...

  if ($form->isValid()) {
    // ...

    return $this->redirect($this->generateUrl('ens_job_preview', array(
      'company' => $entity->getCompanySlug(),
      'location' => $entity->getLocationSlug(),
      'token' => $entity->getToken(),
      'position' => $entity->getPositionSlug()
    )));
  }

  // ...
}

public function updateAction($token)
{
  // ...

  if ($editForm->isValid()) {
    // ...

    return $this->redirect($this->generateUrl('ens_job_preview', array(
      'company' => $entity->getCompanySlug(),
      'location' => $entity->getLocationSlug(),
      'token' => $entity->getToken(),
      'position' => $entity->getPositionSlug()
    )));
  }

  // ...
}

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:

# src/Ens/JobeetBundle/Resources/config/routing/job.yml
# ...

ens_job_publish:
    pattern:  /{token}/publish
    defaults: { _controller: "EnsJobeetBundle: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):

<!-- src/Ens/JobeetBundle/Resources/views/job/admin.html.twig -->
<!-- ... -->

{% if not job.isActivated %}
  <li><a href="{{ path('ens_job_edit', { 'token': job.token }) }}">Edit</a></li>
  <li>
    <form action="{{ path('ens_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:

// src/Ens/JobeetBundle/Controller/JobController.php
// ...

public function previewAction($token)
{
  // ...

  $deleteForm = $this->createDeleteForm($entity->getId());
  $publishForm = $this->createPublishForm($entity->getToken());

  return $this->render('EnsJobeetBundle:Job:show.html.twig', array(
    'entity'      => $entity,
    'delete_form' => $deleteForm->createView(),
    'publish_form' => $publishForm->createView(),
  ));
}

public function publishAction($token)
{
  $form = $this->createPublishForm($token);
  $request = $this->getRequest();

  $form->bindRequest($request);

  if ($form->isValid()) {
    $em = $this->getDoctrine()->getEntityManager();
    $entity = $em->getRepository('EnsJobeetBundle:Job')->findOneByToken($token);

    if (!$entity) {
      throw $this->createNotFoundException('Unable to find Job entity.');
    }

    $entity->publish();
    $em->persist($entity);
    $em->flush();

    $this->get('session')->setFlash('notice', 'Your job is now online for 30 days.');
  }

  return $this->redirect($this->generateUrl('ens_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:

// src/Ens/JobeetBundle/Entity/Job.php
// ...

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:

// src Ens/JobeetBundle/Repository/JobRepository.php

namespace Ens\JobeetBundle\Repository;
use Doctrine\ORM\EntityRepository;

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 (\Doctrine\Orm\NoResultException $e) {
      $job = null;
    }

    return $job;
  }
}

The same for CategoryRepository getWithJobs() method:

// src/Ens/JobeetBundle/Repository/CategoryRepository.php

namespace Ens\JobeetBundle\Repository;
use Doctrine\ORM\EntityRepository;

class CategoryRepository extends EntityRepository
{
  public function getWithJobs()
  {
    $query = $this->getEntityManager()->createQuery(
      'SELECT c FROM EnsJobeetBundle: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.

Symfony2 Jobeet Day 11: Testing your Forms

Symfony2 Jobeet Day 9: The Functional Tests

37 thoughts on “Symfony2 Jobeet Day 10: The Forms”

  1. Great Tutorial! Also if i know the Symfony2 Basics i like reading this because you always find out some new points that you didn’t know so far. Thank you

  2. This is a great tutorial, what I am looking for in interest is how you will handle the different user types

    1. I will follow the original Jobeet tutorial for Symfony so in Day 13 we will take a look at the authentication and authorization process for Symfony2.

  3. Hi, I’m get to the part where I change in edit.hmtl.twig the following lines:

    The first one I find and replace but the other (delete) I can’t find in that page, did I forget to add some code?

  4. form action=”{{ path(‘ens_job_update’, { ‘token’: entity.token }) }}” method=”post” {{ form_enctype(edit_form) }}

    form action=”{{ path(‘ens_job_delete’, { ‘token’: entity.token }) }}” method=”post”

    This are the lines.

  5. Also I put the form on the admin.html.twig for the publish but I have the errro that publish_form doesn’t exist. :s What could it be?

    1. I found the problem, in your tutorial you say do add the following lines in the preview action:
      $deleteForm = $this->createDeleteForm($entity->getId());
      $publishForm = $this->createDeleteForm($entity->getToken());

      but you forget to say to add also in the return this:

      ‘publish_form’ => $publishForm->createView(),

      I already add and now its working.

      1. And here
        $publishForm=$this->createDeleteForm($entity->getToken());
        should be

        $publishForm=$this->createPublishForm($entity->getToken());

        right?

  6. Well, typo are limited to “/src/” -> “src/” (not big).

    I know the probability is low, but I think check if the token generated is unique and remove the edit possibility (when show) are required for real use.

    good job.

  7. This is freaking awesome. I think part of it is actually better than the original.

    I have one minor issue that I think is a caching thing.

    If I try to upload a new image on a Job that has already been created it does not show up in the preview if I only change the image.

    If I change ANY other field as well as the image then the preview image shows up correctly.

    I will try to troubleshoot it and if i find a fix add it to the comments but has anyone else had and fixed this issue?

    AWESOME Tutorial Series thanks a bazzillion for doing it.

      1. the change tracking thing did not work. I did however find a solution.

        “file” is not handled by doctrine so if you only change the file and nothing else none of the life cycle callbacks work.

        The solution is to trigger $entity-preUpload() manually before the $em-persist($entity);

        In jobController.php

        public function updateAction($token) {…


        if ($editForm->isValid()) {
        // manually call preUpload()
        $entity->preUpload();
        $em->persist($entity);
        $em->flush();

        }

        and in Job.php

        /**
        * @ORM\PrePersist
        */
        public function preUpload()
        {
        // added updated at field to trigger doctrine callbacks
        $this->setUpdatedAt(new \DateTime(‘now’));

        if (null !== $this->file) {
        // do whatever you want to generate a unique name
        $this->logo = uniqid().’.’.$this->file->guessExtension();
        }
        }

        That’s a combination of what I found. I’ve tested it and it works great. I hope it helps someone.

        1. $entity->preUpload(); i am use but don’t remove old image.some change $entity->Upload(); use and full working this.

  8. Hi there,

    great tutorial overall, thanks for doing this. One thing I’m wondering, looking at the Doctrine Repository, we already define the business logic around active non-expired jobs 3x. Elsewhere we are always striving to avoid duplication, yet here we have to update 3 methods to introduce the filter on the active field.. is there no way to encapsulate query modifications to make this business logic more re-usable as well?

  9. In the updateAction method under the true valid form condition I see:
    return $this->redirect($this->generateUrl(‘ens_job_edit’, array(‘token’ => $token)));

    shouldn’t it be in this way?

    return $this->redirect($this->generateUrl(‘ens_job_preview’, array(‘token’ => $token)));

    Infact in this way I remain jailed in the edit form.
    Thank you

  10. Salut,

    I followed the tutorial until the token part, but when i try to access the new\job page i get this error:
    Fatal error: Class ‘Ens\JobeetBundle\Form\Job’ not found in C:\jobeet\src\Ens\JobeetBundle\Form\JobType.php on line 13

    I am using symfony 2.1 and i don’t know what to do, i could not find a solution on google.

    Thanks

    1. The line is this:

      $builder->add(‘type’, ‘choice’, array(‘choices’ => Job::getTypes(), ‘expanded’ => true));

      and I think that it try to run the Job::getTypes from the same location, and dosen’t know how to find it..

  11. Hey, my admin bar is not displayed, have any idea if on symfony 2.1 the app.request.get() was modified?

    or why it is not displayed?

    1. Link wrong. To show the panel to dial url:
      /app_dev.php/job/company-100/paris-france/job_100/web-developer

      Instead of ID must be entered TOKEN
      🙂

  12. Hello,
    Thank you for a nice guide.

    I’am traying to edit a Job … web/app_dev.php/job/job_100/edit
    but I have this error:

    [Semantical Error] The annotation “@Doctrine\ORM\Mapping\postPersist” in method Ens\JobeetBundle\Entity\Job::upload() does not exist, or could not be auto-loaded.
    500 Internal Server Error – AnnotationException

    this is my Job.php file:
    file) {
    return;
    }

    I use “symfony/symfony”: “2.1.*”,
    “doctrine/orm”: “>=2.3.0”,
    “doctrine/doctrine-bundle”: “1.0.*”,

    Probably I need to register ORM Annotations, but how can I do this?

    Can you help me?

    Thanks.

  13. When I try to add a field job with the company logo
    I get this error
    Unable to guess the mime type as no guessers are available (Did you enable the php_fileinfo extension?)
    thinks

  14. Can you help me, please?
    public function preUpload()
    {
    if (null !== $this->file) {
    // do whatever you want to generate a unique name
    $this->logo = uniqid() . ‘.’ . $this->file->guessExtension();
    }
    }
    triggers
    public function guess($path)
    {
    if (!is_file($path)) {
    throw new FileNotFoundException($path);
    }
    … and value of the $path == (string) /tmp/phpxOeU0W
    and I got FileNotFoundException

    thanks in advance!

    1. Fixed!

      In JobController I have replaced:
      $entity->file->move(__DIR__ . ‘/../../../../web/uploads/jobs’, $entity->file->getClientOriginalName());
      with
      copy($entity->file->getPathname(), __DIR__ . ‘/../../../../web/uploads/jobs/’. $entity->file->getClientOriginalName());
      because in preUpload and upload lifecycleCallbacks $this->file->guessExtension() and $this->file->move($this->getUploadRootDir(), $this->logo) trigger exceptions as file was moved before (in Controller) and currently does not exist in /tmp directory.
      I wonder how using move() for the same file twice works for others ?
      Please, respond if i mesunderstood smt.

      Great tutorial – thanks for doing it, please keep going!

  15. FatalErrorException: Compile Error: Declaration of Ens\JobeetBundle\Form\JobType::buildForm() must be compatible with that of Symfony\Component\Form\FormTypeInterface::buildForm() in D:\Git_repos\jobeet_symfony\src\Ens\JobeetBundle\Form\JobType.php line 29
    please help

    1. Hi, I had same problem. So I changed file JobType.php to this and it’s working:

      add(‘category’);
      $builder->add(‘type’, ‘choice’, array(‘choices’ => Job::getTypes(), ‘expanded’ => true));
      $builder->add(‘company’);
      $builder->add(‘url’);
      $builder->add(‘logo’, null, array(‘label’ => ‘Company logo’));
      $builder->add(‘how_to_apply’, null, array(‘label’ => ‘How to apply?’));
      $builder->add(‘is_public’, null, array(‘label’ => ‘Public?’));
      $builder->add(‘position’);
      $builder->add(‘location’);
      $builder->add(‘description’);
      $builder->add(‘token’);
      $builder->add(’email’);
      }

      public function setDefaultOptions(OptionsResolverInterface $resolver)
      {
      $resolver->setDefaults(array(
      ‘data_class’ => ‘Ens\JobeetBundle\Entity\Job’
      ));
      }
      public function getName()
      {
      return ‘ens_jobeetbundle_jobtype’;
      }
      }

      1. … again …

        namespace Ens\JobeetBundle\Form;

        use Symfony\Component\Form\AbstractType;
        use Symfony\Component\Form\FormBuilderInterface;
        use Symfony\Component\OptionsResolver\OptionsResolverInterface;
        use Ens\JobeetBundle\Entity\Job;

        class JobType extends AbstractType
        {
        public function buildForm(FormBuilderInterface $builder, array $options)
        {
        $builder->add(‘category’);
        $builder->add(‘type’, ‘choice’, array(‘choices’ => Job::getTypes(), ‘expanded’ => true));
        $builder->add(‘company’);
        $builder->add(‘url’);
        $builder->add(‘logo’, null, array(‘label’ => ‘Company logo’));
        $builder->add(‘how_to_apply’, null, array(‘label’ => ‘How to apply?’));
        $builder->add(‘is_public’, null, array(‘label’ => ‘Public?’));
        $builder->add(‘position’);
        $builder->add(‘location’);
        $builder->add(‘description’);
        $builder->add(‘token’);
        $builder->add(’email’);
        }

        public function setDefaultOptions(OptionsResolverInterface $resolver)
        {
        $resolver->setDefaults(array(
        ‘data_class’ => ‘Ens\JobeetBundle\Entity\Job’
        ));
        }
        public function getName()
        {
        return ‘ens_jobeetbundle_jobtype’;
        }
        }

  16. Thanks for this great tutorial!

    I’m stuck on form validation, regardless wich value i put on fields, none of the fields are validated and job is saved into database…

    I think symfony2 is ignoring my /Ens/JobeetBundle/Resources/config/validation.yml…

    Do I need to activate it somewhere?
    Can you help me?

    Thanks in advance…

    PD: I’m using symfony 2.2.1

Leave a Reply

Your email address will not be published. Required fields are marked *