События формы (Form Events)
Form Events позволяют динамически изменять форму на разных этапах её жизненного цикла. Это ключевой механизм для зависимых полей, условной логики и преобразования данных.
Жизненный цикл формы
setData($data)
│
├─ PRE_SET_DATA → Form data is about to be set (modify data or add/remove fields)
│
├─ POST_SET_DATA → Form data has been set (read data, adjust form)
│
submit($data)
│
├─ PRE_SUBMIT → Raw submitted data available (modify submitted data, add/remove fields)
│
├─ SUBMIT → Data is mapped to form (internal, rarely used)
│
└─ POST_SUBMIT → Data is validated (read final data, add validation errors)
Когда использовать какое событие
| Событие | Когда использовать |
|---|---|
PRE_SET_DATA |
Добавить/удалить поля на основе начальных данных (edit vs create) |
POST_SET_DATA |
Прочитать установленные данные для настройки формы |
PRE_SUBMIT |
Модифицировать отправленные данные до маппинга, добавить/удалить поля |
POST_SUBMIT |
Прочитать итоговые данные, добавить кастомные ошибки валидации |
PRE_SET_DATA -- динамические поля
<?php
declare(strict_types=1);
namespace App\Form;
use App\Entity\Product;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class ProductType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('name', TextType::class)
->add('price', MoneyType::class)
;
// PRE_SET_DATA: modify form based on initial data
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event): void {
$product = $event->getData();
$form = $event->getForm();
// If editing existing product (has ID)
if ($product instanceof Product && null !== $product->getId()) {
// Add SKU field only for existing products (cannot change on creation)
$form->add('sku', TextType::class, [
'disabled' => true,
'label' => 'SKU (read-only)',
]);
}
// If product is new, add different fields
if (null === $product || null === $product->getId()) {
$form->add('sku', TextType::class, [
'label' => 'SKU',
'help' => 'Enter a unique SKU for this product',
]);
}
});
}
public function configureOptions(OptionsResolver $options): void
{
$options->setDefaults([
'data_class' => Product::class,
]);
}
}
Подвох экзамена: В
PRE_SET_DATA$event->getData()может бытьnull, если форма создана без передачи объекта (createForm(ProductType::class)). Всегда проверяйте на null!
PRE_SUBMIT -- зависимые поля (каскадный выбор)
Классический пример: выбор страны -> загрузка городов.
<?php
declare(strict_types=1);
namespace App\Form;
use App\Entity\City;
use App\Entity\Country;
use App\Repository\CityRepository;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormInterface;
final class AddressType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('country', EntityType::class, [
'class' => Country::class,
'choice_label' => 'name',
'placeholder' => 'Select country',
]);
// Add city field based on selected country
$addCityField = function (FormInterface $form, ?Country $country): void {
$form->add('city', EntityType::class, [
'class' => City::class,
'choice_label' => 'name',
'placeholder' => null === $country ? 'Select country first' : 'Select city',
'query_builder' => fn (CityRepository $repo) =>
$repo->createQueryBuilder('c')
->where('c.country = :country')
->setParameter('country', $country)
->orderBy('c.name', 'ASC'),
'disabled' => null === $country,
]);
};
// When form is initialized with data (edit mode)
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($addCityField): void {
$data = $event->getData();
$country = $data?->getCountry();
$addCityField($event->getForm(), $country);
});
// When form is submitted -- update city list based on selected country
$builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) use ($addCityField): void {
$data = $event->getData(); // Raw submitted data (array)
$countryId = $data['country'] ?? null;
$country = null;
if (null !== $countryId) {
// In real app, fetch from repository
$country = $this->countryRepository->find((int) $countryId);
}
$addCityField($event->getForm(), $country);
});
}
}
Подвох экзамена: В
PRE_SUBMIT$event->getData()возвращает сырые данные из запроса (массив строк), а НЕ объект. ВPRE_SET_DATA-- возвращает объект (или null). Это принципиальная разница!
POST_SUBMIT -- кастомная валидация
<?php
declare(strict_types=1);
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
final class DateRangeType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('dateFrom', DateType::class, ['label' => 'From'])
->add('dateTo', DateType::class, ['label' => 'To'])
;
// POST_SUBMIT: validate cross-field logic
$builder->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event): void {
$form = $event->getForm();
$dateFrom = $form->get('dateFrom')->getData();
$dateTo = $form->get('dateTo')->getData();
if ($dateFrom instanceof \DateTimeInterface
&& $dateTo instanceof \DateTimeInterface
&& $dateFrom > $dateTo
) {
// Add error to specific field
$form->get('dateTo')->addError(
new FormError('End date must be after start date.')
);
}
});
}
}
Event Subscriber для форм
Для переиспользования логики событий используйте EventSubscriberInterface.
<?php
declare(strict_types=1);
namespace App\Form\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
final class AddTimestampFieldsSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
FormEvents::PRE_SET_DATA => 'onPreSetData',
];
}
public function onPreSetData(FormEvent $event): void
{
$data = $event->getData();
$form = $event->getForm();
// If editing (entity has createdAt), show it as read-only
if (null !== $data && null !== $data->getCreatedAt()) {
$form->add('createdAt', DateTimeType::class, [
'disabled' => true,
'label' => 'Created',
]);
}
}
}
<?php
declare(strict_types=1);
namespace App\Form;
use App\Form\EventSubscriber\AddTimestampFieldsSubscriber;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
final class ArticleType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('title', TextType::class)
->add('content', TextareaType::class)
->addEventSubscriber(new AddTimestampFieldsSubscriber())
;
}
}
Data Transformers
Data Transformer преобразует данные между двумя представлениями: model data (PHP-объект) и norm/view data (строка для HTML-формы).
Два типа трансформеров
| Тип | Направление | Пример |
|---|---|---|
ModelTransformer |
Model ↔ Norm | Entity ID ↔ Entity object |
ViewTransformer |
Norm ↔ View | DateTime ↔ строка "2026-02-22" |
Model Data ←→ Norm Data ←→ View Data
(Entity) Model (mixed) View (string)
Transform Transform
Создание Data Transformer
<?php
declare(strict_types=1);
namespace App\Form\DataTransformer;
use App\Entity\Tag;
use App\Repository\TagRepository;
use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Exception\TransformationFailedException;
/**
* Transforms comma-separated string ↔ collection of Tag entities.
*
* @implements DataTransformerInterface<list<Tag>, string>
*/
final class TagsToStringTransformer implements DataTransformerInterface
{
public function __construct(
private readonly TagRepository $tagRepository,
) {
}
/**
* Model → View: Tag[] → "php, symfony, web"
*/
public function transform(mixed $value): string
{
if (null === $value || [] === $value) {
return '';
}
return implode(', ', array_map(
fn (Tag $tag): string => $tag->getName(),
$value instanceof \Traversable ? iterator_to_array($value) : $value,
));
}
/**
* View → Model: "php, symfony, web" → Tag[]
*/
public function reverseTransform(mixed $value): array
{
if ('' === $value || null === $value) {
return [];
}
$names = array_unique(array_filter(
array_map('trim', explode(',', $value))
));
$tags = [];
foreach ($names as $name) {
$tag = $this->tagRepository->findOneBy(['name' => $name]);
if (null === $tag) {
throw new TransformationFailedException(
sprintf('Tag "%s" does not exist.', $name)
);
}
$tags[] = $tag;
}
return $tags;
}
}
Подключение трансформера к полю
<?php
declare(strict_types=1);
namespace App\Form;
use App\Form\DataTransformer\TagsToStringTransformer;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
final class ArticleType extends AbstractType
{
public function __construct(
private readonly TagsToStringTransformer $transformer,
) {
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('title', TextType::class)
->add('tags', TextType::class, [
'label' => 'Tags (comma-separated)',
'required' => false,
])
;
// Add Model Transformer to the "tags" field
$builder->get('tags')->addModelTransformer($this->transformer);
}
}
Подвох экзамена:
addModelTransformer()трансформирует между model и norm data.addViewTransformer()-- между norm и view data.TransformationFailedExceptionвreverseTransform()приводит к ошибке валидации формы, а не к PHP-исключению.
invalid_message для ошибок трансформации
<?php
declare(strict_types=1);
$builder->add('tags', TextType::class, [
// Shown when TransformationFailedException is thrown
'invalid_message' => 'One or more tags are invalid.',
]);
Form Extensions
Form Extension модифицирует поведение существующих типов форм без наследования. Применяется ко всем экземплярам указанного типа.
<?php
declare(strict_types=1);
namespace App\Form\Extension;
use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
// Extension applies to ALL form types (extends FormType)
final class HelpTooltipExtension extends AbstractTypeExtension
{
// Which types this extension applies to
public static function getExtendedTypes(): iterable
{
// FormType -- applies to ALL types
return [FormType::class];
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefined(['help_tooltip']);
$resolver->setAllowedTypes('help_tooltip', ['null', 'string']);
$resolver->setDefault('help_tooltip', null);
}
public function buildView(FormView $view, FormInterface $form, array $options): void
{
// Pass custom option to Twig template
$view->vars['help_tooltip'] = $options['help_tooltip'];
}
}
<?php
declare(strict_types=1);
// Usage -- now ALL form types support help_tooltip option
$builder->add('email', EmailType::class, [
'help_tooltip' => 'We will never share your email.',
]);
{# Access in Twig theme #}
{% if help_tooltip %}
<span class="tooltip" data-tooltip="{{ help_tooltip }}">?</span>
{% endif %}
Extension для конкретного типа
<?php
declare(strict_types=1);
namespace App\Form\Extension;
use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
// Extension only for FileType
final class ImagePreviewExtension extends AbstractTypeExtension
{
public static function getExtendedTypes(): iterable
{
return [FileType::class];
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'image_preview' => false,
'image_preview_path' => null,
]);
}
public function buildView(FormView $view, FormInterface $form, array $options): void
{
$view->vars['image_preview'] = $options['image_preview'];
$view->vars['image_preview_path'] = $options['image_preview_path'];
}
}
Подвох экзамена:
getExtendedTypes()-- статический метод, возвращающий iterable (массив FQCN). Extension дляFormType::classприменяется ко ВСЕМ типам (включая TextType, EntityType и т.д.), потому что все типы наследуют от FormType.
Итоги
PRE_SET_DATA-- данные ещё не установлены; можно менять форму по начальным даннымPRE_SUBMIT-- сырые данные из запроса (массив); идеально для зависимых полейPOST_SUBMIT-- данные провалидированы; добавление кастомных ошибок- Data Transformer:
transform()(model->view) иreverseTransform()(view->model) TransformationFailedException-- не retry, а ошибка валидации- Form Extension:
getExtendedTypes()определяет, к каким типам применяется - Extension для
FormType::classраспространяется на ВСЕ типы форм