Перейти к содержимому
Поиск
Разработка

Symfony Validator: проверка данных в PHP — мастерская и 6 примеров

Проверяем формы и JSON с Symfony Validator без фреймворка: шесть живых сценариев, мини-мастерская ограничений и готовый PHP-проект. Разбираем ошибки полей, вложенные данные, даты, импорт и подключение через Composer.

Александр Некрасов
Symfony Validator — проверка данных в PHP: поля формы, правила и результаты валидации

Форма отправляет пустое имя, JSON содержит строку вместо числа, дата окончания оказывается раньше начала, а в десятой строке импорта неверный email. Во всех случаях хочется получить одинаково понятный результат: какое поле не прошло проверку, почему и что исправить.

Symfony Validator — отдельный PHP-компонент для описания таких правил. Его можно установить через Composer в обычный проект, без полного фреймворка Symfony. В этой статье есть мини-мастерская и шесть работающих примеров: контактная форма, регистрация, расчёт стоимости, период работ, вложенный заказ и импорт строк.

Здесь выполняется настоящий PHP. Кнопка проверки отправляет данные ограниченному обработчику с Symfony Validator 7.4.23. Он возвращает ошибки и пояснения, но не создаёт аккаунты, не отправляет письма и не сохраняет записи. Для опытов используйте подготовленные вымышленные данные.

Ручные if и filter_var или библиотека правил

Проверить единственное поле можно несколькими условиями. Это нормальный выбор для маленького скрипта. Сложность появляется, когда те же требования нужны в форме, API, импорте и консольной команде: проверки расходятся, сообщения дублируются, а вложенные ошибки приходится собирать вручную.

$email = $input['email'] ?? null;
$errors = [];
if (!is_string($email) || trim($email) === '') {
    $errors['email'][] = 'Введите email';
} elseif (filter_var(trim($email), FILTER_VALIDATE_EMAIL) === false) {
    $errors['email'][] = 'Проверьте формат email';
}

В Validator условие оформляется как constraint — ограничение, например NotBlank, Email или Range. Проверяющий механизм возвращает список нарушений с путями полей. Правила можно переиспользовать для массивов и объектов, сочетать в цепочки и дополнять проверками связанных значений.

Задача Ручная проверка Symfony Validator
Одно простое условие Короткий if обычно достаточен Дополнительная зависимость может не понадобиться
Одинаковые правила в нескольких местах Нужно самостоятельно вынести и поддерживать функции Общие ограничения, DTO и группы проверки
Вложенные строки заказа Индексы и ошибки собираются вручную Collection, All и пути нарушений
Сохранение и права Реализуются приложением Также остаются задачей приложения

HTML-атрибуты required, min и браузерная проверка улучшают ввод, но запрос можно отправить напрямую. Сервер проверяет данные независимо от интерфейса. При этом успешная валидация означает соответствие заданным правилам, а не разрешение операции, существование пользователя или готовность данных к любому способу вывода.

Установка через Composer и первый работающий скрипт

В примерах закреплён Symfony Validator 7.4.23 из поддерживаемой ветки 7.4 LTS. Требуется PHP 8.2 или новее. Установка, autoload и разница между install и update подробно разобраны в руководстве по Composer.

mkdir validator-demo
cd validator-demo
composer require symfony/validator:7.4.23

Сохраните следующий полный пример как check.php рядом с папкой vendor. Здесь ещё нет веб-сервера, формы и базы: проверяем значение из командной строки и выводим сообщения.

<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';

use Symfony\Component\Validator\Validation;
use Symfony\Component\Validator\Constraints as Assert;

$validator = Validation::createValidator();
$violations = $validator->validate('wrong-address', new Assert\Sequentially([
    new Assert\Type('string'),
    new Assert\NotBlank(message: 'Введите email', normalizer: 'trim'),
    new Assert\Email(message: 'Проверьте формат email', normalizer: 'trim'),
]));
foreach ($violations as $violation) {
    echo $violation->getMessage(), PHP_EOL;
}
echo count($violations) === 0 ? 'Данные прошли проверку' : 'Есть ошибки';
php check.php
composer show symfony/validator
composer check-platform-reqs

