<? phpukraine СПІВБЕСІДИ
Пошук по платформі
SYMFONY · MIDDLE

Як працюють Symfony Forms і Validator, і коли форми заважають в API?

Форма — це двонаправлений маппер HTTP-даних на обʼєкт: submit прогонить дані через трансформери, покладе їх у модель і лише потім викличе Validator, який валідує обʼєкт, а не поля; в JSON-API цей шар зайвий, бо `handleRequest` читає `$request->request`, а не тіло запиту, і краще брати `#[MapRequestPayload]`.

Чому `$form->isValid()` повертає false, хоча жодної помилки на екрані немає?
Ми шлемо JSON у контролер із формою — форма каже, що всі поля порожні. Чому?
Де саме спрацьовують констрейнти: у формі чи в сутності?
Чим `#[MapRequestPayload]` кращий за форму для REST-ендпоінта?
Forms Validator DTO MapRequestPayload API

Форма в Symfony — це двонаправлений маппер між HTTP-даними й обʼєктом, а не валідатор. Коли викликається submit(), кожне поле проганяє вхідний рядок через ланцюжок view- і model-трансформерів (getViewData()getNormData()getData()), після чого DataMapper записує результат у властивості обʼєкта з data_class через PropertyAccess. Валідація починається лише після цього і виконується окремим сервісом: ValidatorExtension додає до кореневої форми констрейнт Form, а його FormValidator просить ValidatorInterface перевірити вже змаплений обʼєкт за метаданими класу — тими самими атрибутами #[Assert\...], які працюють і без форм. Тому фраза «констрейнти у формі» майже завжди помилкова: у формі лежить хіба що опція constraints для полів без data_class, решта живе на DTO чи сутності.

