<? phpukraine СТАТТІ
Пошук по платформі
SYMFONY 15 вересня 2026 · 7 хв читання

Форми Symfony чи DTO з валідатором: як не потонути у FormType

`#[MapRequestPayload]` перетворює тіло запиту на типізований об'єкт і валідує його без жодного FormType, і для JSON-ендпоінтів це майже завжди правильний вибір. Але щойно з'являються вкладені колекції з кнопкою «додати рядок», залежні селекти й рендеринг помилок у Twig, форми повертають собі перевагу. Розбираємо, що саме робить резолвер під капотом, де він мовчки віддає 404 замість 422, як віддати формі й API один і той самий набір constraints і чому тест на DTO пишеться за три рядки, а на FormType - за тридцять.

РP
Редакція phpukraine
Редакція платформи

Перевірено на Symfony 7.4 LTS і 8.0, PHP 8.4, з symfony/serializer, symfony/validator і phpdocumentor/reflection-docblock у складі проєкту.

Що робить резолвер, коли ви пишете один атрибут

#[MapRequestPayload] має вигляд магії, хоча всередині це звичайний ValueResolver з цілком передбачуваною поведінкою. Ось типовий ендпоінт:

final readonly class CreateVacancy
{
    /**
     * @param list<string> $tags
     */
    public function __construct(
        #[Assert\NotBlank]
        #[Assert\Length(max: 120)]
        public string $title,

        #[Assert\Positive]
        public int $salaryFrom,

        #[Assert\Valid]
        public CompanyContact $contact,

        #[Assert\Count(min: 1, max: 10)]
        #[Assert\All([new Assert\Choice(['php', 'symfony', 'javascript'])])]
        public array $tags = [],
    ) {}
}

#[Route('/api/vacancies', methods: ['POST'])]
public function create(
    #[MapRequestPayload] CreateVacancy $payload,
    VacancyCreator $creator,
): JsonResponse {
    return new JsonResponse($creator->create($payload), 201);
}

RequestPayloadValueResolver дивиться на Content-Type, підбирає формат, віддає тіло серіалізатору з увімкненим COLLECT_DENORMALIZATION_ERRORS, а потім пропускає готовий об'єкт через валідатор. Далі три деталі, які впливають на ваш код.

Помилки типізації не вибухають винятком серіалізатора. Якщо в JSON прилетіло "salaryFrom": "багато", резолвер зловить PartialDenormalizationException і перетворить кожну NotNormalizableValueException на звичайну ConstraintViolation з повідомленням про очікуваний тип. Помилки денормалізації та помилки валідації приходять одним списком, і обробляти їх треба в одному місці.

Невідповідний Content-Type дає 415, побите тіло - 400, невалідні дані - 422. А от #[MapQueryString] за замовчуванням віддає 404. Так задумано: невалідний query string зазвичай означає неіснуючу сторінку списку, а не помилку введення. Якщо вам потрібен 422, задавайте явно:

public function search(
    #[MapQueryString(validationFailedStatusCode: Response::HTTP_UNPROCESSABLE_ENTITY)]
    SearchQuery $query = new SearchQuery(),
): Response

Значення за замовчуванням тут обов'язкове. Порожній query string змушує резолвер віддати null, і без default-значення (або nullable-типу) ви отримаєте помилку на першому ж запиті без параметрів.

Третя деталь: тип масиву резолвер бере з PHPDoc. array $tags без анотації денормалізується як є, а list<CompanyContact> $contacts дасть масив об'єктів лише тоді, коли в проєкті стоїть phpdocumentor/reflection-docblock і PropertyInfo бачить докблок. Забудете залежність - отримаєте масив асоціативних масивів і TypeError у конструкторі рівнем нижче.

Віддати клієнту нормальний формат помилок зручно одним слухачем:

#[AsEventListener(event: KernelEvents::EXCEPTION)]
final class ValidationExceptionListener
{
    public function __invoke(ExceptionEvent $event): void
    {
        $exception = $event->getThrowable();
        $previous = $exception instanceof HttpException ? $exception->getPrevious() : null;

        if (!$previous instanceof ValidationFailedException) {
            return;
        }

        $errors = [];

        foreach ($previous->getViolations() as $violation) {
            $errors[$violation->getPropertyPath()][] = $violation->getMessage();
        }

        $event->setResponse(new JsonResponse(['errors' => $errors], $exception->getStatusCode()));
    }
}

Чого DTO не вміє

Файли. Резолвер розбирає тіло серіалізатором, а multipart/form-data з UploadedFile у це не вкладається. Для завантажень є окремий атрибут:

public function uploadCv(
    #[MapUploadedFile([
        new Assert\File(maxSize: '2M', extensions: ['pdf']),
    ])]
    UploadedFile $cv,
): Response

Змішаний запит, де частина полів у multipart, а частина в JSON-рядку всередині поля, доведеться розбирати руками. Ціна нормальна, тільки дізнатися про неї краще до того, як половина ендпоінтів написана.

Невідомі поля серіалізатор мовчки ігнорує. Клієнт надіслав salary_from замість salaryFrom і отримав 422 «поле обов'язкове», не розуміючи, куди поділося його значення. Лікується контекстом:

#[MapRequestPayload(serializationContext: [
    AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => false,
])]

Тепер зайве поле дає явну помилку. Для публічного API це радше шкодить (ламає forward compatibility), для внутрішнього фронтенду - економить години.