Ожидаемая ошибка ввода возвращается в ConstraintViolationList. Для неё не нужен catch вокруг каждого поля. Исключение из-за неверно настроенного ограничения или ошибки PHP — другая ситуация: его нельзя превращать в сообщение «данные корректны» или выдавать посетителю вместе со стеком вызовов.

Версия и требования: официальная страница Symfony 7.4 LTS.

Точная версия в команде помогает повторить материал с тем же поведением. В рабочем проекте обновления зависимостей планируют отдельно: Composer проверяет совместимость, а ваши проверки подтверждают, что сообщения, границы и API остаются ожидаемыми. Передавайте вместе с проектом composer.json и composer.lock; папка vendor восстанавливается командой install.

Мини-мастерская: одно поле, правила и настоящий ответ сервера

Выберите тип поля: строка, email, число, дата или значение из списка. Настройте обязательность и доступные ограничения, введите значение и запустите проверку. Браузер передаёт конфигурацию обработчику; разрешённые правила применяет Symfony Validator на PHP.

Попробуйте: сделайте email обязательным и отправьте пустое значение. Затем введите неправильный адрес и исправьте его на demo@example.test. Сравните сообщение об обязательности с ошибкой формата. Отключите обязательность и снова проверьте пустоту: необязательное поле может отсутствовать, но заполненное всё равно должно соответствовать своему типу.

Мастерская возвращает PHP-код для выбранных правил и JSON настроек. После изменения поля или ограничений старый ответ перестаёт описывать текущий ввод: сначала выполните новую проверку. Генератор использует ограниченный набор готовых правил, а не исполняет произвольный PHP, присланный посетителем.

Экспортированный PHP запускается с Composer-зависимостями. Обычный HTML-файл не умеет выполнить серверную проверку сам: для полноценного повторения скачайте проект и запустите локальный PHP-сервер по инструкции в конце статьи.

Для следующего опыта выберите число и установите нижнюю границу. Сравните пустое поле, ноль и значение ровно на границе. Затем переключитесь на список: здесь проверяется принадлежность разрешённым вариантам, а не длина текста. Такой конструктор полезен при проектировании формы: сначала договоритесь о данных и пограничных случаях, потом переносите правила в приложение.

Шесть примеров: от поля формы до вложенного JSON

В каждом сценарии есть понятные поля, готовые данные и результат реального запроса. Начните с корректного набора, внесите одну ошибку, затем исправьте её. Так видно, какое ограничение сработало и как путь нарушения связывается с конкретным полем.

Проверка не означает отправку заявки или регистрацию. Сервер только разбирает входной набор, применяет правила и возвращает ответ. Если соединение пропало, интерфейс должен сообщить о недоступной проверке, а не показывать старый зелёный результат.

Скачать полный проект с PHP-ядром, интерфейсами и примерами. В архиве используется тот же подход к проверке, что и на странице; доступны PHP-сниппеты и JSON с данными.

1. Контактная форма: обязательность, email и согласие

Проверяем имя от 2 до 80 символов, email длиной до 180, сообщение от 10 до 1000 и обязательное согласие. Пустой адрес и адрес неправильного формата — разные ошибки. Полезно сначала объяснить отсутствие значения, а уже потом разбирать его содержание.

Попробуйте: очистите имя, введите wrong-address и снимите согласие. После запроса найдите ошибки у соответствующих полей. Верните подготовленный корректный набор: результат должен сообщить об успешной проверке, а не об отправленном письме.

