HardТеория6 min

Подвохи Forms и Validation

Data transformers, inherit_data, form events, validation groups, group sequences, compound constraints

Подвох 1: Data Transformers -- направление трансформации

Data Transformers имеют ДВА направления, и их легко перепутать.

<?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;

/**
 * @implements DataTransformerInterface<Tag|null, string>
 */
final readonly class TagToStringTransformer implements DataTransformerInterface
{
    public function __construct(
        private TagRepository $tagRepository,
    ) {
    }

    /**
     * Модель -> Представление (Entity -> строка для отображения в форме)
     * Вызывается при ОТОБРАЖЕНИИ формы
     */
    public function transform(mixed $value): string
    {
        if (null === $value) {
            return '';
        }

        // Tag entity -> строка для input поля
        return $value->getName();
    }

    /**
     * Представление -> Модель (строка из формы -> Entity)
     * Вызывается при SUBMIT формы
     */
    public function reverseTransform(mixed $value): ?Tag
    {
        if ('' === $value || null === $value) {
            return null;
        }

        $tag = $this->tagRepository->findOneByName($value);

        if (null === $tag) {
            // TransformationFailedException -> показывает invalid_message
            throw new TransformationFailedException(
                sprintf('Tag "%s" not found.', $value),
            );
        }

        return $tag;
    }
}

Два типа трансформеров:

Тип Метод добавления Уровень
Model Transformer addModelTransformer() Данные модели <-> Нормализованные данные
View Transformer addViewTransformer() Нормализованные данные <-> Данные представления
<?php

declare(strict_types=1);

// Цепочка данных в Form:
// Model Data -> [Model Transformers] -> Norm Data -> [View Transformers] -> View Data

// При SUBMIT (reverseTransform):
// View Data -> [View Transformers] -> Norm Data -> [Model Transformers] -> Model Data

На экзамене: transform() = Model->View (при рендеринге). reverseTransform() = View->Model (при submit). Многие путают направления. addModelTransformer и addViewTransformer -- разные уровни трансформации!

Подвох 2: inherit_data -- форма без собственных данных

Опция inherit_data позволяет форме работать с данными родителя вместо собственных.

<?php

declare(strict_types=1);

namespace App\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

// Эта форма работает с данными РОДИТЕЛЬСКОЙ формы
final class AddressType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('street', TextType::class)
            ->add('city', TextType::class)
            ->add('zipCode', TextType::class);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'inherit_data' => true,
            // data_class НЕ указывается! Данные берутся из parent.
        ]);
    }
}

// Использование:
final class UserType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('name', TextType::class)
            // AddressType НЕ создаёт вложенный объект!
            // street, city, zipCode мапятся прямо на User entity
            ->add('address', AddressType::class);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => User::class,
            // User должен иметь свойства: name, street, city, zipCode
        ]);
    }
}

На экзамене: inherit_data: true НЕ создаёт вложенный объект. Поля формы маппятся напрямую на parent data. Используется для визуальной группировки полей без вложенности данных.

Подвох 3: Порядок Form Events

Form events вызываются в строго определённом порядке. Путаница в порядке -- частая ошибка.

<?php

declare(strict_types=1);

namespace App\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;

final class DynamicFormType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->add('type', ChoiceType::class, [
            'choices' => ['A' => 'a', 'B' => 'b'],
        ]);

        // PRE_SET_DATA: данные ещё НЕ установлены в форму
        // Используется для модификации формы на основе начальных данных
        $builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event): void {
            $data = $event->getData(); // Начальные данные (из entity/array)
            $form = $event->getForm();

            if ($data && 'a' === $data['type']) {
                $form->add('extra', TextType::class);
            }
        });

        // POST_SET_DATA: данные УЖЕ установлены
        // Используется для чтения данных (не для модификации формы!)
        $builder->addEventListener(FormEvents::POST_SET_DATA, function (FormEvent $event): void {
            // Данные уже в форме
        });

        // PRE_SUBMIT: submitted данные ещё НЕ обработаны
        // Можно модифицировать submitted data!
        $builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event): void {
            $submittedData = $event->getData(); // RAW submitted data (array)
            // Можно изменить данные!
            // $event->setData($modifiedData);
        });

        // SUBMIT: данные нормализованы, но форма НЕ валидирована
        $builder->addEventListener(FormEvents::SUBMIT, function (FormEvent $event): void {
            // Данные уже нормализованы
        });

        // POST_SUBMIT: данные установлены, валидация ПРОЙДЕНА
        // Модифицировать форму здесь уже НЕЛЬЗЯ!
        $builder->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event): void {
            // Нельзя $form->add() -- бросит исключение!
        });
    }
}

