24 сент. 2025 г.·8 мин чтения

Пакеты валидации PHP API для тонких контроллеров и DTO

Сравните пакеты валидации PHP API, мапперы запросов и сериализаторы, чтобы контроллеры были чище, DTO — понятнее, а клиенты могли быстро исправлять ошибки API.

Пакеты валидации PHP API для тонких контроллеров и DTO

Почему эта проблема возникает в PHP API

PHP очень легко позволяет начать эндпоинт с сырых данных запроса и пары быстрых проверок. В первый день это удобно. Через месяц контроллер часто превращается в ворох isset()-проверок, ручных приведений типов, значений по умолчанию и собственных массивов ошибок.

Простой action «создать пользователя» легко начинает выполнять пять задач сразу. Он читает query-параметры или JSON, превращает строки в int или bool, проверяет обязательные поля, форматирует ошибки валидации, вызывает бизнес-логику, а затем заново собирает ответ для клиента. Контроллер перестаёт быть тонкой точкой входа и становится местом, куда сваливается каждое маленькое правило.

Из-за такой смеси проблемы с тестами появляются быстро. Если парсинг, валидация, бизнес-правила и форматирование ответа живут в одном методе, каждый тест должен покрывать слишком много веток. Небольшое изменение текста ошибки или приведения типов может сломать тесты, которые должны были проверять поведение бизнеса, а не обработку запроса.

Слабая обработка входных данных ещё сильнее усугубляет проблему, потому что скрывает ошибки контракта. Один контроллер может принять "42" как целое число, другой — отклонить его, а третий тихо проигнорирует неизвестные поля. Локально всё выглядит нормально, а потом продакшен находит дыры. Мобильное приложение отправляет null туда, где ожидался массив, или партнёрская система присылает лишнее поле с опечаткой, и теперь сбой трудно отследить.

Именно поэтому важны пакеты валидации PHP API, мапперы запросов и инструменты сериализации. Они уводят повторяющуюся работу в код, у которого одна понятная задача. Контроллер может снова делать совсем немного: принять запрос, передать его мапперу или валидатору, вызвать сервис приложения и вернуть оформленный ответ.

Такое разделение делает ошибки контракта заметными раньше. И ещё оно упрощает чтение кода. Когда контроллер помещается на один экран, команды обычно замечают ошибки раньше пользователей.

Что сравнить перед выбором

Большинство пакетов похожи по списку возможностей, но ломаются в разных местах, когда в ваш API начинают приходить реальные payload'ы. Когда сравниваете пакеты валидации PHP API, смотрите не на маркетинговые обещания, а на то, что происходит, когда клиент присылает плохие данные.

Начните с сообщений об ошибках. Хороший инструмент точно показывает, какое поле не прошло проверку, почему это произошло и какой тип ожидался. Если email отсутствует или age приходит строкой вместо целого числа, пакет должен указать именно на это поле, а не заставлять вас разбираться в общей исключительной ситуации.

Вложенные структуры важны не меньше. Многие API принимают JSON вроде заказа с данными клиента, позициями и деталями доставки. Если пакет хорошо справляется с плоским вводом, но становится неудобным на вложенных массивах и типизированных объектах, ваши контроллеры снова начнут разрастаться ручным маппингом.

Внимательно посмотрите на неудобные значения ещё до выбора. Даты обычно ломаются первыми. Потом идут enum'ы. Затем начинается путаница с null, особенно когда в одном поле null означает «намеренно пусто», а в другом — «отсутствует».

Совместимость с фреймворком важна, но не должна вас запирать. Некоторые инструменты естественно ощущаются в Symfony, потому что встроены в жизненный цикл запроса. Другие лучше подходят для Laravel с кастами, form request'ами или привязкой через контейнер. Если вы работаете на чистом PHP, проверьте, остаётся ли пакет аккуратным без кучи собственного клея.

Быстрая проверка помогает больше, чем длинная сравнительная таблица:

  • Смаппить вложенный JSON-пейлоад в DTO
  • Отправить отсутствующее обязательное поле
  • Отправить неверный тип внутри дочернего объекта
  • Разобрать одно поле с датой и одно поле с enum'ом
  • Разрешить null в одном свойстве и запретить его в другом