$contactRules = new Assert\Collection([
    'name' => new Assert\Sequentially([
        new Assert\Type('string'),
        new Assert\NotBlank(normalizer: 'trim'),
        new Assert\Length(min: 2, max: 80, normalizer: 'trim'),
    ]),
    'email' => new Assert\Sequentially([
        new Assert\Type('string'),
        new Assert\NotBlank(normalizer: 'trim'),
        new Assert\Length(max: 180, normalizer: 'trim'),
        new Assert\Email(normalizer: 'trim'),
    ]),
    'message' => new Assert\Sequentially([
        new Assert\Type('string'), new Assert\NotBlank(normalizer: 'trim'),
        new Assert\Length(min: 10, max: 1000, normalizer: 'trim'),
    ]),
    'consent' => new Assert\Sequentially([
        new Assert\NotNull(), new Assert\Type('bool'), new Assert\IsTrue(),
    ]),
]);
$violations = $validator->validate($input, $contactRules);

IsTrue сам допускает также число 1 и строку "1". В JSON-контракте примера требуется именно boolean, поэтому перед ним стоят NotNull и Type('bool'). Библиотека проверяет правила; сбор данных формы, текст согласия и последующая обработка обращения принадлежат приложению.

Документация: точное поведение IsTrue.

Это основа формы обратной связи, заявки на расчёт или обращения в поддержку. В готовом наборе указаны Анна и адрес anna@example.test; домен используется как пример. Успешный Email подтверждает формат, но не доставку письма и не владение ящиком. Подтверждение адреса отдельной ссылкой, защита от повторных отправок и отправка уведомления требуют собственной логики.

Документация: Email: формат, пустые значения и режимы проверки.

Открыть контактную форму ↑

2. Регистрация: ограничения полей и совпадение паролей

В демонстрации логин содержит 3–24 латинские буквы, цифры, подчёркивание или дефис. Пароль — от 8 до 64 символов, подтверждение должно совпадать, условия должны быть приняты. Используйте учебные значения: результат проверки не создаёт аккаунт.

Попробуйте: замените подтверждение пароля отличающимся текстом. Ошибка должна относиться к confirmPassword, чтобы человек сразу понимал, что исправлять. Затем задайте одинаковые значения. Пробелы пароля не удаляются автоматически: это часть введённого секрета, а не обычное оформление имени.

use Symfony\Component\Validator\Context\ExecutionContextInterface;

final class PasswordPair
{
    public mixed $password = null;
    public mixed $confirmPassword = null;

    #[Assert\Callback]
    public function checkMatch(ExecutionContextInterface $context): void
    {
        if (!is_string($this->password) || !is_string($this->confirmPassword)) {
            return; // Ошибки типов проверяются отдельными ограничениями.
        }
        if ($this->password !== $this->confirmPassword) {
            $context->buildViolation('Пароли не совпадают')
                ->atPath('confirmPassword')
                ->addViolation();
        }
    }
}

Этот фрагмент показывает только межполевую проверку. Полный сценарий дополнительно проверяет тип, длину и обязательность каждого значения. Callback добавляет нарушение через контекст; return false не является способом сообщить ошибку. Пароль и его подтверждение не нужно возвращать в JSON ошибок или записывать в журнал.

Документация: Callback и привязка нарушения к полю.

Такая проверка нужна и при смене пароля, и при приглашении сотрудника во внутренний сервис. Ограничения длины в демонстрации показывают механизм; политика паролей конкретного продукта может отличаться. Проверка уникальности логина потребует обращения к хранилищу, а создание учётной записи — отдельной операции. Здесь посетитель получает только проверку набора, поэтому результат не обещает, что логин свободен.

Открыть проверку регистрации ↑

3. Расчёт стоимости: числа, диапазоны и пустые значения

У позиции есть название, количество, цена за единицу и скидка. Количество должно быть больше нуля и не превышать 10 000; цена — от 0 до 1 000 000; скидка — от 0 до 50%. Для этого примера допускается не больше двух знаков после десятичной точки.

