Symfony 2 Joboard : Формы

На каждом сайте в том или ином виде присутствуют формы, от простой формы контакта до сложных со множеством полей. Создание форм — не простая задача для разработчика, для начала надо написать HTML форму, реализовать проверку введенных данных, обработку данных перед сохранением в БД, отображение ошибок, восстановление полей в случае ошибок и т.д.

В третьей части мы использовали команду doctrine:generate:crud для создания простого CRUD контроллера сущности Job. Также мы получили сгенерированный файл формы src/App/JoboardBundle/Form/JobType.php.

Настройка формы вакансии

Форма вакансии — Job — отличный пример для индивидуальной настройки формы. Разберёмся в этом шаг за шагом. Первое — добавим адрес для ссылки Добавить вакансию в шаблоне app/Resources/views/base.html.twig, чтобы мы могли перейти и посмотреть на форму создания вакансии.

<a href="{{ path('app_job_new') }}">Добавить вакансию</a>

Потом, изменим параметры маршрута app_job_show в методе createAction контроллера JobControllerтаким образом, чтобы маршрут редиректа (метод $this->redirect) использовал актуальный маршрут app_job_show, который мы создали в пятой части.

<?php
// ...
public function createAction(Request $request)
{
    $entity  = new Job();
    $form = $this->createForm(new JobType(), $entity);
    $form->handleRequest($request);
    if ($form->isValid()) {
        $em = $this->getDoctrine()->getManager();
        $em->persist($entity);
        $em->flush();
        return $this->redirect($this->generateUrl('app_job_show', array(
            'company' => $entity->getCompanySlug(),
            'location' => $entity->getLocationSlug(),
            'id' => $entity->getId(),
            'position' => $entity->getPositionSlug()
        )));
    }
    return $this->render('AppJoboardBundle:Job:new.html.twig', array(
        'entity' => $entity,
        'form'   => $form->createView(),
    ));
}
// ...

По умолчанию Doctrine создала поля формы для каждого параметра модели Job. Но некоторые из них не должны быть изменяемыми конечным пользователем, например поля: expires_at, created_at, updated_at, is_activated. Удалим лишние поля, которые не должны отображаться в форме. Для этого изменим форму Job следующим образом:

<?php
# src/App/JoboardBundle/Form/JobType.php
namespace App\JoboardBundle\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;
class JobType extends AbstractType
{
        /**
     * @param FormBuilderInterface $builder
     * @param array $options
     */
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('type')
            ->add('company')
            ->add('logo')
            ->add('url')
            ->add('position')
            ->add('location')
            ->add('description')
            ->add('how_to_apply')
            ->add('token')
            ->add('is_public')
            ->add('email')
            ->add('category')
        ;
    }
    /**
     * @param OptionsResolverInterface $resolver
     */
    public function setDefaultOptions(OptionsResolverInterface $resolver)
    {
        $resolver->setDefaults(array(
            'data_class' => 'App\JoboardBundle\Entity\Job'
        ));
    }
    /**
     * @return string
     */
    public function getName()
    {
        return 'app_joboardbundle_job';
    }
}

По хорошему каждая форма в Symfony2 должна описываться в особом классе, который наследуется от Symfony\Component\Form\AbstractType. Обычно такие классы размещаются в каталоге Form в бандле. В нашем случае класс формы находится в каталоге src/App/JoboardBundle/Form/. Класс JobType имеет три метода:

  • buildForm — конструктор формы, тут добавляются поля, которые будут отображаться в html форме. Для каждого отдельного поля можно задать тип и дополнительные настройки, которые будут описаны ниже.
  • setDefaultOptions — устанавливает параметры по умолчанию, в классе JobType используется только один параметр data_class, он означает, что форма будет работать с моделью Job.
  • getName — название, которое будет отображаться в полях html формы, т.е. поле html формы будет выглядеть так: . Такой подход позволяет сделать возможным обработку нескольких форм на одной странице.

В Symfony2 проверка полей применяется на уровне объекта Job. Другими словами, вопрос стоит не в том корректна ли форма, а в том соответствует ли объект Job тем правилам, которые мы задали после применения значений из формы (когда данные из формы автоматически переносятся в объект модели). Чтобы описать валидаторы для конкретной модели необходимо создать файл validation.yml в директории Resources/config нашего бандла и вставить в него следующий код:

# src/App/JoboardBundle/Resources/config/validation.yml
App\JoboardBundle\Entity\Job:
    properties:
        email:
            - NotBlank: ~
            - Email: ~

Даже если поле type в БД с типом varchar, то всё равно мы можем ограничить это поле только значениями: full-time, part-time или freelance.

<?php
# src/App/JoboardBundle/Form/JobType.php
// ...
use App\JoboardBundle\Entity\Job;
class JobType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('type', 'choice', [
                'choices'  => Job::getTypes(),
                'expanded' => true
            ])
            // ...
    }
    // ...
}

Для этого добавьте следующие методы в класс Job:

<?php
# src/App/JoboardBundle/Entity/Job.php
     // ...
    public static function getTypes()
    {
        return [
            'full-time' => 'Полный рабочий день',
            'part-time' => 'Неполный рабочий день',
            'freelance' => 'Фриланс'
        ];
    }
    public static function getTypeValues()
    {
        return array_keys(self::getTypes());
    }
    // ...

Вторым параметром в методе add можно указать тип поля, в нашем случае это тип choice, который означает, что в результате в html форме будет отображёно поле с тегом select, radio, или checkbox со списком возможных типов вакансии (по умолчанию отображается тег select). Третий параметр — это настройки для поля, в ключе choices задаётся массив значений, а expanded означает, что список значений будет отображён в виде радио кнопок (input type=radio). Метод getTypes() применяется в формах для получения возможных типов для Job, а метод getTypesValues() — используется при проверке полей.

# src/App/JoboardBundle/Resources/config/validation.yml
App\JoboardBundle\Entity\Job:
    properties:
        type:
            - NotBlank: ~
            - Choice: { callback: getTypeValues }
        email:
            - NotBlank: ~
            - Email: ~

Symfony2 автоматически генерирует label (который так же используется в качестве тега при отрисовки формы) для каждого поля, а генерирует он его из названия поля модели. Но в настройках поля мы можем изменить это поведение, сделать это можно добавив параметр label:

<?php
# src/App/JoboardBundle/Form/JobType.php
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            // ...
           ->add('logo', null, ['label' => 'Лого компании'])
            // ...
            ->add('how_to_apply', null, ['label' => 'Как соискатель может подать свое резюме?'])
            // ...
            ->add('is_public', null, ['label' => 'Публичная?'])
            // ...
    }

Так же добавим правила для остальных полей:

# src/App/JoboardBundle/Resources/config/validation.yml
App\JoboardBundle\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: ~
        url:
            - Url: ~

Правило для поля url задает возможные значения такого типа: http://www.sitename.domain или https://www.sitename.domain. После изменения файла validation.yml требуется очистить кеш.

Загрузка файлов в Symfony2

Для загрузки файлов в форме нам понадобится поле virtual file. Для этого добавим новое свойство в класс Job:

<?php
# src/App/JoboardBundle/Entity/Job.php
    // ...
    public $file;
    // ...

Теперь следует заменить поле logo на file:

<?php
# src/App/JoboardBundle/Form/JobType.php
// ...
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            // ...
            ->add('file', 'file', ['label' => 'Лого компании', 'required' => false])
            // ...
    }
// ...

Заметка: ‘required’ => false — означает, что поле не обязательное, но только для html формы.

Укажем правило для нового поля, чтобы мы принимали только изображения:

# src/App/JoboardBundle/Resources/config/validation.yml
App\JoboardBundle\Entity\Job:
    properties:
        # ...
        file:
            - Image: ~

После отправки формы поле file будет типа UploadedFile. Этот класс содержит в себе методы для переноса файла в место постоянного хранения. Затем мы изменяем значение поля logo на имя загруженного файла.

<?php
# src/App/JoboardBundle/Controller/JobController.php
// ...
    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('app_job_show', array(
                'company' => $entity->getCompanySlug(),
                'location' => $entity->getLocationSlug(),
                'id' => $entity->getId(),
                'position' => $entity->getPositionSlug()
            )));
        }
        // ...
    }
// ...

Нам требуется создать директорию web/uploads/jobs/. Проверьте открыт ли доступ на запись на сервере (или вашем компьютере) для этого каталога. Хотя такой способ является работоспособным, лучше использовать Doctrine для загрузки файлов.

Сначала, измените класс Job как показано далее:

<?php
# src/App/JoboardBundle/Entity/Job.php
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;
    }
}