Время настройки — последняя проверка, а не первая. Пакет, на подключение которого уйдёт на 30 минут больше, всё равно может оказаться лучшим выбором, если он уберёт 200 строк кода из контроллера и сделает ошибки контракта очевидными уже в первый день. Тонкие контроллеры — это не про меньшее количество файлов. Это про перенос парсинга, валидации и формирования ответа туда, где ошибки легко увидеть.

Пакеты, которые валидируют входные данные чисто

Хорошая валидация заставляет плохие данные падать раньше, ещё до того, как ваш контроллер коснётся бизнес-логики. Это важнее, чем признаёт большинство команд. Как только сырые данные запроса начинают просачиваться глубже в приложение, ошибки контракта становится сложнее отследить и гораздо сложнее объяснить клиентам.

Среди пакетов валидации PHP API Symfony Validator — самый безопасный вариант по умолчанию, если вам нужны понятные правила и понятные сообщения об ошибках. Он даёт сильные ограничения для строк, чисел, дат, массивов, вложенных объектов и собственных правил. Он также хорошо сообщает ошибки на уровне поля, поэтому API может возвращать полезные сообщения вроде «email недействителен» или «age должен быть не меньше 18», а не расплывчатый ответ 400.

Respect/Validation подходит для plain PHP-проектов, которые не хотят зависеть от полного фреймворка. Его правила читаются просто, и вы можете добавлять их почти куда угодно. Это облегчает внедрение в старые кодовые базы или небольшие сервисы, хотя вокруг формата ошибок обычно приходится строить больше вручную.

Laravel Validator и Form Requests работают быстро, если ваш API уже живёт внутри Laravel. Вы получаете валидацию запросов, проверки авторизации и читаемые ответы об ошибках почти без настройки. Компромисс — переносимость. Если позже вы вынесете логику в общий пакет или в сервис без Laravel, этот слой валидации уже не переедет так гладко.

Где подходит каждый инструмент

Symfony Validator лучше всего работает, когда у ваших payload'ов есть вложенные данные и вам нужен единый формат ошибок на множестве эндпоинтов. Respect/Validation лучше, когда нужен лёгкий пакет и вы сами контролируете поток запроса. Laravel Form Requests — самый быстрый вариант, если вся команда и так пишет по-ларевельному.

Одной только валидации достаточно, когда входные данные просты, а эндпоинт должен лишь отклонять некорректный ввод.

  • Обязательные поля должны присутствовать
  • Типы должны совпадать
  • Длина строк или диапазоны должны быть в пределах нормы
  • Небольшому набору полей нужны проверки формата, например email, UUID или дата

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

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

Инструменты, которые маппят запросы в DTO

Маппер превращает свободные массивы запроса в типизированные объекты ещё до запуска логики контроллера. Это звучит как мелочь, но меняет весь поток. Контроллер перестаёт доставать значения из $request по одному, а плохие данные ломаются у границы приложения.

Valinor — самый строгий вариант. Он маппит массивы в обычные PHP-объекты и выдаёт очень точные ошибки типов, когда вход не совпадает. Если поле должно быть int, а клиент присылает "abc", Valinor покажет, какой путь не прошёл проверку, даже во вложенных данных вроде items[2].price. Так ошибки контракта легко заметить.

Spatie Laravel Data ощущается плавнее в Laravel-проектах. Он хорошо сочетается с request-классами, валидацией, кастами и созданием DTO так, что команды Laravel обычно быстро осваивают его. Значения по умолчанию легко описывать прямо в data-объекте, а вложенные данные хорошо работают в обычных случаях вроде заказа с позициями.

Symfony request payload mapping имеет смысл, если вы уже строите проект на Symfony. Вы можете напрямую маппить payload запроса в типизированные объекты и оставить большую часть служебного кода на фреймворк. Это делает контроллеры короткими и согласованными с остальным приложением.

Где проявляются различия

Именно на лишних полях эти инструменты начинают ощущаться по-разному. Некоторые команды игнорируют неизвестный ввод, чтобы быть более терпимыми, но это часто скрывает ошибки клиентов на недели. Valinor по умолчанию строгий, и для публичных API я считаю это плюсом. Symfony и Spatie дают больше свободы в том, как должны вести себя неизвестные поля, в зависимости от настройки.

Важны и значения по умолчанию. Если вы хотите, чтобы отсутствующее значение page превращалось в 1, Spatie Laravel Data делает это легко. Если же вы хотите, чтобы отсутствующие данные считались ошибкой, если только вы не описали их очень аккуратно, Valinor подходит лучше. Symfony находится посередине и больше зависит от того, как вы описали DTO и правила валидации.

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

