Symfony Form: настраиваем страницу обратной связи

Чтобы сделать страницу обратной связи — нам нужна форма. Это, как правило, тег form, внутри которого содержатся различные поля, в которые пользователь вводит данные и кнопка «Отправить», по которой происходит событие submit, отправляющее форму. Можно бы было сделать это посредством html тега <form>, но Symfony имеет в своём составе свои, защищённые и полезные формы, с которыми стоит разобраться.

Сейчас я предлагаю тебе разобрать официальный ман и параллельно сделать небольшую и приятную, адаптивную страничку обратной связи, куда пользователи смогут писать предложения, пожелания или замечания.

Так как мы устанавливали Symfony через skeleton webproject, отдельно нам докачивать ничего не надо. Поэтому сразу создадим сущность, куда будет падать информация из формы. Я думаю, что пользователь будет должен оставить свой email, своё имя и, собственно, текст сообщения. Создадим класс UserFeedBack.php в директории Entity:

<?php
namespace App\Entity;
/**
 * @ORM\Entity
* @ORM\Table(name="user_feedback")
 */
class UserFeedBack
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
    */
    protected $id;
    /**
     * @return mixed
     */
    public function getId()
    {
        return $this->id;
    }
}

Я добавлю аннотацию @ORM\Entity, которая говорит говорит Symfony, что это — сущность, и аннотацию @ORM\Table с параметром name, которая указывает, как назвать таблицу. Помимо этого я скопировал из сущности User поле id со всеми аннотациями и его get’ер.

Теперь прописываю поля:

<?php
namespace App\Entity;
/**
 * @ORM\Entity
* @ORM\Table(name="user_feedback")
 */
class UserFeedBack
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
    */
    protected $id;
    /**
     * @return mixed
     */
    public function getId()
    {
        return $this->id;
    }
    private $email;
    private $username;
    private $text;
    private $datetime;
}

В поле datetime будет лежать время обращения. Чуть попозже мы сделаем так, чтобы оно заполнялось автоматически, через конструктор. Теперь по традиции нажимаем Alt + Insert -> Orm Annotation -> выделаем все поля -> Enter. Получается так:

<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
/**
 * @ORM\Entity
* @ORM\Table(name="user_feedback")
 */
class UserFeedBack
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
    */
    protected $id;
     /**
     * @ORM\Column(type="string")
     */private $email;
    /**
     * @ORM\Column(type="string")
     */
     private $username;
    /**
     * @ORM\Column(type="string")
     */
     private $text;
    /**
     * @ORM\Column(type="string")
     */
     private $datetime;
     /**
     * @return mixed
     */
    public function getId()
    {
        return $this->id;
    }
}

Руками измени type у datetime на datetime, а у text на text. Теперь нажимаем Alt + Insert и генерируем get’еры и set’еры. После генерации set’ер для поля datetime можно удалить, так как нигде, кроме конструктора, мы его инициализировать не будем. Получилась полноценная сущность:

<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
/**
 * @ORM\Entity
* @ORM\Table(name="user_feedback")
 */
class UserFeedBack
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
    */
    protected $id;
    /**
     * @ORM\Column(type="string")
     */private $email;
    /**
     * @ORM\Column(type="string")
     */
private $username;
    /**
     * @ORM\Column(type="text")
     */
private $text;
    /**
     * @ORM\Column(type="datetime")
     */
private $datetime;
    /**
     * @return mixed
     */
public function getEmail()
    {
        return $this->email;
    }
    /**
     * @param mixed $email
     */
public function setEmail($email)
    {
        $this->email = $email;
    }
    /**
     * @return mixed
     */
public function getUsername()
    {
        return $this->username;
    }
    /**
     * @param mixed $username
     */
public function setUsername($username)
    {
        $this->username = $username;
    }
    /**
     * @return mixed
     */
public function getText()
    {
        return $this->text;
    }
    /**
     * @param mixed $text
     */
public function setText($text)
    {
        $this->text = $text;
    }
    /**
     * @return mixed
     */
public function getDatetime()
    {
        return $this->datetime;
    }

    public function getId()
    {
        return $this->id;
    }
}

Теперь создадим конструктор. Конструктор для сущности выглядит немного иначе, нежели конструктор для сервиса. Введи два символа нижнего подчёркивания подряд: __ и IDE подскажет тебе, что можно сгенерировать:

Нажимаем Tab

В сгенерированном конструкторе нужно задать дату и время, когда пользователь добавил сообщение:

public function __construct()
{
    $this->datetime = new \DateTime();
}

Теперь давай внесём этот класс в конфиг config\packages\easy_admin.yaml:

easy_admin:
  entities:
  - App\Entity\User
  - App\Entity\UserFeedBack

И обновим схему:

php bin/console doctrine:schema:update —force

Теперь переходим в класс с контроллерами и создаём новый, в котором уже будем создавать форму:

/**
 * @Route("/feedback", name="feedback")
 */
public function feedback()
{
}

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

/**
 * @Route("/feedback", name="feedback")
 */
public function feedback()
{
    $feedback = new UserFeedBack();
    $form = $this->createFormBuilder($feedback)
        ->add('email', EmailType::class)
        ->add('username', TextType::class)
        ->add('text', TextType::class)
        ->add('save', SubmitType::class, array('label' => 'Отправить'))
        ->getForm();
    return $this->render('feedback.html.twig', array(
        'form' => $form->createView(),
    ));
}