Поле logo должно хранить относительный путь файла и хранится в БД. Метод getAbsolutePath()возвращает абсолютный путь, а getWebPath() относительный путь, который можно сразу использовать в качестве ссылки на файл.

Объединим операции над БД и сохранением файла воедино, т.е. если возникает ошибка при сохранении данных, то вся операция отменяется. Для этого мы должны переместить файл сразу же после создания записи в БД. Этого можно добиться при помощи lifecycle callbacks. Как мы делали в третьей части, мы исправим файл Job.orm.yml и добавим параметры preUpload, upload и removeUpload:

# src/App/JoboardBundle/Resources/config/doctrine/Job.orm.yml
App\JoboardBundle\Entity\Job:
    # ...
    lifecycleCallbacks:
        prePersist: [ preUpload, setCreatedAtValue, setExpiresAtValue ]
        preUpdate: [ preUpload, setUpdatedAtValue ]
        postPersist: [ upload ]
        postUpdate: [ upload ]
        postRemove: [ removeUpload ]

Теперь выполним команду generate:entities для добавления новых методов в класс Job:

php app/console doctrine:generate:entities AppJoboardBundle

Изменим созданные методы в классе Job:

<?php
# src/App/JoboardBundle/Entity/Job.php
class Job
{
    // ...
    /**
     * @ORM\PrePersist
     */
    public function preUpload()
    {
        if (null !== $this->file) {
            // Генерируем уникальное имя для файла
            $this->logo = uniqid().'.'.$this->file->guessExtension();
        }
    }
    /**
     * @ORM\PostPersist
     */
    public function upload()
    {
        if (null === $this->file) {
            return;
        }
        // Перемещаем файл в наш каталог web/uploads/job
        $this->file->move($this->getUploadRootDir(), $this->logo);
        unset($this->file);
    }
    /**
     * @ORM\PostRemove
     */
    public function removeUpload()
    {
        if ($this->logo) {
            if ($file = $this->getAbsolutePath()) {
                unlink($file);
            }
        }
    }
}

Заметка: Если у вас появляется ошибка Unable to guess the mime type as no guessers are available (Did you enable the php_fileinfo extension?) , то необходимо установить расширение для php fileinfo, как установить смотрите здесь http://www.php.net/manual/ru/fileinfo.setup.php

Теперь класс выполняет все, что нам нужно: генерирует уникальное имя перед сохранением, перемещает файл после сохранения и удаляет файл при удалении сущности. Так как мы настроили автоматическое управление загружаемыми файлами, удалим старый код:

<?php
# src/App/JoboardBundle/Controller/JobController.php
// ...
    public function createAction(Request $request)
    {
       $entity = new Job();
        $form = $this->createForm(new JobType(), $entity);
        $form->handleRequest($request);
        if ($form->isValid()) {
            $em = $this->getDoctrine()->getManager();
            $em->persist($entity);
            $em->flush();
            return $this->redirect($this->generateUrl('app_job_show', array(
                'company' => $entity->getCompanySlug(),
                'location' => $entity->getLocationSlug(),
                'id' => $entity->getId(),
                'position' => $entity->getPositionSlug()
            )));
        }
        return $this->render('AppJoboardBundle:Job:new.html.twig', array(
            'entity' => $entity,
            'form'   => $form->createView(),
        ));
    }
    // ...

В этом же файле найдите метод createCreateForm и удалите из него строчку:

<?php
$form->add('submit', 'submit', array('label' => 'Create'));

Тоже самое сделайте для метода createEditForm:

<?php
$form->add('submit', 'submit', array('label' => 'Update'));

Позже эти кнопки мы сами добавим в шаблон.

Шаблон формы

Мы настроили форму под наши нужды, теперь отредактируем её отображение. Откройте файл src/App/JoboardBundle/Resources/views/Job/new.html.twig и исправьте:

{% extends '::base.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/appjoboard/css/job.css') }}" type="text/css" media="all" />
{% endblock %}
{% block content %}
<h1>Добавление вакансии</h1>
{{ form_start(form) }}
    <table id="job_form">
        <tbody>
            <tr>
                <th>{{ form_label(form.category) }}</th>
                <td>
                    {{ form_widget(form.category) }}
                    {{ form_errors(form.category) }}
                </td>
            </tr>
            <tr>
                <th>{{ form_label(form.type) }}</th>
                <td>
                    {{ form_widget(form.type) }}
                    {{ form_errors(form.type) }}
                </td>
            </tr>
            <tr>
                <th>{{ form_label(form.company) }}</th>
                <td>
                    {{ form_widget(form.company) }}
                    {{ form_errors(form.company) }}
                </td>
            </tr>
            <tr>
                <th>{{ form_label(form.file) }}</th>
                <td>
                    {{ form_widget(form.file) }}
                    {{ form_errors(form.file) }}
                </td>
            </tr>
            <tr>
                <th>{{ form_label(form.url) }}</th>
                <td>
                    {{ form_widget(form.url) }}
                    {{ form_errors(form.url) }}
                </td>
            </tr>
            <tr>
                <th>{{ form_label(form.position) }}</th>
                <td>
                    {{ form_widget(form.position) }}
                    {{ form_errors(form.position) }}
                </td>
            </tr>
            <tr>
                <th>{{ form_label(form.location) }}</th>
                <td>
                    {{ form_widget(form.location) }}
                    {{ form_errors(form.location) }}
                </td>
            </tr>
            <tr>
                <th>{{ form_label(form.description) }}</th>
                <td>
                    {{ form_widget(form.description) }}
                    {{ form_errors(form.description) }}
                </td>
            </tr>
            <tr>
                <th>{{ form_label(form.how_to_apply) }}</th>
                <td>
                    {{ form_widget(form.how_to_apply) }}
                    {{ form_errors(form.how_to_apply) }}
                </td>
            </tr>
            <tr>
                <th>{{ form_label(form.token) }}</th>
                <td>
                    {{ form_widget(form.token) }}
                    {{ form_errors(form.token) }}
                </td>
            </tr>
            <tr>
                <th>{{ form_label(form.is_public) }}</th>
                <td>
                    {{ form_widget(form.is_public) }}
                    {{ form_errors(form.is_public) }}
                    <br /> Может ли вакансия быть получена сайтами партнёрами.
                </td>
            </tr>
            <tr>
                <th>{{ form_label(form.email) }}</th>
                <td>
                    {{ form_widget(form.email) }}
                    {{ form_errors(form.email) }}
                </td>
            </tr>
        </tbody>
        <tfoot>
        <tr>
            <td>
                <input type="submit" value="Просмотр" />
            </td>
            <td>
                <input type="submit" value="Отправить" />
            </td>
        </tr>
        </tfoot>
    </table>
{{ form_end(form) }}
{% endblock %}

Показать форму можно при помощи одной строки кода.

{{ form(form) }}

В таком случае все поля автоматически генерируется вместе с лейблами и сообщениями об ошибках, если таковые есть. Этот метод очень прост, но он не поддается настройке. Но мы воспользуемся другим методом, так как нам надо полностью контролировать этот процесс (будем отрисовывать каждое поле отдельно). Также мы воспользуемся темами для форм, для настройки отображения ошибок. В документации по Symfony2 вы найдете исчерпывающую информацию на эту тему.

Теперь изменим файл src/App/JoboardBundle/Resources/views/Job/edit.html.twig:

{% extends '::base.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/appjoboard/css/job.css') }}" type="text/css" media="all" />
{% endblock %}
{% block content %}
    <h1>Редактирование вакансии</h1>
    {{ form_start(edit_form) }}
    <table id="job_form">
        <tbody>
        <tr>
            <th>{{ form_label(edit_form.category) }}</th>
            <td>
                {{ form_widget(edit_form.category) }}
                {{ form_errors(edit_form.category) }}
            </td>
        </tr>
        <tr>
            <th>{{ form_label(edit_form.type) }}</th>
            <td>
                {{ form_widget(edit_form.type) }}
                {{ form_errors(edit_form.type) }}
            </td>
        </tr>
        <tr>
            <th>{{ form_label(edit_form.company) }}</th>
            <td>
                {{ form_widget(edit_form.company) }}
                {{ form_errors(edit_form.company) }}
            </td>
        </tr>
        <tr>
            <th>{{ form_label(edit_form.file) }}</th>
            <td>
                {{ form_widget(edit_form.file) }}
                {{ form_errors(edit_form.file) }}
            </td>
        </tr>
        <tr>
            <th>{{ form_label(edit_form.url) }}</th>
            <td>
                {{ form_widget(edit_form.url) }}
                {{ form_errors(edit_form.url) }}
            </td>
        </tr>
        <tr>
            <th>{{ form_label(edit_form.position) }}</th>
            <td>
                {{ form_widget(edit_form.position) }}
                {{ form_errors(edit_form.position) }}
            </td>
        </tr>
        <tr>
            <th>{{ form_label(edit_form.location) }}</th>
            <td>
                {{ form_widget(edit_form.location) }}
                {{ form_errors(edit_form.location) }}
            </td>
        </tr>
        <tr>
            <th>{{ form_label(edit_form.description) }}</th>
            <td>
                {{ form_widget(edit_form.description) }}
                {{ form_errors(edit_form.description) }}
            </td>
        </tr>
        <tr>
            <th>{{ form_label(edit_form.how_to_apply) }}</th>
            <td>
                {{ form_widget(edit_form.how_to_apply) }}
                {{ form_errors(edit_form.how_to_apply) }}
            </td>
        </tr>
        <tr>
            <th>{{ form_label(edit_form.token) }}</th>
            <td>
                {{ form_widget(edit_form.token) }}
                {{ form_errors(edit_form.token) }}
            </td>
        </tr>
        <tr>
            <th>{{ form_label(edit_form.is_public) }}</th>
            <td>
                {{ form_widget(edit_form.is_public) }}
                {{ form_errors(edit_form.is_public) }}
                <br /> Может ли вакансия быть получена сайтами партнёрами.
            </td>
        </tr>
        <tr>
            <th>{{ form_label(edit_form.email) }}</th>
            <td>
                {{ form_widget(edit_form.email) }}
                {{ form_errors(edit_form.email) }}
            </td>
        </tr>
        </tbody>
        <tfoot>
        <tr>
            <td>
                <input type="submit" value="Просмотр" />
            </td>
            <td>
                <input type="submit" value="Сохранить" />
            </td>
        </tr>
        </tfoot>
    </table>
    {{ form_end(edit_form) }}
{% endblock %}

Действие формы

Итак, у нас есть класс формы и её шаблон. Настало время всё связать. Эта форма обрабатывается четырьмя методами из JobController:

  • newAction — показывает пустую форму при создании новой вакансии
  • createAction — обрабатывает форму и создаёт новую сущность в соответствии c введенными данными
  • editAction — показывает существующие данные
  • updateAction — обрабатывает форму и обновляет существующие данные

При открытии страницы /job/new метод createForm() создает экземпляр формы и передает его в шаблон (метод newAction). Когда пользователь отправляет форму (createAction) данные формы привязываются к объекту (handeRequest($request)) и срабатывает метод отвечающий за проверку данных. Как только данные привязаны, проверить форму можно методом isValid(), который возвращает true в случае успеха, а в противном случае мы получим false и покажем шаблон new.html.twig с уже введенными данными и ошибками.

Функционал редактирование вакансии очень похож на функционал создания вакансии. Существует только одно отличие — объект job передается в качестве второго аргумента методу createForm. Значения этого объекта буду использованы для значений полей формы в шаблоне. Кстати, такие значения (значения по умолчанию) можно указать и при создании новых сущностей. Для этого передадим созданный заранее объект job в форму, чтобы указать значение full-time:

<?php
// ...
    public function newAction()
    {
        $entity = new Job();
        $entity->setType('full-time');
        $form = $this->createForm(new JobType(), $entity, [
            'action' => $this->generateUrl('app_job_create'),
            'method' => 'POST',
        ]);
        return $this->render('AppJoboardBundle:Job:new.html.twig', array(
            'entity' => $entity,
            'form'   => $form->createView(),
        ));
    }
    // ...

Защита формы с помощью токена (token)

На данном этапе мы должны иметь полностью рабочую форму. Пользователь самостоятельно вводит ключ для вакансии. Но он должен автоматически генерироваться при создании новой вакансии, так как мы не хотим заставлять пользователя создавать уникальный ключ. Добавьте метод setTokenValue в lifecycleCallback prePersist в файле src/App/JoboardBundle/Resources/config/doctrine/Job.orm.yml:

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

Обновим классы сущностей:

php app/console doctrine:generate:entities AppJoboardBundle

Добавьте логику генерации нового ключа перед сохранением вакансии:

<?php
# src/App/JoboardBundle/Entity/Job.php
    // ...
    public function setTokenValue()
    {
        if(!$this->getToken()) {
            $this->token = sha1($this->getEmail().rand(11111, 99999));
        }
    }
    // ...

Удаляем поле token из формы:

<?php
# src/App/JoboardBundle/Form/JobType.php
// ...
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('type', 'choice', [
                'choices'  => Job::getTypes(),
                'expanded' => true
            ])
            ->add('company')
            ->add('file', 'file', ['label' => 'Лого компании', 'required' => false])
            ->add('url')
            ->add('position')
            ->add('location')
            ->add('description')
            ->add('how_to_apply', null, ['label' => 'Как соискатель может подать свое резюме?'])
            ->add('is_public', null, ['label' => 'Публичная?'])
            ->add('email')
            ->add('category')
            ;
    }
// ...

И из шаблонов new.html.twig и edit.html.twig:

<!-- ... -->
<tr>
    <th>{{ form_label(form.token) }}</th>
    <td>
        {{ form_widget(form.token) }}
        {{ form_errors(form.token) }}
    </td>
</tr>
<!-- ... -->
<!-- ... -->
<tr>
    <th>{{ form_label(edit_form.token) }}</th>
    <td>
        {{ form(edit_form.token) }}
        {{ form_errors(edit_form.token) }}
    </td>
</tr>
<!-- ... →

А также из файла validation.yml:

# ...
    # ...
    token:
        - NotBlank: ~

Если вы помните во второй части мы определили, что пользователь может править только ту вакансию, от которой у него есть токен. Сейчас довольно просто править и удалять вакансии зная только URL адрес. Так получилось потому что URL адрес для редактирования выглядит следующим образом — /job/ID/edit, где ID — ключ вакансии в БД.

Исправим маршруты, чтобы они соответствовали вакансии, откройте файл src/App/JoboardBundle/Resources/config/routing/job.yml и измените:

# ...
app_job_edit:
    pattern:  /{token}/edit
    defaults: { _controller: "AppJoboardBundle:Job:edit" }
app_job_update:
    pattern:  /{token}/update
    defaults: { _controller: "AppJoboardBundle:Job:update" }
    requirements: { _method: post|put }
app_job_delete:
    pattern:  /{token}/delete
    defaults: { _controller: "AppJoboardBundle:Job:delete" }
    requirements: { _method: post|delete }

Теперь отредактируем JobController, чтобы тот использовал наш токен:

<?php
# src/App/JoboardBundle/Controller/JobController.php
// ...
class JobController extends Controller
{
    // ...
    public function editAction($token)
    {
       $em = $this->getDoctrine()->getManager();
        $entity = $em->getRepository('AppJoboardBundle:Job')->findOneByToken($token);
        if (!$entity) {
            throw $this->createNotFoundException('Такой вакансии не существует.');
        }
        $editForm = $this->createForm(new JobType(), $entity, [
            'action' => $this->generateUrl('app_job_update', ['token' => $token]),
            'method' => 'PUT',
        ]);
        $deleteForm = $this->createDeleteForm($token);
        return $this->render('AppJoboardBundle: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('AppJoboardBundle:Job')->findOneByToken($token);
        if (!$entity) {
            throw $this->createNotFoundException('Такой вакансии не существует.');
        }
        $editForm   = $this->createForm(new JobType(), $entity, [
            'action' => $this->generateUrl('app_job_update', ['token' => $token]),
            'method' => 'PUT'
        ]);
        $deleteForm = $this->createDeleteForm($token);
        $editForm->handleRequest($request);
        if ($editForm->isValid()) {
            $em->persist($entity);
            $em->flush();
            return $this->redirect($this->generateUrl('app_job_edit', array('token' => $token)));
        }
        return $this->render('AppJoboardBundle: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->handleRequest($request);
        if ($form->isValid()) {
            $em = $this->getDoctrine()->getManager();
            $entity = $em->getRepository('AppJoboardBundle:Job')->findOneByToken($token);
            if (!$entity) {
               throw $this->createNotFoundException('Такой вакансии не существует.');
            }
            $em->remove($entity);
            $em->flush();
        }
        return $this->redirect($this->generateUrl('app_job'));
    }
    /**
     * Creates a form to delete a Job entity by id.
     *
     * @param mixed $id The entity id
     *
     * @return Symfony\Component\Form\Form The form
     */
    private function createDeleteForm($token)
    {
        return $this->createFormBuilder(['token' => $token])
            ->add('token', 'hidden')
            ->getForm()
            ;
    }
}

Добавим ссылку для редактирования вакансии app_job_edit в шаблоне show.html.twig, под изображением логотипа необходимо добавить блок:

<div class="job-controls">
    <a href="{{ path('app_job_edit', {'token': entity.token}) }}" class="text-success">Редактировать вакансию</a>
</div>

Теперь все маршруты в порядке за исключением app_show_user. Например, маршрут для редактирования вакансии выглядит следующим образом:

http://joboard.local/app_dev.php/job/TOKEN/edit

Страница предварительного просмотра

Эта страница соответствует странице отображения вакансии. Разница только в том, что страница предварительного просмотра должна использовать token вместо ID:

# src/App/JoboardBundle/Resources/config/routing/job.yml
# ...
app_job_show:
    pattern:  /{company}/{location}/{id}/{position}/
    defaults: { _controller: "AppJoboardBundle:Job:show" }
    requirements:
        id:  \d+
app_job_preview:
    pattern:  /{company}/{location}/{token}/{position}/
    defaults: { _controller: "AppJoboardBundle:Job:preview" }
    requirements:
        token:  \w+
# ...

Метод previewAction (разница с методом showAction в том, что для получения данных из БД мы используем не id вакансии, а её токен):

<?php
# src/App/JoboardBundle/Controller/JobController.php
// ...
    public function previewAction($token)
    {
        $em = $this->getDoctrine()->getManager();
        $entity = $em->getRepository('AppJoboardBundle:Job')->findOneByToken($token);
        if (!$entity) {
            throw $this->createNotFoundException('Такой вакансии не существует.');
        }
        $deleteForm = $this->createDeleteForm($entity->getId());
        return $this->render('AppJoboardBundle:Job:show.html.twig', array(
            'entity'      => $entity,
            'delete_form' => $deleteForm->createView(),
        ));
    }
// ...

Если пользователь пришел по адресу с токеном, то сверху страницы добавляем меню администратора . В начале файла show.html.twig добавьте шаблон административной панели и под изображением удалите ссылку на редактирование :

<!-- ... -->
{% block content %}
    <div class="admin-panel">
        {% if app.request.get('token') %}
            {% include 'AppJoboardBundle:Job:admin.html.twig' with {'job': entity} %}
        {% endif %}
    </div>
<!-- ... -->
{% endblock %}

Теперь создайте /src/App/JoboardBundle/Resources/views/Job/admin.html.twig:

<div id="job-actions" class="well">
    <h3>Администрирование</h3>
    <ul>
        {% if not job.isActivated %}
            <li><a href="{{ path('app_job_edit', { 'token': job.token }) }}">Редактировать</a></li>
            <li><a href="{{ path('app_job_edit', { 'token': job.token }) }}">Опубликовать</a></li>
        {% endif %}
        <li>
            <form action="{{ path('app_job_delete', { 'token': job.token }) }}" method="post">
                {{ form_widget(delete_form) }}
                <button type="submit" onclick="if(!confirm('Удалить вакансию?')) { return false; }" class="btn btn-danger">Удалить</button>
            </form>
        </li>
        {% if job.isActivated %}
            <li {% if job.expiresSoon %} class="expires_soon" {% endif %}>
                {% if job.isExpired %}
                    Завершённая
                {% else %}
                    Завершится через <strong>{{ job.getDaysBeforeExpires }}</strong> дней
                {% endif %}
                {% if job.expiresSoon %}
                    - <a href="">Продлить</a> ещё на 30 дней
                {% endif %}
            </li>
        {% else %}
            <li>
                [Запомните <a href="{{ url('app_job_preview', { 'token': job.token, 'company': job.companyslug, 'location': job.locationslug, 'position': job.positionslug }) }}">ссылку</a>, чтобы редактировать эту вакансию в будущем.]
            </li>
        {% endif %}
    </ul>
</div>

В нем довольно много кода, но он очень прост для понимания. Для облегчения задачи мы добавили много вспомогательных методов в модель Job:

<?php
# src/App/JoboardBundle/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);
    }
    // ...

Административная панель показывает доступные методы в зависимости от статуса вакансии. Теперь перенаправим пользователя на страницу предварительного просмотра после отработки методов create и update в JobController:

<?php
public function createAction(Request $request)
{
    // ...
    if ($form->isValid()) {
        // ... 
        return $this->redirect($this->generateUrl('app_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('app_job_preview', array(
            'company' => $entity->getCompanySlug(),
            'location' => $entity->getLocationSlug(),
            'token' => $entity->getToken(),
            'position' => $entity->getPositionSlug()
        )));
    }
    // ...
}