Для большинства команд разделение довольно простое:

  • Выбирайте Valinor, когда важнее всего строгие типы и точные ошибки.
  • Выбирайте Spatie Laravel Data, если вы работаете в Laravel и хотите быстрый поток DTO.
  • Выбирайте Symfony mapping, если ваше приложение уже следует паттернам Symfony.

Инструменты, которые формируют ответы API

Сравните пакеты в контексте
Получите практический совет по Valinor, Symfony Validator, Laravel Data или собственному DTO-потоку.

Хороший слой ответов делает одну задачу и делает её хорошо: превращает внутренние данные в тот самый JSON, который обещает ваш API. Когда этот слой понятный, контроллеры перестают заниматься мелочами вроде переименования полей, обрезки вложенных данных или ручной сборки строк даты.

Symfony Serializer подходит командам, которым нужен большой контроль без повторного написания одного и того же кода маппинга. Он хорошо работает с переименованием полей, группами сериализации, датами и enum'ами. Если одному эндпоинту нужен created_at, а другому createdAt, вы можете хранить это правило в сериализаторе, а не размазывать его по контроллерам.

JMS Serializer всё ещё имеет смысл в старых Symfony-проектах, особенно если кодовая база уже опирается на аннотации в моделях и DTO. Для нового проекта он может ощущаться тяжеловато, но менять его только ради стиля часто бессмысленно. Если команда уже понимает его правила, а результат предсказуем, оставить его может быть безопаснее.

Laravel API Resources часто оказываются самым простым вариантом для Laravel-команд. Они делают формирование ответа читаемым и держат правила представления рядом с итоговым JSON. Это удобно, когда нужно показывать только часть модели, добавить небольшие вычисляемые поля или упаковать коллекции в единый формат.

Обычный json_encode вместе с DTO ответа по-прежнему часто оказывается хорошим выбором, хотя многие это недооценивают. Если у вашего API немного форм ответа, обычный DTO с явным маппингом может оказаться понятнее, чем сериализатор с множеством скрытых правил. Это также делает изменения контракта заметнее при code review, потому что каждое поле находится в одном месте.

Полезно помнить простое правило:

  • Используйте Symfony Serializer, когда правила вывода меняются от эндпоинта к эндпоинту.
  • Оставляйте JMS Serializer в старых Symfony-приложениях, которые уже на него завязаны.
  • Используйте Laravel API Resources, когда вы уже глубоко внутри Laravel.
  • Используйте DTO плюс json_encode, когда хотите максимально лёгкий стек.

Команды, которым важны тонкие контроллеры, обычно выигрывают, если выбирают один способ формирования ответов и придерживаются его. Смешивать resources, serializers, массивы и ad hoc JSON в одном API быстро становится грязно. Если вы хотите, чтобы ошибки контракта оставались видимыми, скучное и явное обычно побеждает хитроумное.

Как выстроить поток с тонким контроллером

Тонкий контроллер должен делать очень мало. Он должен принять HTTP-запрос, передать данные мапперу, вызвать один сервис и вернуть DTO ответа. Если проверки типов, правила валидации и формирование ответа сидят внутри контроллера, эндпоинт быстро становится грязным.

Начните с одного эндпоинта, а не со всего API. Одного POST-эндпоинта достаточно, чтобы проверить подход. Создайте request DTO с точными полями, которые вы принимаете, и response DTO с точными полями, которые вы отдаёте.

Сначала маппинг и валидация, потом бизнес-логика

Большинство пакетов валидации PHP API работают лучше, когда плохие данные падают раньше. Сначала замаппьте сырые данные запроса в request DTO, а уже потом пусть контроллер зовёт сервис. Затем провалидируйте этот DTO — правила можно держать рядом с каждым полем через атрибуты, метаданные или обычный стиль пакета.

Такой порядок сохраняет спокойствие контроллера. Если age приходит как twenty, а email отсутствует, сервис вообще не запускается. Контроллеру нужна только одна простая развилка: валидный ввод идёт дальше, невалидный возвращает ошибку контракта.

Обычно небольшой поток выглядит так:

  • Декодировать тело запроса
  • Замаппить его в request DTO
  • Провалидировать DTO
  • Передать DTO в application service
  • Замаппить результат в response DTO и сериализовать его

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