Порядок вызова:

Создание формы:
  1. PRE_SET_DATA   -- до установки данных (можно менять форму)
  2. POST_SET_DATA  -- после установки данных (только чтение)

Submit формы:
  3. PRE_SUBMIT     -- до обработки данных (можно менять данные)
  4. SUBMIT         -- данные нормализованы (можно менять данные)
  5. POST_SUBMIT    -- после валидации (НЕЛЬЗЯ менять форму!)

На экзамене: Добавлять поля в форму можно в PRE_SET_DATA и PRE_SUBMIT. В POST_SUBMIT менять форму НЕЛЬЗЯ. Путаница между PRE и POST -- классическая ловушка.

Подвох 4: Validation Groups -- автоопределение

Symfony может автоматически определять validation group на основе данных формы.

<?php

declare(strict_types=1);

namespace App\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

final class RegistrationType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('email')
            ->add('type') // 'personal' или 'company'
            ->add('companyName');
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => Registration::class,
            // Динамические validation groups через callback
            'validation_groups' => function (FormInterface $form): array {
                $data = $form->getData();

                $groups = ['Default'];

                if ('company' === $data?->type) {
                    $groups[] = 'company'; // companyName обязательно
                }

                return $groups;
            },
        ]);
    }
}
<?php

declare(strict_types=1);

namespace App\Entity;

use Symfony\Component\Validator\Constraints as Assert;

final class Registration
{
    #[Assert\NotBlank]
    #[Assert\Email]
    public string $email = '';

    #[Assert\NotBlank]
    public string $type = 'personal';

    // Валидируется ТОЛЬКО если group 'company' активна
    #[Assert\NotBlank(groups: ['company'])]
    public string $companyName = '';
}

На экзамене: Группа 'Default' соответствует ограничениям без явной группы. Callback в validation_groups получает FormInterface, НЕ данные напрямую. Нужен $form->getData().

Подвох 5: GroupSequence -- последовательная валидация

GroupSequence валидирует группы ПОСЛЕДОВАТЕЛЬНО. Если первая группа не прошла -- остальные НЕ проверяются.

<?php

declare(strict_types=1);

namespace App\Entity;

use Symfony\Component\Validator\Constraints as Assert;

#[Assert\GroupSequence(['User', 'Strict'])]
final class User
{
    // Группа 'User' (Default) -- проверяется ПЕРВОЙ
    #[Assert\NotBlank]
    public string $email = '';

    // Группа 'Strict' -- проверяется ТОЛЬКО если 'User' прошла
    #[Assert\Email(mode: 'strict', groups: ['Strict'])]
    public string $email = '';

    #[Assert\NotBlank]
    #[Assert\Length(min: 8)]
    public string $password = '';

    // Эти проверки выполнятся только если NotBlank и Length прошли
    #[Assert\PasswordStrength(minScore: 3, groups: ['Strict'])]
    public string $password = '';
}

На экзамене: В #[GroupSequence] первый элемент ДОЛЖЕН совпадать с именем класса (это заменяет группу 'Default'). Если первая группа не прошла -- последующие НЕ проверяются. Это оптимизация: зачем проверять сложность пароля, если он пустой?

Подвох 6: Callback Constraint vs Custom Constraint

<?php

declare(strict_types=1);

namespace App\Entity;

use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;

final class Order
{
    public \DateTimeImmutable $startDate;
    public \DateTimeImmutable $endDate;

