Перевірено на 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. Дублювання правил валідації в цій схемі неможливе за побудовою, і саме тому вона виграє у двох незалежних гілок коду.