{
  "errors": [
    { "field": "email", "code": "required", "message": "email is required" }
  ]
}

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

Тесты помогают держать этот поток честным. Напишите несколько request-level тестов, которые специально отправляют плохие payload'ы. Один со строкой там, где должен быть integer, один без обязательного поля и один с неожиданным полем помогут рано поймать большую часть рассинхронизации контракта.

Когда этот подход начинает работать, контроллеры сокращаются до нескольких строк. Это ускоряет ревью, делает ошибки понятнее и упрощает понимание маппинга DTO.

Реалистичный пример рефакторинга

Привлеките поддержку CTO
Используйте поддержку Fractional CTO для ревью архитектуры, инфраструктуры и рабочих процессов разработки с AI.

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

До

Первая версия обычно забирает значения прямо из массива запроса и смешивает правила транспорта с бизнес-логикой.

public function signup(Request $request): JsonResponse
{
    $data = $request->request->all();

    if (empty($data['email']) || !filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
        return new JsonResponse(['error' => 'Email is invalid'], 422);
    }

    if (empty($data['password']) || strlen($data['password']) < 8) {
        return new JsonResponse(['error' => 'Password must be at least 8 characters'], 422);
    }

    if (!isset($data['consent']) || $data['consent'] !== true) {
        return new JsonResponse(['error' => 'Consent is required'], 422);
    }

    $user = $this->userService->create(
        $data['email'],
        password_hash($data['password'], PASSWORD_BCRYPT),
        $data['consent']
    );

    return new JsonResponse([
        'id' => $user->id,
        'email' => $user->email,
        'created_at' => $user->createdAt->format(DATE_ATOM),
    ], 201);
}

Это работает, но ошибки контракта прячутся в мелких деталях. Один контроллер может ожидать consent === true, другой принимает 1, а третий обрезает email, тогда как остальные этого не делают. Сообщения об ошибках тоже быстро расходятся, потому что каждый action пишет свои строки.

После

Более чистая версия маппит запрос в DTO, валидирует его, передаёт в сервис и сериализует ответ. Обычный стек в PHP — Valinor для маппинга, Symfony Validator для правил входных данных и Symfony Serializer для вывода.

public function signup(Request $request): JsonResponse
{
    $command = $this->mapper->map(SignupRequest::class, $request->toArray());
    $this->validator->validate($command);

    $user = $this->signupService->handle($command);

    return new JsonResponse(
        $this->serializer->normalize(UserResponse::fromUser($user)),
        201
    );
}

Теперь сбои происходят раньше. Если email отсутствует или записан некорректно, маппинг или валидация останавливают запрос до запуска сервиса. Если password слишком короткий, валидатор возвращает ошибку поля, привязанную к password, а не общее сообщение. Если consent false или отсутствует, DTO не доходит до создания аккаунта.

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

Ошибки, которые скрывают ошибки контракта

Многие PHP API выглядят нормально, пока клиенты не начнут отправлять грязный ввод. Потом один и тот же эндпоинт начинает падать тремя разными способами, и никто не может понять, в чём проблема — в запросе, в бизнес-правиле или в пути выполнения. Обычно это происходит, когда команды стирают границу между проверкой контракта и доменной логикой.

Распространённая ошибка — смешивать простую проверку входа с бизнес-правилами. Проверять, что email — это строка, а quantity — целое число, нужно на границе приложения. Проверять, может ли пользователь оформить заказ, нужно глубже в потоке. Когда всё происходит в одном месте, клиенты получают расплывчатые ошибки вроде invalid request вместо проблем с очень разными причинами.

Ещё одна проблема начинается после того, как вы уже замаппили запрос в DTO. Если контроллер или сервис снова возвращаются к свободным массивам, вы теряете безопасность, которую только что создали. DTO говорит: «это поле существует, и у него такой тип». Массив говорит почти ничего. Один helper читает userId, другой ждёт user_id, и контракт снова становится размытым.

Мутация DTO посреди обработки запроса тоже создаёт проблемы. Если входной DTO говорит, что age равен null, а на следующем шаге кто-то тихо меняет его на 0, отлаживать становится очень неприятно. Клиент отправил одно. Ваше приложение обработало другое. Держите request DTO неизменяемыми и создавайте новый объект, когда вам нужны производные данные.

Форма ошибки тоже имеет значение. Если один эндпоинт возвращает:

{"error":"Invalid email"}

а другой возвращает:

{"errors":{"email":["Required"]}}

клиентам приходится писать обработку ошибок под каждый эндпоинт отдельно. Это тратит время и скрывает повторяющиеся паттерны в плохих запросах. Выберите один формат ошибок и используйте его везде.

Самая коварная ошибка — тихое преобразование типов. Клиент отправляет "false", 0 или "123abc", а PHP превращает это во что-то достаточно удобное, чтобы оно прошло дальше. Потом API ведёт себя не так, как клиент когда-либо просил. Хорошие пакеты валидации PHP API помогают только если вы держите строгие типы, рано отклоняете плохие значения и не полагаетесь на преобразование «и так сойдёт».

Если контроллер остаётся тонким, ошибки контракта остаются на виду. Один раз распарсить, один раз провалидировать, один раз замаппить и передать чистые данные дальше.

Быстрые проверки перед тем, как внедрять

Сделайте контроллеры короче
Уберите клеевой код и держите валидацию как можно ближе к границе приложения.

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

Если сравниваете пакеты валидации PHP API, начните с сообщений об ошибках. Хороший инструмент должен указывать на точный путь к полю, а не просто говорить, что payload неправильный. Если клиент отправляет customer.address.postcode, а ответ говорит только address is invalid, кто-то потратит лишнее время на догадки.

Используйте этот короткий чеклист, когда пробуете пакет на реальном запросе:

  • Он показывает точный путь к ошибке, включая вложенные массивы и объекты.
  • Он маппит вложенные данные в DTO без ручной возни с массивами.
  • Он позволяет запускать те же правила в HTTP-запросах, тестах и задачах очереди.
  • Он работает с жизненным циклом запросов вашего фреймворка, а не заставляет городить странные обходные пути.
  • После него у вас остаётся DTO, который другой разработчик сможет прочитать за 30 секунд.

Вложенный маппинг важнее, чем ожидает большинство команд. Плоские примеры выглядят просто, но реальные payload'ы обычно содержат позиции, опции и дочерние объекты. Если пакет разваливается, как только вы добавляете items[2].price или billing.contact.email, контроллер в итоге начнёт исправлять структурные проблемы, которые должен был закрыть маппер.

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

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

Ещё одна простая проверка: откройте файл DTO, не глядя в документацию. Сможет ли коллега понять, что API принимает, что необязательно и что приводит к ошибке валидации? Если нет, контракт всё ещё скрыт. Обычно это означает больше багов, более медленные ревью и контроллеры, которые снова начинают разрастаться.

Следующие шаги для вашей команды

Выберите один эндпоинт, который регулярно вызывает трение. Формы регистрации, запросы на оформление заказа или партнёрские webhook'и — этого достаточно. Рефакторьте только этот поток, а потом измерьте, что изменилось: сколько строк ушло из контроллера, сколько ручных if-проверок исчезло и сколько ответов об ошибках теперь следуют одному понятному формату.

Этот небольшой тест говорит больше, чем долгий спор. Если контроллер уменьшился, DTO стало легко читать, а плохие данные падают в одном очевидном месте, вы двигаетесь в правильную сторону. Если после одного эндпоинта стек кажется тяжёлым, остановитесь и упростите.

Короткое правило для команды помогает больше, чем длинный style guide. Держите его простым:

  • Контроллеры принимают запрос и быстро передают его дальше.
  • DTO хранят типизированный ввод, а не бизнес-логику.
  • Валидация выполняется до запуска сервисного кода.
  • Формирование ответа происходит в одном месте, а не внутри каждого контроллера.

Запишите эти правила там, где команда и так работает. Двух-трёх предложений в проектной документации достаточно, если все им следуют.

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

Хорошая контрольная точка после первого рефакторинга проста: сможет ли новый разработчик открыть эндпоинт и за несколько минут понять форму запроса, правила валидации и формат ответа? Если нет, режьте дальше.

Если стартапу нужен внешний взгляд, Oleg Sotnikov предлагает консультации Fractional CTO по архитектуре, инфраструктуре и рабочим процессам разработки с AI. Такой обзор особенно полезен, когда вам нужен лёгкий набор для маппинга запросов в PHP и DTO без превращения маленького API в framework-проект.

Сделайте один эндпоинт хорошо, повторите паттерн и сделайте следующий эндпоинт проще предыдущего.