Попробуйте: задайте количество 0, затем отрицательную цену и скидку 60. Исправьте их по очереди. Итог следует считать только после проверки всего набора: число в карточке не должно выглядеть достоверным, если одна из его составляющих недопустима.

$quantityRules = new Assert\Sequentially([
    new Assert\NotNull(message: 'Укажите количество'),
    new Assert\Type(type: ['int', 'float'], message: 'Количество должно быть числом'),
    new Assert\GreaterThan(value: 0, message: 'Количество должно быть больше нуля'),
    new Assert\LessThanOrEqual(value: 10000, message: 'Максимум — 10000'),
]);
// Проверка количества знаков после точки — отдельное правило примера.
$violations = $validator->validate($input['quantity'] ?? null, $quantityRules);

JSON-число 2 и строка "2" различаются. Наш обработчик не превращает строку в число; форма выполняет явное преобразование корректного числового ввода, а пустоту передаёт как null. Это защищает от распространённой ошибки, когда пустое поле незаметно становится нулём.

function readNumber(input) {
  const raw = input.value.trim();
  if (raw === '') return null;
  const value = Number(raw);
  if (!Number.isFinite(value)) throw new Error('Введите конечное число');
  return value;
}

Validator проверяет диапазон и тип, но не определяет финансовую модель. Округление, допустимая точность и работа с копейками должны быть согласованы отдельно. Нулевая цена может означать бесплатную позицию; нулевое количество в этом сценарии запрещено. Одинаковая проверка «не пусто» для обоих полей не описывает такую разницу.

Контрольный набор: количество 2, цена 1500, скидка 10%. После успешной проверки получается 2700. Сценарий можно использовать для строки коммерческого предложения, калькулятора услуги или редактора заказа. В этих интерфейсах важно хранить отдельно введённые значения, результат проверки и рассчитанный итог, чтобы ошибка не оставляла на экране сумму от предыдущего набора.

Открыть расчёт стоимости ↑

4. Период работ: формат даты и связь двух полей

Корректный формат ещё не делает период корректным. Сначала проверяем календарные даты Y-m-d, затем правило «окончание не раньше начала» и ограничение разницы в 366 суток. Одинаковые даты допустимы: разница равна нулю, дополнительный день к ней не прибавляется.

Попробуйте: поменяйте даты местами. Ошибка должна появиться у окончания. Затем задайте одинаковые даты и проверьте допустимость такого периода. Для календарной формы это понятнее, чем сравнивать случайно выбранные часовые пояса или время полуночи.

function parseIsoDate(mixed $value): ?DateTimeImmutable
{
    if (!is_string($value) || !preg_match('/^\d{4}-\d{2}-\d{2}$/D', $value)) {
        return null;
    }
    $date = DateTimeImmutable::createFromFormat('!Y-m-d', $value);
    return $date && $date->format('Y-m-d') === $value ? $date : null;
}

$periodRelation = new Assert\Callback(
    static function (mixed $data, ExecutionContextInterface $context): void {
        if (!is_array($data)) return;
        $start = parseIsoDate($data['start'] ?? null);
        $end = parseIsoDate($data['end'] ?? null);
        if (!$start || !$end) return; // Формат проверяют отдельные Date/NotBlank.
        if ($end < $start) {
            $context->buildViolation('Окончание не может быть раньше начала')
                ->atPath('[end]')->addViolation();
        } elseif ((int) $start->diff($end)->days > 366) {
            $context->buildViolation('Период не должен превышать 366 суток')
                ->atPath('[end]')->addViolation();
        }
    }
);

Проверка связи не должна добавлять вторую бессмысленную ошибку поверх неверного формата. Поэтому сначала убеждаемся, что обе даты распознаны. Для бронирования с часами и минутами потребуется отдельный договор о часовом поясе и границах интервала; календарный пример не решает эти вопросы автоматически.