З цього порядку випливають дві класичні загадки. Перша: помилка є, а на екрані порожньо. Порушення приходить із property path, який ViolationMapper шукає в дереві форм; якщо шляху немає (наприклад, констрейнт на рівні класу через #[Assert\Callback]), помилка залишається на кореневій формі, і побачити її можна тільки якщо шаблон рендерить form_errors(form). Лікується опцією error_mapping, яка явно каже, на яке поле повісити конкретний шлях. Друга: «This value is not valid» замість вашого повідомлення. Це TransformationFailedException із трансформера — рядок не перетворився на DateTimeImmutable чи int, дані в модель не потрапили, і Validator для цього поля просто не запускався; текст береться з опції invalid_message.

Джерело даних для форми — $request->request і $request->files, а не тіло запиту. Symfony не декодує JSON у $request->request автоматично, тому handleRequest() на JSON-ендпоінті чесно бачить порожньо і повідомляє, що обовʼязкові поля не заповнені. Далі накладається все інше, що форма тягне з собою у stateless-контекст: CSRF-токен, якого в клієнта немає (для класичних форм у Symfony 7.2 зʼявився ще й stateless-варіант із double-submit cookie); іменування полів як form_name[email], тобто чужий для API формат; помилка extra_fields на будь-який зайвий ключ, поки не виставлено allow_extra_fields; і структура помилок у вигляді дерева FormErrorIterator, яку доводиться вручну складати в плаский JSON через $form->getErrors(true, false).

Практичний висновок: у JSON-API форму варто замінити на DTO плюс #[MapRequestPayload] (Symfony 6.3+). Резолвер бере тіло, віддає його Serializer, валідує результат Validator і у разі порушень кидає виняток із кодом 422 — контролер отримує вже коректний обʼєкт. Поруч живуть #[MapQueryString] для query-параметрів (у нього дефолтний код відмови 404, бо невалідний query частіше означає неіснуючий ресурс) і #[MapUploadedFile] для файлів із Symfony 6.4. Формат відповіді при цьому не треба вигадувати: ProblemNormalizer серіалізує ValidationFailedException у RFC 7807 із масивом violations.

Межа проста. Форма виграє там, де сервер рендерить HTML і потрібна двонаправленість: адмінки, CRUD, майстри, CollectionType з allow_add, EntityType з вибіркою з Doctrine. Форма програє там, де половина її роботи не потрібна, а друга половина дублює Serializer. Окремо варто памʼятати про PATCH: HttpFoundationRequestHandler викликає submit($data, false) тільки тому, що метод запиту PATCH, і якщо ви подаєте дані у форму вручну, цей clearMissing доведеться передавати самому — інакше частковий апдейт занулить поля, яких клієнт не надсилав. І в будь-якому підході Validator не замінює обмежень бази: UniqueEntity робить окремий SELECT, тому без унікального індексу гонка двох запитів усе одно пройде.

// 1. Класична форма: валідація живе на класі, а не у формі
final class RegistrationData
{
    #[Assert\NotBlank(groups: ['registration'])]
    #[Assert\Email(mode: 'strict', groups: ['registration'])]
    public ?string $email = null;

    #[Assert\Length(min: 12, groups: ['registration'])]
    public ?string $password = null;
}

$form = $this->createForm(RegistrationType::class, new RegistrationData(), [
    'validation_groups' => ['registration'],  // саме ці групи піде перевіряти FormValidator
    'error_mapping' => ['emailAlreadyTaken' => 'email'], // порушення без свого поля не загубиться
]);

$form->handleRequest($request);           // читає $request->request[form_name] і $request->files
if ($form->isSubmitted() && $form->isValid()) {
    // isValid() без isSubmitted() кине LogicException
}

// 2. Той самий контракт для JSON-API: форма не потрібна взагалі
final class RegisterRequest
{
    public function __construct(
        #[Assert\NotBlank] #[Assert\Email(mode: 'strict')]
        public readonly string $email,
        #[Assert\Length(min: 12)]
        public readonly string $password,
    ) {}
}

#[Route('/api/register', methods: ['POST'])]
public function register(#[MapRequestPayload] RegisterRequest $data): JsonResponse
{
    // тіло вже десеріалізоване Serializer і провалідоване Validator;
    // при порушеннях сюди не зайдемо — резолвер кине 422 з ConstraintViolationList
    return new JsonResponse(['id' => $this->users->register($data)], 201);
}

// 3. Якщо форму все ж треба нагодувати JSON — руками, з урахуванням PATCH
$form->submit(json_decode($request->getContent(), true), $request->getMethod() !== 'PATCH');
Що валідація живе не у формі: форма лише додає констрейнт `Valid` на кореневий обʼєкт, а перевіряє його `ValidatorInterface` за метаданими класу (атрибути `#[Assert\...]`), і порушення потім розкладаються по полях через `error_mapping`.
Що `handleRequest()` бере дані з `$request->request` і `$request->files` за іменем форми, тому сире JSON-тіло туди не потрапляє — його треба декодувати самому й викликати `$form->submit()`.
Що для PATCH `HttpFoundationRequestHandler` викликає `submit($data, false)`, тобто не затирає відсутні поля, а для POST/PUT — затирає; це і є різниця часткового й повного оновлення.
Що помилка трансформера (`TransformationFailedException`) не долітає до Validator і показується як загальне повідомлення з опції `invalid_message`.
Що для stateless JSON-API форму зазвичай замінюють на DTO + `#[MapRequestPayload]` (Symfony 6.3+), який десеріалізує тіло, валідує і дає 422 без CSRF, теми й рендерингу.
Вішати констрейнти лише в `constraints` опції поля й дивуватися, що при `$form->submit()` з `validation_groups` іншої групи вони мовчать.
Вимкнути `csrf_protection` для API, але залишити форму — проблема була не в CSRF, а в тому, що дані не потрапляють у `$request->request` з JSON.
Вважати, що `isValid()` перевіряє форму: без `isSubmitted()` він кине `LogicException`, а перевіряє він змаплений обʼєкт.
Ловити «зайві» ключі як помилку валідації: невідоме поле дає окрему помилку форми `extra_fields`, і вимикається вона опцією `allow_extra_fields`, а не констрейнтом.
Валідувати сутність Doctrine напряму й покладатися на це як на захист БД: Validator не знає про унікальність без `UniqueEntity`, а той робить окремий запит і не рятує від гонки без унікального індексу.
Рендерити помилки форми в JSON через `$form->getErrors()` без `true` як першого аргументу — вкладені помилки дочірніх полів просто зникають.
ПОРАДА

Скажіть коротко: «Form — це UI-шар для HTML, Validator — окремий сервіс, який працює з обʼєктом». Далі покажіть, що в API ви залишаєте другий і викидаєте перший: DTO з `#[Assert]` + `#[MapRequestPayload]`, а `ConstraintViolationList` нормалізуєте у відповідь за RFC 7807.

оновлено 4 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

ValidatorExtension вішає на кореневу форму констрейнт Form; FormValidator валідує обʼєкт за метаданими класу, а ViolationMapper переносить порушення на поля за property path і error_mapping.