Подвох 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] -- вместо четырёх отдельных атрибутов
На экзамене:
Compoundconstraint НЕ имеет собственного валидатора -- он делегирует встроенным 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.