Документация: календарная дата и допустимость пустого значения.

Начальный период 2026-11-06 → 2026-11-16 даёт 10 суток. Это подходит для интервала отчёта, планирования работ или срока действия заявки. Если продукт считает оба календарных дня включительно, формула и объяснение должны измениться вместе. Не заменяйте бизнес-правило случайной арифметикой только потому, что поле уже прошло проверку даты.

Открыть период работ ↑

5. Вложенный заказ: Collection, All и точный путь ошибки

Заказ содержит email покупателя, способ получения и от одной до десяти позиций. У каждой позиции свой артикул, целое количество от 1 до 99 и цена. Collection описывает поля одного объекта-массива, а All применяет одинаковую проверку к каждому элементу списка.

Попробуйте: испортите количество во второй позиции. В ответе нужен путь именно к этой строке, а не общее «ошибка заказа». Для пути items.1.quantity индекс 1 означает вторую позицию; в подписи интерфейса человеку удобнее видеть номер с единицы.

$itemRules = new Assert\Collection([
    'sku' => new Assert\Sequentially([
        new Assert\Type('string'), new Assert\NotBlank(),
        new Assert\Length(min: 2, max: 24),
    ]),
    'quantity' => new Assert\Sequentially([
        new Assert\NotNull(), new Assert\Type('int'),
        new Assert\Range(min: 1, max: 99),
    ]),
    'price' => new Assert\Sequentially([
        new Assert\NotNull(), new Assert\Type(['int', 'float']),
        new Assert\Range(min: 0, max: 1000000),
    ]),
]);
$itemsRules = new Assert\Sequentially([
    new Assert\NotNull(), new Assert\Type('array'),
    new Assert\Count(min: 1, max: 10),
    new Assert\All([new Assert\Sequentially([
        new Assert\NotNull(), new Assert\Type('array'), $itemRules,
    ])]),
]);

Это фрагмент правил позиций; верхний уровень дополнительно проверяет покупателя и значение pickup либо courier. Наличие ключа и непустое значение различаются: Required требует ключ, а NotBlank проверяет содержимое. Optional разрешает отсутствие поля, но переданное значение всё равно проходит вложенные правила.

$optionalNote = new Assert\Collection([
    'title' => new Assert\Required([new Assert\NotBlank()]),
    'note' => new Assert\Optional([new Assert\Sequentially([
        new Assert\Type('string'), new Assert\Length(max: 200),
    ])]),
]);

Документация: Collection, Required и Optional.

Документация: проверка каждого элемента через All.

В корректном заказе две строки: KB-01 — две единицы по 1500 и MS-02 — одна по 900. После проверки сводка показывает 3 единицы и 3900. Тот же способ адресации ошибок пригодится для редактора сметы или комплектации оборудования: номер строки сохраняет контекст, даже когда в нескольких позициях одновременно неверно количество.

Открыть вложенный заказ ↑

6. Импорт строк: исправляем ошибки по месту

При пакетном вводе одна неправильная строка не должна теряться среди остальных. В примере проверяются от одной до десяти записей: имя 2–80 символов, email и целый возраст 18–120. Пользователь видит номер строки и поле, а затем исправляет конкретное значение.

Попробуйте: добавьте строку с неправильным email и возрастом 17. После проверки исправьте только одну ошибку и сравните новый ответ. Важно различать количество нарушений и количество ошибочных строк: в одной записи может быть несколько проблем.

$byRow = [];
foreach ($violations as $violation) {
    $path = $violation->getPropertyPath();
    if (preg_match('/^\[rows\]\[(\d+)\]\[([^]]+)\]$/', $path, $match)) {
        $rowNumber = (int) $match[1] + 1;
        $byRow[$rowNumber][$match[2]][] = $violation->getMessage();
    }
}