Активация и публикация вакансии

В предыдущем разделе присутствует ссылка на публикацию вакансии. Её требуется изменить, чтобы она указывала на соответствующий метод. Создадим новый маршрут:

# ...
app_job_publish:
    pattern:  /{token}/publish/
    defaults: { _controller: "AppJoboardBundle:Job:publish" }
    requirements: { _method: post, token: \w+ }

Изменим ссылку. (Воспользуемся формами и здесь, как в случае удаления вакансии, будем отсылать POST запрос):

<!-- ... -->
{% if not job.isActivated %}
           <li><a href="{{ path('app_job_edit', { 'token': job.token }) }}">Редактировать</a></li>
            <li>
                <form action="{{ path('app_job_publish', { 'token': job.token }) }}" method="post">
                    {{ form_widget(publish_form) }}
                    <button type="submit">Опубликовать</button>
                </form>
            </li>
{% endif %}
<!-- ... -->

Последний шаг — создание метода publishAction, формы для публикации и изменение метода previewAction:

<?php
# src/App/JoboardBundle/Controller/JobController.php
// ...
public function previewAction($token)
{
    // ...
    $deleteForm = $this->createDeleteForm($entity->getToken());
    $publishForm = $this->createPublishForm($entity->getToken());
    return $this->render('AppJoboardBundle: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->handleRequest($request);
        if ($form->isValid()) {
            $em = $this->getDoctrine()->getManager();
            $entity = $em->getRepository('AppJoboardBundle:Job')->findOneByToken($token);
            if (!$entity) {
                throw $this->createNotFoundException('Такой вакансии не существует.');
            }
            $entity->publish();
            $em->persist($entity);
            $em->flush();
            $this->get('session')->getFlashBag()->add('notice', 'Ваша вакансия опубликована на 30 дней.');
        }
        return $this->redirect($this->generateUrl('app_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()
            ;
    }
// ...

Метод publishAction() использует новый метод publish() модели Job, который выглядит следующим образом:

<?php
# src/App/JoboardBundle/Entity/Job.php
// ...
public function publish()
{
    $this->setIsActivated(true);
}
// ...

Теперь вы можете протестировать функцию предварительного просмотра в своем браузере. Но это ещё не все. Неактивированные вакансии должны быть недоступны, т.е. их не должно быть на домашней странице и мы не могли их открыть зная URL адрес. Надо изменить методы класса JobRepository:

<?php
# src/App/JoboardBundle/Repository/JobRepository.php
namespace App\JoboardBundle\Repository;
use Doctrine\ORM\EntityRepository;
/**
 * JobRepository
 *
 * This class was generated by the Doctrine ORM. Add your own custom
 * repository methods below.
 */
class JobRepository extends EntityRepository
{
    public function getActiveJobs($categoryId = 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($categoryId) {
            $qb->andWhere('j.category = :category_id')
                ->setParameter('category_id', $categoryId);
        }
        $query = $qb->getQuery();
        return $query->getResult();
    }
    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;
    }
    public function countActiveJobs($categoryId = 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($categoryId)
        {
            $qb->andWhere('j.category = :category_id')
                ->setParameter('category_id', $categoryId);
        }
        $query = $qb->getQuery();
        return $query->getSingleScalarResult();
    }
}

Тоже самое сделаем с методом getWithJobs() в классе CategoryRepository:

<?php
# src/App/JoboardBundle/Repository/CategoryRepository.php
namespace App\JoboardBundle\Repository;
use Doctrine\ORM\EntityRepository;
class CategoryRepository extends EntityRepository
{
    public function getWithJobs()
    {
        $query = $this->getEntityManager()->createQuery(
            'SELECT c FROM AppJoboardBundle: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();
    }
}

На этом все. Можете проверить в своем браузере. Все не активированные вакансии пропали с главной страницы, даже зная URL адрес вы не откроете их. Тем не менее зная ключ вакансии вы сможете её увидеть. А предварительный просмотр теперь показывает панель администратора.