Динаміка. DTO описує фіксовану структуру. Якщо набір полів залежить від значення іншого поля (тип вакансії визначає, які характеристики обов'язкові), варіантів два: validationGroups з callback-ом або окремі DTO на кожен варіант з union-типом у сигнатурі. Обидва працюють і обидва читаються гірше, ніж форма з підпискою на подію.

Де форми досі поза конкуренцією

Адмінка з вкладеними колекціями - це рівно той випадок, де FormType окупається. CollectionType з allow_add: true дає готовий prototype: HTML-шаблон рядка з плейсхолдером __name__, який JS підставляє при натисканні «додати». Плюс allow_delete, плюс by_reference: false, коли колекція живе за геттером і сеттером сутності, плюс error_bubbling для нормального показу помилок. Написати те саме поверх DTO означає написати власний prototype, власну індексацію полів і власне зіставлення шляхів помилок з інпутами.

Залежні поля роблять через події форми, і кращого способу тут поки не вигадали:

$builder->addEventListener(FormEvents::PRE_SUBMIT, function (PreSubmitEvent $event): void {
    $data = $event->getData();

    $event->getForm()->add('city', ChoiceType::class, [
        'choices' => $this->cities->forCountry($data['country'] ?? null),
    ]);
});

PRE_SET_DATA робить те саме для початкового рендерингу, PRE_SUBMIT - для перевірки того, що прийшло. Ніякого ручного розбору $request->request->all().

Далі йде все, що стосується HTML: DataTransformerInterface для полів, чиє представлення відрізняється від моделі (рядок тегів через кому проти масиву), form theming для узгодженого вигляду всіх форм проєкту, автоматичний CSRF-токен, EntityType з query_builder і кешуванням choice-list. Плюс те, чого не видно, поки не зникне: $form->submit($data, false) з clearMissing: false дає коректну PATCH-семантику, де відсутнє поле означає «не чіпати», а не «очистити».

Спільні constraints для форми й API

Найгірший результат - описати правила двічі: у FormType через constraints в опціях полів і в DTO через атрибути. Розходження знайдеться за півроку на проді.

Форма вміє працювати з DTO як з data_class. Для незмінного об'єкта з promoted-властивостями потрібен empty_data-callback, бо без сеттерів мапер не може записати значення:

public function configureOptions(OptionsResolver $resolver): void
{
    $resolver->setDefaults([
        'data_class' => CreateVacancy::class,
        'empty_data' => static fn (FormInterface $form): CreateVacancy => new CreateVacancy(
            $form->get('title')->getData() ?? '',
            (int) $form->get('salaryFrom')->getData(),
            $form->get('contact')->getData() ?? new CompanyContact(),
            $form->get('tags')->getData() ?? [],
        ),
    ]);
}

Схема робоча для створення. На редагуванні незмінний DTO ламається: форма отримує наявний об'єкт і намагається в нього писати. Тут або мутабельний DTO без readonly, або окремий UpdateVacancy, який будується з сутності фабричним методом. Другий варіант чесніший, хоча й більш багатослівний.

Валідація при цьому лишається одна, у DTO. Опція форми constraints не потрібна взагалі: ValidatorExtension валідує підсумковий об'єкт і розкидає порушення по полях за property path. Ті самі атрибути обслуговують і HTML-форму, і JSON-ендпоінт, і генератор OpenAPI, якщо ви користуєтеся nelmio/api-doc-bundle.

Тест як аргумент

Перевірка правила «зарплата має бути додатною» на DTO виглядає так:

public function test_salary_must_be_positive(): void
{
    $violations = $this->validator->validate(new CreateVacancy('Backend', -100, new CompanyContact()));

    self::assertSame('salaryFrom', $violations->get(0)->getPropertyPath());
}

Валідатор піднімається за один виклик Validation::createValidatorBuilder()->enableAttributeMapping()->getValidator(), залежностей немає, тест виконується миттєво.

Те саме на формі вимагає TypeTestCase з перевизначеним getExtensions(), підключеним ValidatorExtension, вимкненим csrf_protection і зареєстрованими вручну типами, які ваш FormType використовує через DI. Якщо у формі є EntityType, доводиться або мокати ManagerRegistry, або йти в KernelTestCase. Тест перестає перевіряти бізнес-правило й починає перевіряти інфраструктуру форм.

Звідси практичний поділ: бізнес-правила живуть у constraints на DTO і тестуються на DTO; тест на FormType пишеться тоді, коли є що перевіряти саме у формі, наприклад динамічне додавання поля в PRE_SUBMIT або нетривіальний DataTransformer.

Як обирати

JSON-ендпоінт, SPA чи мобільний клієнт, плоска або помірно вкладена структура, фіксований набір полів: #[MapRequestPayload]. Менше коду, типізований аргумент у контролері, тести на рівні unit.

Серверний рендеринг, колекції з додаванням і видаленням рядків, залежні селекти, завантаження файлів упереміш з полями, десятки однотипних екранів у бекофісі: форми. Prototype, theming і події вже написані, повторювати їх поверх DTO нерозумно.

Найчастіше трапляється змішаний випадок: адмінка на формах і API на DTO для тих самих даних. Тоді DTO з constraints стає спільним контрактом, а FormType - лише способом його заповнити через HTML. Дублювання правил валідації в цій схемі неможливе за побудовою, і саме тому вона виграє у двох незалежних гілок коду.

ПИШЕТЕ ПРО PHP?Опублікуйте розбір або історію з проєкту на платформіРедактор із чеклістом, редактура, авторська сторінка. Републікація з блогу отримує canonical на оригінал. Відкрити редактор →
РP
Редакція phpukraine
Редакція платформи
Матеріали, які готує команда платформи на основі власних даних: каталогу вакансій, зарплатного звіту й банку питань. Кожна цифра в них рахується з бази, а не береться з голови.
оновлено 15 вересня 2026 · ліцензія CC-BY-SA-4.0
ДАЛІ ПО ТЕМІ
ЧИТАТИ ДАЛІ
← Усі статті