HardТеория7 min

События форм и расширения

События форм: PRE_SET_DATA, POST_SET_DATA, PRE_SUBMIT, POST_SUBMIT, form extensions и data transformers

События формы (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 распространяется на ВСЕ типы форм

Проверь себя

Какое событие формы лучше всего подходит для реализации каскадного выбора (выбор страны -> загрузка городов)?

Что произойдёт, если `reverseTransform()` Data Transformer выбросит `TransformationFailedException`?

Чем отличается `addModelTransformer()` от `addViewTransformer()`?

К каким типам форм применится Extension с `getExtendedTypes()` возвращающим `[FormType::class]`?

Что возвращает `$event->getData()` в событии `PRE_SUBMIT`?