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