Формы в Symfony: разбираем на живом примере
Форма редактирования профиля с загрузкой аватара и валидацией: как собрать FormType, обработать POST, показать ошибки и не наступить на грабли с CSRF и mapped-полями.

Возьмём одну задачу и доведём её до рабочего состояния: форма редактирования профиля пользователя с email, отображаемым именем и загрузкой аватара. Разберём, как Symyfony связывает форму с сущностью, почему одно поле нужно пометить mapped: false, откуда берётся защита от CSRF и как показать пользователю понятные ошибки валидации. Всё написано для Symfony 6.4/7.x с компонентом symfony/form и валидатором; синтаксис PHP-атрибутов работает начиная с 6.x.
Очевидный подход — собрать массив из $request->request->all() и руками присвоить поля сущности — на первой же итерации даёт три проблемы: нет CSRF-защиты, нет валидации, а загрузку файла придётся обрабатывать отдельно от остальных полей. Компонент форм закрывает всё это одним объектом, но взамен требует понимать, где проходит граница между данными формы и данными сущности.
Что связываем и с чем
Пусть есть сущность User с полями email, displayName и avatarPath (строка с путём к файлу). Аватар пользователь загружает как файл, но в базе хранится не файл, а путь. Значит, поле формы для файла не должно напрямую писаться в сущность — иначе Symfony попытается положить объект UploadedFile в строковое свойство. Это и есть тот случай, ради которого существует опция mapped: false.
Форма и сущность — не одно и то же. Поле mapped: false живёт только внутри формы: вы достаёте его значение вручную и решаете, что с ним делать. Всё остальное Symfony переносит в объект автоматически.
FormType
Логику формы выносят в отдельный класс — наследник AbstractFormType. Так форму можно переиспользовать и тестировать отдельно от контроллера.
<?php
// src/Form/ProfileFormType.php
namespace App\Form;
use App\Entity\User;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\File;
use Symfony\Component\Validator\Constraints\Image;
class ProfileFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('email', EmailType::class, [
'label' => 'Электронная почта',
])
->add('displayName', TextType::class, [
'label' => 'Отображаемое имя',
'required' => false,
])
// Файл не пишется в сущность напрямую — достаём вручную
->add('avatarFile', FileType::class, [
'label' => 'Аватар (JPG или PNG до 2 МБ)',
'mapped' => false,
'required' => false,
'constraints' => [
new Image([
'maxSize' => '2M',
'mimeTypes' => ['image/jpeg', 'image/png'],
'mimeTypesMessage' => 'Загрузите JPG или PNG.',
]),
],
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
// Форма знает, что работает с объектом User
$resolver->setDefaults([
'data_class' => User::class,
]);
}
}
Валидация email и displayName задаётся не здесь, а атрибутами #[Assert\...] на самой сущности User — форма подхватывает их автоматически, потому что видит data_class. Ограничения на файл живут в форме, так как поле mapped: false в сущности вообще не существует.
Контроллер: обработка POST
Здесь важен порядок вызовов. handleRequest() сам понимает, был ли POST, и заполняет форму данными запроса. После этого isSubmitted() && isValid() — единственно верная проверка: isValid() без isSubmitted() на первом GET-заходе вернёт непредсказуемый результат.
<?php
// src/Controller/ProfileController.php
namespace App\Controller;
use App\Entity\User;
use App\Form\ProfileFormType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\File\Exception\FileException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\String\Slugger\SluggerInterface;
class ProfileController extends AbstractController
{
#[Route('/profile/edit', name: 'profile_edit')]
public function edit(
Request $request,
EntityManagerInterface $em,
SluggerInterface $slugger,
): Response {
// В реальном коде берём текущего пользователя: $this->getUser()
$user = $em->getRepository(User::class)->find(1);
$form = $this->createForm(ProfileFormType::class, $user);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// Поле mapped: false достаём отдельно
$avatar = $form->get('avatarFile')->getData();
if ($avatar) {
$original = pathinfo($avatar->getClientOriginalName(), PATHINFO_FILENAME);
$safeName = $slugger->slug($original);
$newName = $safeName . '-' . uniqid() . '.' . $avatar->guessExtension();
try {
$avatar->move(
$this->getParameter('avatars_directory'),
$newName,
);
} catch (FileException $e) {
$this->addFlash('error', 'Не удалось сохранить файл.');
return $this->redirectToRoute('profile_edit');
}
$user->setAvatarPath($newName);
}
// email и displayName Symfony уже записал в $user сам
$em->flush();
$this->addFlash('success', 'Профиль обновлён.');
return $this->redirectToRoute('profile_edit');
}
return $this->render('profile/edit.html.twig', [
'form' => $form,
]);
}
}
Параметр avatars_directory задаётся в config/services.yaml — например через %kernel.project_dir%/public/uploads/avatars. Точный синтаксис параметров смотрите в разделе Service Container официальной документации, привязывать к нему выдуманное имя ключа не стоит.
Шаблон и вывод ошибок
В Twig форму можно вывести одной функцией form(form), но так вы теряете контроль над версткой. На живом проекте почти всегда рендерят поля по одному. Ошибки валидации Symfony кладёт рядом с полем, к которому они относятся, а общие — в form_errors(form).
{# templates/profile/edit.html.twig #}
{{ form_start(form) }}
{{ form_errors(form) }}
{{ form_label(form.email) }}
{{ form_widget(form.email) }}
{{ form_errors(form.email) }}
{{ form_label(form.displayName) }}
{{ form_widget(form.displayName) }}
{{ form_errors(form.displayName) }}
{{ form_label(form.avatarFile) }}
{{ form_widget(form.avatarFile) }}
{{ form_errors(form.avatarFile) }}
{{ form_end(form) }}
Функция form_start сама добавит атрибут enctype="multipart/form-data", потому что видит поле FileType — вручную его прописывать не нужно. А скрытое поле с CSRF-токеном добавит form_end. Если рендерить поля вручную и забыть form_end, токен не попадёт в разметку и любой POST будет отклонён.
Что ломается чаще всего
- «This form should not contain extra fields». В HTML-форме есть поле, которого нет в FormType, либо у поля неверный
name. Чаще всего — ручная кнопка или лишний input. - Файл не доходит до контроллера. Пропал
enctype="multipart/form-data", потому что форму собрали руками безform_start. Проверьте исходный код страницы. - Данные не сохраняются, ошибок нет. Забыли
$em->flush(), либо изменили поле, помеченноеmapped: false— тогда оно живёт только в форме и в базу не попадёт. - «The CSRF token is invalid». Токен протух (открыли форму, ушли надолго, отправили) или отсутствует в разметке. На AJAX-отправках токен нужно передавать явно.
- Валидация email не срабатывает. Ограничения
#[Assert\Email]заданы на сущности, но у формы не выставленdata_class— Symfony не знает, откуда брать правила.
Mapped против не-mapped: когда что
Главный развилочный вопрос — записывать значение поля в сущность автоматически или обрабатывать вручную.
| Ситуация | mapped | Почему |
|---|---|---|
| Поле совпадает со свойством сущности по типу | true (по умолчанию) | Symfony сам читает и пишет свойство |
| Загрузка файла, в базе хранится путь | false | Тип поля (UploadedFile) не совпадает со свойством (string) |
| Повтор пароля, чекбокс «согласен с условиями» | false | Значение нужно только для проверки, в сущности его нет |
| Простой email/имя/описание | true | Прямое соответствие свойству |
Что настроить дальше
Форма работает — теперь стоит закрыть три вещи. Первое: удаление старого файла аватара при загрузке нового, иначе каталог uploads будет расти. Второе: вынести загрузку файла из контроллера в отдельный сервис-хендлер — контроллер не место для файловой логики. Третье: если форм с загрузкой много, посмотрите на бандл VichUploaderBundle, который автоматизирует связку «файл ↔ путь в сущности» и убирает ручной код перемещения.
Инвалидация CSRF-токенов и настройка их времени жизни — отдельная тема, здесь мы её не разворачиваем. За актуальным поведением функций рендеринга и опций FileType сверяйтесь с документацией вашей минорной версии Symfony: между 6.4 и 7.x сигнатуры совместимы, но опции валидаторов иногда меняют дефолты.
Редактор изображений: Убрать лишнее, заменить фон, добавить объект, расширить кадр — на вашем изображении.
Спросить за 20 ₽Источники: Symfony — Forms (официальная документация), Symfony — How to Upload Files
Частые вопросы
У меня Symfony 5.4, атрибуты #[Route] и #[Assert] работают?
Отправляю форму — «The CSRF token is invalid», хотя всё выглядит правильно
Почему поле аватара помечено mapped: false, а email — нет?
Валидация из атрибутов #[Assert] на сущности не срабатывает — в чём дело?
Данные не сохраняются в базу, но и ошибок нет
Стоит ли класть FormType-логику прямо в контроллер?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.