Валидация массивов в Laravel без N+1: Form Request как построитель контекста
exists в Form Request незаметно порождает N+1 запросов при валидации массивов — каждый элемент проверяется отдельным обращением к базе. Разбираем, как с помощью prepareForValidation() выполнить один запрос, сохранить точные сообщения об ошибках и не скатиться в архитектурный костыль.Введение
Когда пользователь отправляет корзину на эндпоинт оформления заказа, мы обязаны исходить из предположения, что полезная нагрузка враждебна. Злоумышленник может подставить произвольные идентификаторы товаров, указать ID из черновиков или архивных записей, либо сослаться на товары, принадлежащие другому арендатору в мультитенантной системе. Наша задача — отклонить такой запрос до того, как он достигнет базы данных или платёжного шлюза.
Сложность в том, что самый очевидный способ проверить эти входные данные — применение правила exists:products,id к каждому элементу массива — незаметно порождает классическую проблему N+1 запросов. Мы разберём, как валидировать вложенные массивы безопасно, не оплачивая этот скрытый счёт производительностью.
TL;DR
Проблема: правило exists для items.*.product_id выполняется отдельно для каждого элемента массива. Корзина из двадцати позиций генерирует двадцать запросов SELECT COUNT(*) ещё до начала основной логики контроллера.
Решение: собрать все идентификаторы из запроса, выполнить один запрос к базе в методе prepareForValidation(), закешировать результат в свойстве класса и проверять каждый элемент массива по этому кешу через замыкание. Вы получаете один запрос к базе и точные сообщения об ошибках с указанием конкретного индекса, например items.2.product_id.
Мы не просто применим трюк, а обоснуем, почему использование prepareForValidation() для предзагрузки контекста валидации является легитимным архитектурным решением, расширяющим ответственность Form Request как самодостаточного объекта передачи данных.
Цена встроенного правила exists
Рассмотрим типичный пейлоад, который приходит на эндпоинт оформления заказа. Клиент отправляет массив товаров, где каждый элемент содержит идентификатор продукта и желаемое количество. На стороне сервера мы обязаны убедиться, что каждый переданный product_id действительно существует в базе и принадлежит опубликованному товару. Пропустить проверку нельзя, молчаливо проигнорировать несуществующий идентификатор — тоже, поскольку это открывает возможности для перебора и разведки структуры данных.
Начинающий разработчик, вооружённый документацией Laravel, напишет Form Request примерно так:
// CheckoutRequest
public function rules(): array
{
return [
'items' => ['required', 'array', 'min:1'],
'items.*.product_id' => [
'required',
'integer',
Rule::exists('products', 'id')->where('status', 'published'),
],
'items.*.quantity' => ['required', 'integer', 'min:1'],
];
}Если синтаксис items.*.product_id с подстановочным символом * вызывает у вас вопросы, загляните в статью «Простая целостность данных с валидацией массивов в Laravel», где подробно разобраны правила для вложенных структур и работа с массивами в валидации.
Подробнее о работе с массивами в правилах валидации можно прочитать в официальной документации, а синтаксис правила exists детально разобран в соответствующем разделе.
Правила выглядят исчерпывающе. Массив обязателен, каждый вложенный идентификатор проверяется на существование в таблице products с дополнительным условием по статусу публикации, количество товара должно быть положительным целым числом. Код получается лаконичным и декларативным.
Более того, сообщения об ошибках, которые сгенерирует такой Form Request, обладают хирургической точностью. Если третий элемент массива содержит несуществующий идентификатор, Laravel вернёт ошибку с ключом items.2.product_id, и фронтенд-разработчик сможет подсветить проблемную строку в корзине пользователя, не заставляя его гадать, какой именно товар снят с продажи.
Проблема спрятана под капотом. Правило exists выполняется отдельно для каждого элемента массива, и Laravel честно запрашивает базу данных для каждого вхождения. Механика проста: фреймворк итерируется по массиву items, извлекает product_id, формирует запрос SELECT COUNT(*) FROM products WHERE id = ? AND status = 'published' и повторяет это для каждой позиции. Корзина из пяти товаров порождает пять запросов. Корзина из двадцати — двадцать. И это происходит до того, как валидация завершится и управление перейдёт к контроллеру.
Для небольшого интернет-магазина с низким трафиком такое поведение может оставаться незамеченным месяцами. Но масштабирование линейно: каждый дополнительный товар в корзине добавляет ровно один запрос к базе данных, и злоумышленник, намеренно отправляющий запросы с сотней позиций, получает простой и элегантный вектор для истощения ресурсов вашего сервера, даже не проходя аутентификацию.
Мы оказываемся перед дилеммой. Хотим сохранить точность указания на проблемный элемент массива, но не готовы платить N+1 запросами. Следующий шаг — попытка решить проблему производительности через метод after(), пожертвовав качеством обратной связи.
Попытка сэкономить: метод after() и потеря контекста
Первое, что приходит в голову для устранения N+1 — перенести проверку существования из правил валидации в метод after(), где мы можем собрать все идентификаторы и выполнить один запрос через whereIn. Документация описывает after как хук для дополнительной валидации, выполняемой после основных правил. Код меняется незначительно, но поведение становится принципиально иным:
// CheckoutRequest
public function rules(): array
{
return [
'items' => ['required', 'array', 'min:1'],
'items.*.product_id' => ['required', 'integer'],
'items.*.quantity' => ['required', 'integer', 'min:1'],
];
}
public function after(): array
{
return [
function (Validator $validator) {
$submittedIds = collect($validator->getData()['items'] ?? [])
->pluck('product_id')
->filter();
$validIds = Product::query()
->whereIn('id', $submittedIds)
->where('status', 'published')
->pluck('id');
if ($submittedIds->diff($validIds)->isNotEmpty()) {
$validator->errors()->add(
'items',
'One or more of the submitted products are invalid.'
);
}
},
];
}С точки зрения нагрузки на базу данных мы достигли идеала. Вне зависимости от количества позиций в корзине выполняется ровно один запрос, который возвращает только те идентификаторы, что прошли фильтр по статусу публикации. Разница между переданным набором и полученным из базы вычисляется в памяти, и если она не пуста, мы добавляем ошибку в коллекцию валидатора.
Но присмотритесь к ключу, по которому добавляется сообщение об ошибке: items. Это общая ошибка на весь массив, а не на конкретный элемент. Пользователь, отправивший корзину из пяти товаров, один из которых оказался недоступен, увидит размытое «один или несколько товаров недоступны» и будет вынужден вручную перепроверять каждую позицию. Для интернет-магазина с конверсией, зависимой от плавности оформления заказа, такой UX неприемлем.
Есть и менее очевидное последствие, связанное с эксплуатацией. Когда вы получаете предупреждение о попытке подбора идентификаторов, вы хотите видеть, какой именно ID был подставлен злоумышленником и на какой позиции в массиве он находился. Метод after() агрегирует все нарушения в одно сообщение, лишая вас этой телеметрии. Вы знаете, что атака была, но не знаете её точный вектор.
Нам нужно решение, которое сочетает единственный запрос к базе из подхода с after() и хирургическую точность указания на проблемный элемент из наивного подхода. И такое решение существует, причём встроенное в архитектуру самого Form Request.
Предзагрузка контекста через prepareForValidation()
Архитектура Form Request в Laravel предоставляет метод prepareForValidation(), который выполняется до запуска правил валидации. В документации Laravel этот метод описан как инструмент для подготовки и нормализации данных перед валидацией — классический пример включает очистку строк или добавление вычисляемых полей.
Однако возможность выполнить произвольный код до старта валидации открывает более интересный сценарий. Мы можем собрать все переданные идентификаторы, выполнить один запрос к базе данных и сохранить результат в свойстве класса. Затем, внутри правил валидации, обращаться к этому закешированному набору через замыкание, проверяя каждый элемент массива в памяти, без дополнительных обращений к базе.
Вот как это выглядит в коде:
// CheckoutRequest
protected array $validProductIds = [];
protected function prepareForValidation(): void
{
$submittedIds = collect($this->input('items', []))
->pluck('product_id')
->filter()
->unique();
$this->validProductIds = Product::query()
->whereIn('id', $submittedIds)
->where('status', 'published')
->pluck('id')
->all();
}
public function rules(): array
{
return [
'items' => ['required', 'array', 'min:1'],
'items.*.product_id' => [
'required',
'integer',
function (string $attribute, mixed $value, Closure $fail) {
if (! in_array($value, $this->validProductIds, true)) {
$fail('The selected product is not available.');
}
},
],
'items.*.quantity' => ['required', 'integer', 'min:1'],
];
}Разберём происходящее по шагам. Метод prepareForValidation() извлекает массив items из запроса, собирает все значения product_id, отфильтровывает пустые и удаляет дубликаты вызовом unique(). Затем выполняется единственный запрос whereIn, возвращающий массив идентификаторов, существующих в базе и имеющих статус опубликованных. Результат сохраняется в защищённом свойстве $validProductIds.
Когда фреймворк приступает к валидации, он по-прежнему итерируется по массиву items и для каждого элемента вызывает замыкание, привязанное к items.*.product_id. Но теперь замыкание не обращается к базе данных, а выполняет быструю проверку in_array() по заранее подготовленному массиву в памяти. Строгое сравнение с флагом true гарантирует, что строковое представление идентификатора не будет неявно приведено к целому числу и ложно признано валидным.
Результат объединяет сильные стороны обоих предыдущих подходов. Количество запросов к базе данных равно единице и не зависит от размера корзины. При этом Laravel продолжает генерировать ошибки с точным указанием индекса: если третий элемент содержит несуществующий идентификатор, ключ ошибки будет items.2.product_id, а фронтенд получит возможность подсветить проблемную строку. Более того, вы сохраняете полную телеметрию для отладки и мониторинга атак.
Возникает закономерный вопрос: не является ли такое использование prepareForValidation() нарушением его прямого назначения и, следовательно, архитектурным костылём.
Легитимность подхода: Form Request как построитель контекста
Сторонник буквального прочтения документации может возразить, что prepareForValidation() предназначен исключительно для мутации входящих данных, а не для выполнения запросов к базе. Формально это верно. Но давайте посмотрим на эволюцию самого паттерна Form Request в экосистеме Laravel.
Изначально Form Request действительно задумывался как тонкая прослойка для централизации правил валидации и логики авторизации. Однако с годами практики разработки накопили консенсус вокруг более широкой роли этого класса: Form Request стал де-факто объектом передачи данных, или DTO, для входящих HTTP-запросов. Разработчики регулярно используют его не только для валидации, но и для подготовки данных к передаче в контроллер или сервисный слой. Метод passedValidation() часто применяется для дополнительной обработки уже проверенных данных, а метод prepareForValidation() — не только для тривиального trim, но и для обогащения запроса контекстом, необходимым для самой валидации.
Эта идея не нова и уже обсуждалась в сообществе. В статье «Поговорим о запросах формы» показано, как использовать prepareForValidation() для добавления локали пользователя или динамической замены налоговой ставки до запуска правил. Если интересно внутреннее устройство Form Request и жизненный цикл его методов, рекомендую «Глубокое погружение в FormRequest» — там детально разобран процесс от вызова validateResolved() до хуков passedValidation().
Кеширование списка допустимых идентификаторов попадает именно в эту категорию. Мы не мутируем входящие данные в классическом смысле, но мы готовим окружение, в котором правила валидации смогут выполниться эффективно. Это не нарушение контракта, а его естественное расширение. Если вы используете prepareForValidation() для того, чтобы сделать запрос к внешнему API и проверить доступность какого-либо ресурса перед валидацией, это воспринимается как нормальная практика. Запрос к собственной базе данных для той же цели ничем не отличается с архитектурной точки зрения.
Более того, альтернативные пути ведут к усложнению кодовой базы без соразмерной выгоды. Можно вынести логику предзагрузки в пользовательское правило валидации, которое в конструкторе принимает репозиторий и выполняет запрос. Можно написать отдельный middleware, который добавляет закешированный список в запрос. Все эти варианты рабочие, но они увеличивают количество классов и косвенных связей там, где достаточно одного свойства и одного метода. Прозрачность кода страдает, а сопровождаемость не выигрывает.
Теперь, когда легитимность подхода обоснована, стоит очертить границы применимости. Как и у любого инженерного решения, у кеширования в памяти есть предел разумного использования.
Границы применимости и финальные рекомендации
Предложенное решение покрывает подавляющее большинство сценариев, с которыми сталкивается типичное веб-приложение. Корзина интернет-магазина, форма с динамическим списком тегов, массовое добавление участников в группу, пакетное обновление статусов заказов — во всех этих случаях количество элементов редко превышает несколько десятков, а то и сотен. Один запрос whereIn с сотней идентификаторов выполняется за миллисекунды, и хранение такого массива в памяти не оказывает заметного влияния на потребление ресурсов.
Ситуация меняется, когда мы переходим к сценариям массового импорта. Представьте эндпоинт, принимающий CSV-файл с десятью тысячами строк, каждая из которых содержит идентификатор продукта для валидации. Выполнение whereIn с десятью тысячами параметров создаст огромное SQL-выражение, которое может упереться в ограничения базы данных по длине запроса или вызвать деградацию производительности планировщика. Хранение массива из десяти тысяч идентификаторов в свойстве класса Form Request тоже не бесплатно, хотя и менее критично, чем проблемы на стороне базы.
Для подобных случаев стоит рассмотреть альтернативные стратегии. Первая — разбиение на чанки и выполнение нескольких запросов whereIn с ограниченным размером, например по пятьсот идентификаторов за раз. Это не сводит количество запросов к единице, но держит их под контролем и избегает патологических SQL-конструкций. Вторая — использование временных таблиц, когда вы записываете все переданные идентификаторы в специальную таблицу сессии и выполняете одно соединение для проверки существования и статуса. Это более тяжёлое решение, оправданное только при действительно больших объёмах данных.
Третья, и часто наиболее правильная для сценариев массового импорта — вынос валидации за пределы синхронного HTTP-запроса. Приняв файл, вы сохраняете его и ставите задачу в очередь, а пользователю возвращаете идентификатор задачи для отслеживания прогресса. Фоновая обработка позволяет выполнять валидацию порциями, не блокируя веб-сервер и не рискуя таймаутом соединения.
Возвращаясь к основному сценарию — валидации массивов разумного размера — мы получаем инструмент, который экономит запросы к базе данных, сохраняет точность сообщений об ошибках и остаётся в рамках идиоматичного Laravel-кода. Подход с предзагрузкой контекста через prepareForValidation() не требует внешних библиотек, не усложняет навигацию по кодовой базе и легко объясняется новым членам команды. В следующий раз, когда вы увидите правило exists, применённое к элементам массива, вспомните о скрытой цене этого синтаксического удобства и о том, что один метод и одно свойство класса способны устранить проблему полностью.
Статья основана на материале Validating Array Inputs in Laravel Without the N+1 за авторством Daryl Legion. Благодарю за отличный разбор темы, который вдохновил на эту русскоязычную адаптацию с расширенным архитектурным анализом.