Этот фрагмент группирует стандартные пути массива для отчёта. Сам проект отдаёт клиенту согласованный вариант с точками, например rows.1.email. Выберите один формат и используйте его последовательно. В реальном импорте отдельно решается, принимать ли хорошие строки частично или отклонять пакет целиком; текущий пример ничего не записывает.

Корректный набор содержит две записи с возрастом 28 и 35. В наборе с ошибками строковое "35" специально отличается от числа 35: это помогает заметить проблему преобразования CSV или внешнего JSON. При настоящем импорте преобразование выполняют явно до предметной проверки, а не незаметным приведением каждого сомнительного значения к нулю.

Открыть проверку импорта ↑

Пустота, типы и нормализация: самые важные различия

NotNull запрещает только null. NotBlank также отвергает пустую строку, пустой массив и false, но разрешает 0 и "0". Пробелы не считаются пустотой автоматически. Email сам разрешает null и пустую строку: обязательность задаётся отдельно.

$validator->validate('', new Assert\NotNull());    // 0 нарушений
$validator->validate('', new Assert\NotBlank());   // 1 нарушение
$validator->validate(0, new Assert\NotBlank());    // 0 нарушений
$validator->validate('', new Assert\Email());      // 0 нарушений
$validator->validate('   ', new Assert\NotBlank(normalizer: 'trim')); // 1

Sequentially останавливает цепочку на первой ошибке. Это полезно не только для краткого сообщения: массив не попадёт в строковую проверку, а дорогостоящая проверка не запустится после очевидной ошибки формата. Ограничения описывайте в том порядке, в котором пользователю разумно исправлять ввод.

$rules = new Assert\Sequentially([
    new Assert\Type('string'),
    new Assert\NotBlank(normalizer: 'trim'),
    new Assert\Length(min: 2, max: 80, normalizer: 'trim'),
]);

Normalizer меняет значение только внутри конкретной проверки. Он не переписывает DTO и не передаёт автоматически обрезанную строку следующему ограничению. Если бизнес-логике нужны очищенные данные, создайте отдельную нормализованную копию после проверки типов. Исходные и подготовленные значения не стоит незаметно смешивать.

$rawName = $input['name'] ?? null;
if (!is_string($rawName)) {
    // Вернуть ошибку типа поля; trim массива здесь недопустим.
} else {
    $normalizedName = trim($rawName);
    $violations = $validator->validate($normalizedName, [
        new Assert\NotBlank(), new Assert\Length(min: 2, max: 80),
    ]);
}

Документация: NotBlank и normalizer.

Документация: порядок ограничений и остановка цепочки.

DTO и PHP-атрибуты без полного Symfony

DTO — простой объект, в котором собраны входные данные. Рядом с полем удобно видеть его правила. Для такого формата в самостоятельном приложении нужно включить чтение атрибутов: иначе класс может выглядеть правильно, а ограничения не будут загружены.

final class ContactInput
{
    #[Assert\Sequentially([
        new Assert\Type('string'),
        new Assert\NotBlank(normalizer: 'trim'),
        new Assert\Email(normalizer: 'trim'),
    ])]
    public mixed $email = null;
}

$dto = new ContactInput();
$dto->email = $input['email'] ?? null;
$objectValidator = Validation::createValidatorBuilder()
    ->enableAttributeMapping()
    ->getValidator();

$violations = $objectValidator->validate($dto);

Входное свойство здесь имеет тип mixed намеренно. Если присвоить массив свойству string, PHP может выбросить TypeError ещё до запуска Validator. Сначала получите понятные ошибки входного контракта, затем переносите проверенные значения в строго типизированную модель приложения.

Указывайте начальные значения свойств. Не рассчитывайте, что неинициализированное поле автоматически выразит различие между «не прислано» и «прислан null». Если это различие важно для PATCH или обновления профиля, учитывайте наличие ключа отдельно от его значения.

Документация: загрузка атрибутов в standalone-компоненте.

Документация: Type и ограничения типизированных свойств PHP.