    // Callback -- для ПРОСТЫХ проверок, специфичных для одного класса
    #[Assert\Callback]
    public function validate(ExecutionContextInterface $context): void
    {
        if ($this->startDate >= $this->endDate) {
            $context->buildViolation('End date must be after start date.')
                ->atPath('endDate')
                ->addViolation();
        }
    }
}
<?php

declare(strict_types=1);

namespace App\Validator;

use Symfony\Component\Validator\Constraint;

// Custom Constraint -- для ПЕРЕИСПОЛЬЗУЕМЫХ проверок
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_CLASS)]
final class DateRange extends Constraint
{
    public string $message = 'End date "{{ end }}" must be after start date "{{ start }}".';

    // Для class-level constraints:
    public function getTargets(): string
    {
        return self::CLASS_CONSTRAINT;
    }
}
<?php

declare(strict_types=1);

namespace App\Validator;

use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;

final class DateRangeValidator extends ConstraintValidator
{
    public function validate(mixed $value, Constraint $constraint): void
    {
        if (!$constraint instanceof DateRange) {
            throw new UnexpectedTypeException($constraint, DateRange::class);
        }

        // $value -- это ВЕСЬ объект (class-level constraint)
        if ($value->startDate >= $value->endDate) {
            $this->context->buildViolation($constraint->message)
                ->setParameter('{{ start }}', $value->startDate->format('Y-m-d'))
                ->setParameter('{{ end }}', $value->endDate->format('Y-m-d'))
                ->atPath('endDate')
                ->addViolation();
        }
    }
}

На экзамене: #[Assert\Callback] -- для простых проверок в одном классе (нельзя переиспользовать). Custom Constraint + Validator -- для переиспользуемой логики. Constraint может быть CLASS_CONSTRAINT (получает весь объект) или PROPERTY_CONSTRAINT (получает значение свойства).

Подвох 7: Compound Constraints

Compound constraint объединяет несколько ограничений в одно.

<?php

declare(strict_types=1);

namespace App\Validator;

use Symfony\Component\Validator\Constraints\Compound;
use Symfony\Component\Validator\Constraints as Assert;

// Compound -- комбинация нескольких constraints
#[\Attribute]
final class StrongPassword extends Compound
{
    /**
     * @return array<Assert\Constraint>
     */
    protected function getConstraints(array $options): array
    {
        return [
            new Assert\NotBlank(),
            new Assert\Length(min: 12, max: 128),
            new Assert\NotCompromisedPassword(),
            new Assert\PasswordStrength(minScore: 3),
        ];
    }
}

// Использование:
// #[StrongPassword] -- вместо четырёх отдельных атрибутов

На экзамене: Compound constraint НЕ имеет собственного валидатора -- он делегирует встроенным constraints. Это отличается от custom constraint + validator. Compound -- просто сахар для группировки.

Подвох 8: mapped => false и данные

<?php

declare(strict_types=1);

namespace App\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\FormBuilderInterface;

final class RegistrationFormType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('email')
            ->add('password')
            // mapped: false -- поле НЕ связано с entity
            ->add('agreeTerms', CheckboxType::class, [
                'mapped' => false,
                // Валидация работает, но данные НЕ попадут в entity
            ]);
    }
}

// Для получения значения mapped: false поля:
// $form->get('agreeTerms')->getData()  -- ПРАВИЛЬНО
// $form->getData()->agreeTerms         -- ОШИБКА! Свойства нет в entity

На экзамене: Поля с mapped: false не связаны с data_class. Их данные доступны ТОЛЬКО через $form->get('fieldName')->getData(). Валидация к ним применяется через атрибуты формы, а НЕ через entity constraints.

Проверь себя

5 из 8

Callback в validation_groups получает в качестве аргумента...

Чем Compound Constraint отличается от Custom Constraint с отдельным Validator?

DataTransformer бросает TransformationFailedException в reverseTransform(). Что увидит пользователь?

Как получить значение поля с mapped: false после submit формы?

В #[GroupSequence(['User', 'Strict'])], что произойдёт если валидация группы 'User' не пройдёт?