Теперь создай файл feedback.html.twig в директории templates и в нём пропиши:

{{ form_start(form) }}
{{ form_widget(form) }}
{{ form_end(form) }}

Если перейдём на страницу /feedback, то увидим:

Исходя из документации, метод createFormBuilder является методом, который извлекает «фабрику» по созданию форм из компонента Symfony Form. В метод передаётся соответствующий объект класса, а затем с помощью вызова метода ->add добавляются поля, которые будут отображены в шаблоне и сопоставлены с соответствующими полями объекта, который мы передали. Как ты видишь, справа от каждого поля идёт соответствующий ему тип данных. Чтобы не придумывать велосипеды, добавь в закладки подраздел этого мана, где эти типы перечислены и пользуйся:)

Что происходит в шаблоне? Вот что нам говорят:

  • form_start(form) — отображает начальный тег формы, включая правильный атрибут encrypt, если будет использована загрузка файлов
  • form_widget(form) — отображает все поля, включая сам элемент поля, метку и сообщения об ошибках для этого поля
  • form_end(form) — отображает закрывающий тег формы и все поля, которые мы записали сами (насколько я понимаю — разметку которых мы могли бы прописать ручками в шаблоне). Это полезно для скрытых полей (type=hidden) и использования автоматической защиты CSRF (о ней мы ещё поговорим в последующем).

К слову говоря, по-умолчанию форма отправляет запрос на тот же маршрут, с которого передавалась сама форма, т.е. в тот же контроллер.   Вроде бы всё пока понятно и хорошо, да? Только вот если ты попробуешь отправить данные, то в сущность они не запишутся, потому что мы это нигде не прописали. Помимо этого — форма не валидируется и не проверяется на корректность сам запрос. Кастомизируем под нашу задачу следующий блок кода из мана:

/**
 * @Route("/feedback", name="feedback")
 */
public function feedback(Request $request)
{
    $feedback = new UserFeedBack();
    $form = $this->createFormBuilder($feedback)
        ->add('email', EmailType::class)
        ->add('username', TextType::class)
        ->add('text', TextType::class)
        ->add('save', SubmitType::class, array('label' => 'Отправить'))
        ->getForm();
    $form->handleRequest($request);
    if ($form->isSubmitted() && $form->isValid()) {
        $feedback = $form->getData();
        $entityManager = $this->getDoctrine()->getManager();
        $entityManager->persist($feedback);
        $entityManager->flush();
        return $this->redirectToRoute('success');
    }
    return $this->render('feedback.html.twig', array(
        'form' => $form->createView(),
        'title' => 'Отображение формы'
));
}

Так как запрос летит в тот же контроллер — нам нужно добавить параметр типа Request из HTTP-Foundation. Затем, после формирования формы добавляется обработчик запроса:

$form->handleRequest($request);

После чего идёт проверка, успешно ли отработало событие submit и валиден ли объект $feedback (именно объект, а не форма — это важно) :

if ($form->isSubmitted() && $form->isValid()) {

В случае, если всё хорошо — вызываем EntityManager и сохраняем сущность. Все поля, которые заполнил юзер, приходят как в параметрах запроса, так и обновляются в объект сущности, который мы передали в билдер. После чего идёт редирект на небольшой контроллер:

return $this->redirectToRoute('success');

Сам контроллер:

/**
 * @Route("/success", name="success")
 */
public function successForm()
{
    return $this->render("success_form.html.twig", ['title' => 'Успех!'] );
}

И код шаблона:

{% extends 'base.html.twig' %}
{% block content %}
    <div class="container">
        <div class="row">
            <div class="col-lg-4 d-flex justify-position-center">
                Всё отлично, форма успешно валидирована! Проверяй сущность, друг.
            </div>
        </div>
    </div>
{% endblock %}

Теперь отправь форму и дальше, после редиректа, переходи в админку:

Как видишь — объект  сущности успешно добавлен. И дата заполняется автоматически ?

Помимо этого, я унаследовал шаблон feedback.html.twig от нашего базового шаблона:

{% extends 'base.html.twig' %}
{% block content %}
    <div class="container">
        <div class="row">
            <div class="col-lg-4 d-flex justify-position-center">
                {{ form_start(form) }}
                {{ form_widget(form) }}
                {{ form_end(form) }}
            </div>
        </div>
    </div>
{% endblock %}

Как я написал выше — условие isValid проверяет, валиден ли объект. Валидность данных, который вбил пользователь — мы не проверяли. Давай сделаем это.

Открывай класс сущности UserFeedBack и добавляй следующие аннотации:

/**
 * @ORM\Column(type="string")
 * @Assert\NotBlank()
 */
private $email;
/**
 * @ORM\Column(type="string")
 * @Assert\NotBlank()
 */
private $username;
/**
 * @ORM\Column(type="text")
 * @Assert\NotBlank()
 */
private $text;

И подключи следующий файл:

use Symfony\Component\Validator\Constraints as Assert;

Аннотация @Assert задаёт полям различные условия. В нашем случае @Assert\NotBlank() говорит доктрине, что поле не может быть пустым и пользователь должен его заполнить. Теперь, если пользователь не введёт данные в соответствующие поля — увидит ошибку:

Про валидацию и про то, что она умеет ещё — поговорим в следующий раз.