Группы и последовательность: одни данные, разные действия

Для черновика достаточно заголовка, а для публикации нужно описание. Такое отличие можно выразить группами, не копируя весь набор правил. Ограничения без явно заданной группы относятся к Default; при вызове другой группы она не подключается автоматически.

final class DraftInput
{
    #[Assert\NotBlank]
    public mixed $title = null;

    #[Assert\NotBlank(groups: ['publish'])]
    public mixed $description = null;
}

$draft = new DraftInput();
$draftErrors = $objectValidator->validate($draft);
$publishErrors = $objectValidator->validate($draft, null, ['Default', 'publish']);

Список групп запускает выбранные проверки, а GroupSequence добавляет порядок: следующая группа выполняется, только если предыдущая прошла. Например, нет смысла проверять готовность к публикации, пока отсутствует базовое название.

$sequence = new Assert\GroupSequence(['Default', 'publish']);
$violations = $objectValidator->validate($draft, null, $sequence);

Это передача последовательности непосредственно в validate(). При объявлении последовательности на классе используются дополнительные правила с группой имени класса. Не путайте группы проверки с ролями доступа: выбор publish не доказывает, что пользователь имеет право публиковать материал.

Документация: явный выбор групп проверки.

JSON API: 400, 422 и ошибки, пригодные для интерфейса

Запрос примеров содержит имя разрешённого сценария и данные. Некорректный JSON не дошёл до предметной проверки — это ошибка запроса. Корректный JSON с недопустимым значением поля получает 422. Успешная проверка возвращает 200; эти статусы выбирает обработчик, а не сам компонент Validator.

{
  "scenario": "period",
  "data": { "start": "2026-11-06", "end": "2026-11-05" }
}

Обработчик принимает только POST, ограничивает размер запроса и известные сценарии. В ответе есть valid, список violations, сводка и сведения о движке. Внутренний путь Symfony для массива выглядит как [items][0][quantity]; наш API приводит его к items.0.quantity. Это соглашение проекта, а не автоматическое требование библиотеки.

$errors = [];
foreach ($violations as $violation) {
    $errors[] = [
        'path' => $violation->getPropertyPath(),
        'message' => $violation->getMessage(),
        'code' => $violation->getCode(),
    ];
}
http_response_code($errors === [] ? 200 : 422);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['valid' => $errors === [], 'violations' => $errors],
    JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);

Это минимальный фрагмент формирования ответа без преобразования пути. Не добавляйте к нему getInvalidValue() «для удобства отладки»: в поле может находиться пароль или другое значение, которое не нужно повторять в ответе. Сообщение должно описывать правило, а путь — помогать найти поле.

async function validateData(scenario, data) {
  const response = await fetch('./api.php', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
    body: JSON.stringify({ scenario, data })
  });
  if (response.status !== 200 && response.status !== 422) {
    throw new Error('Проверка сейчас недоступна');
  }
  const result = await response.json();
  if (typeof result.valid !== 'boolean' || !Array.isArray(result.violations)) {
    throw new Error('Неожиданный ответ проверки');
  }
  return result;
}

В полноценной форме нужно также отменять устаревший запрос или игнорировать его ответ, если данные уже изменились. Сетевой сбой и нарушение правила — разные состояния. 422 разрешает пользователю исправить поля; отсутствие ответа не даёт оснований считать ввод ни корректным, ни некорректным.

После валидации остаются права доступа, CSRF для соответствующей модели сессии, запись в хранилище и безопасный вывод. Исключения удобно разбирать через Monolog, а настройки окружения — через phpdotenv. В журнал ошибок не стоит помещать весь запрос формы.

Как показать ошибки, чтобы форму действительно исправили

Одного красного заголовка «Ошибка» мало. Покажите краткую сводку, свяжите каждое сообщение с полем и сохраните введённые значения. Если ошибочных полей несколько, пользователь должен понимать объём работы, а не обнаруживать по одному новому отказу после каждого запроса.

function showFieldError(input, message, errorElement) {
  errorElement.textContent = message;
  errorElement.hidden = false;
  input.setAttribute('aria-invalid', 'true');
  const ids = new Set((input.getAttribute('aria-describedby') || '')
    .split(/\s+/).filter(Boolean));
  ids.add(errorElement.id);
  input.setAttribute('aria-describedby', [...ids].join(' '));
}

Этот фрагмент добавляет сообщение, сохраняя связь с уже существующей подсказкой. При новой проверке устаревшую ошибку нужно снять. Пути из ответа сопоставляйте с известными полями, а текст помещайте через textContent. Не стройте HTML и CSS-селектор простым соединением непроверенной строки.

На телефоне подпись, значение и пояснение должны оставаться рядом; длинный JSON и код прокручиваются внутри своего блока. После отправки удобно перевести фокус к сводке или первому ошибочному полю, но не перехватывать его при каждом символе. Успешное завершение можно показать коротким уведомлением, например через SweetAlert2; сами ошибки формы лучше оставить непосредственно возле ввода.

Типичные ошибки и короткая проверка своего набора правил

Проблема Причина Исправление
Пустой email проходит Email проверяет формат необязательного значения Добавить NotBlank там, где поле обязательно
Атрибуты не работают Standalone builder не читает их автоматически Включить enableAttributeMapping
Массив вызвал ошибку строковой функции Нормализация раньше проверки типа Сначала Type или явная проверка, затем trim/Length
Ноль считается отсутствием Использовано empty() или нестрогое сравнение Разделить null, пустую строку и допустимое 0
Правила потерялись при выборе группы Default не включён в вызов Явно передать нужные группы
Ошибка попала не в ту строку Перепутаны индекс массива и номер для человека Сохранять путь и отдельно формировать подпись
Зелёный результат остался после правки UI показывает ответ для старого ввода Снять результат и выполнить новую проверку

Для каждого правила возьмите корректное значение, ошибочное, пустоту и граничные случаи. Добавьте неожиданный тип: объект или массив вместо строки, строку вместо числа. Это важнее, чем проверять только подготовленный счастливый пример.

$cases = [
    ['value' => '', 'expected' => 1],
    ['value' => '   ', 'expected' => 1],
    ['value' => 'Анна', 'expected' => 0],
    ['value' => ['Анна'], 'expected' => 1],
];
foreach ($cases as $case) {
    $actual = count($validator->validate($case['value'], $rules));
    if ($actual !== $case['expected']) {
        throw new RuntimeException('Неожиданный результат проверки');
    }
}

В этом фрагменте $rules — строковая цепочка из раздела о нормализации. Для API отдельно проверьте синтаксически неверный JSON, неизвестный сценарий, превышение лимита и недоступность сервера. Ошибка в настройке проверяющего механизма не должна маскироваться под обычную ошибку поля.

Исходники, локальный запуск и официальные источники

Скачайте проект, распакуйте его и откройте терминал в каталоге с composer.json. Установите зависимости из lock-файла, затем запустите сервер с корнем public. Открытие HTML через file:// не запускает PHP и не заменяет этот шаг.

composer install
composer check-platform-reqs
php -S 127.0.0.1:8080 -t public

Скачать проект Symfony Validator с шестью примерами и мастерской. После запуска откройте http://127.0.0.1:8080/, повторите упражнение с известной ошибкой, затем замените подготовленные данные. Для внедрения начните с одной формы и общего формата ответа.

Полезный результат этой работы — один понятный договор о данных. Форма помогает человеку ввести значение, PHP независимо проверяет его, ответ указывает точное место ошибки, а приложение выполняет действие только после успешной проверки и проверки прав. Этот договор одинаково пригодится для небольшого сайта, внутреннего сервиса и отдельного API.