Валидация массивов и JSON в Laravel

HTML-формы отправляют не только плоские поля. Мультиселекты, динамические блоки «добавить ещё», JSON-тела API-запросов – всё это массивы и вложенные структуры. Laravel валидирует их через dot notation и wildcard *, не требуя ручного обхода элементов. Правила для массивов покрывают и структуру (какие ключи, сколько элементов), и содержимое (тип, формат, существование в базе).

Правило array

Правило array проверяет, что поле является PHP-массивом. Без него Laravel воспримет строку "tags" как валидное значение для поля, которое ожидает список:

$request->validate([
    'tags' => 'required|array',
    'tags.*' => 'string|max:50',
]);

Ограничение допустимых ключей

Если передать аргументы, array проверит, что массив содержит только перечисленные ключи:

$request->validate([
    'address' => 'required|array:street,city,zip,country',
]);

Ключ state, отправленный клиентом, вызовет ошибку. Это защита от лишних полей – особенно при $model->fill($validated). Без ограничения ключей validated() вернёт все поля массива, даже непровалидированные.

array vs list

array допускает и списки ([1, 2, 3]), и ассоциативные массивы (['a' => 1]). Если нужен именно последовательный список, добавьте правило list (подробнее ниже).

При валидации API-тел, где клиент отправляет JSON-объект, array – основной инструмент. Для HTML-форм с name="items[]" данные тоже приходят как PHP-массив.

Количество элементов и пустые массивы

Для проверки длины массива используйте min, max, size и between:

$request->validate([
    'recipients' => 'required|array|min:1|max:50',
    'options' => 'required|array|size:3',
    'scores' => 'required|array|between:2,10',
]);

Правило size для массивов проверяет count(). min:1 гарантирует непустой массив. between:2,10 – от 2 до 10 элементов включительно. Эти правила работают одинаково для списков и ассоциативных массивов – считают количество элементов верхнего уровня.

Если пустой массив допустим:

'filters' => 'present|array',

present требует наличие ключа в запросе, но разрешает пустой массив []. required|array отклонит пустой массив, потому что пустой массив считается «пустым значением».

Для полностью необязательного массива (может отсутствовать или быть пустым):

'tags' => 'nullable|array',
'tags.*' => 'string|max:50',

При nullable поле может быть null (отсутствует). При его наличии – должен быть массивом. Подробнее о разнице nullable, sometimes и present – в статье про условную валидацию.

Wildcard * и dot notation

Символ * заменяет любой индекс массива. Dot notation обращается к вложенным ключам:

$request->validate([
    'users' => 'required|array|min:1',
    'users.*.name' => 'required|string|max:255',
    'users.*.email' => 'required|email',
    'users.*.role' => 'required|in:admin,editor,viewer',
]);

Для каждого элемента users Laravel проверит name, email и role. Количество элементов не ограничено (кроме min:1). Добавьте max, если нужен лимит – это и защита от абьюза, и сигнал для фронтенда.

Вложенность может быть глубже одного уровня:

$request->validate([
    'order.items' => 'required|array|min:1',
    'order.items.*.product_id' => 'required|integer|exists:products,id',
    'order.items.*.quantity' => 'required|integer|min:1',
    'order.items.*.options' => 'nullable|array',
    'order.items.*.options.*.name' => 'required|string',
    'order.items.*.options.*.value' => 'required|string',
]);

Два уровня wildcard: каждый товар имеет массив опций, у каждой опции – имя и значение. Laravel не ограничивает глубину вложенности – можно добавить третий, четвёртый уровень *, если структура данных это требует.

Dot notation без wildcard

Wildcard не обязателен. Для известных ключей обращайтесь напрямую:

$request->validate([
    'meta.title' => 'required|string|max:60',
    'meta.description' => 'nullable|string|max:160',
    'meta.og_image' => 'nullable|url',
]);

Это типичная ситуация для объектов с фиксированной структурой – SEO-мета, настройки, конфигурация.

Dot notation для конкретных элементов

Можно обратиться к конкретному индексу вместо wildcard:

$request->validate([
    'contacts.0.phone' => 'required|string',
    'contacts.1.phone' => 'nullable|string',
]);

Первый контакт обязателен, второй – нет. Этот подход полезен для форм с фиксированной структурой: форма оплаты с основным и дополнительным адресом, форма регистрации с главным и запасным контактом.

Комбинация wildcard и фиксированных ключей

В реальных формах уровни вложенности чередуются: массив объектов, у каждого – известные ключи:

$request->validate([
    'employees' => 'required|list|min:1',
    'employees.*.personal' => 'required|array:first_name,last_name,birth_date',
    'employees.*.personal.first_name' => 'required|string|max:100',
    'employees.*.personal.last_name' => 'required|string|max:100',
    'employees.*.personal.birth_date' => 'required|date|before:today',
    'employees.*.contacts' => 'required|array|min:1',
    'employees.*.contacts.*.type' => 'required|in:phone,email',
    'employees.*.contacts.*.value' => 'required|string',
]);

Первый * – индекс сотрудника, personal – объект с ограниченными ключами, contacts – вложенный список, второй * – индекс контакта.

Экранирование точек в именах полей

Если имя поля содержит буквальную точку (например, v1.0), Laravel интерпретирует её как разделитель вложенности. Экранируйте обратным слешем:

$request->validate([
    'config.v1\.0' => 'required|string',
]);

Без экранирования Laravel будет искать ключ 0 внутри ключа v1 внутри config.

Wildcard в сообщениях об ошибках

Ошибки для wildcard-правил содержат конкретный индекс. Если users.2.email невалиден, ключ ошибки будет users.2.email, а не users.*.email. Это позволяет фронтенду точно определить, какой элемент формы подсветить красным.

В JSON-ответе валидации (422 Unprocessable Entity) ошибки приходят в формате:

{
    "message": "The users.2.email field must be a valid email address.",
    "errors": {
        "users.2.email": [
            "The users.2.email field must be a valid email address."
        ]
    }
}

JavaScript-клиент может распарсить ключ users.2.email и определить индекс 2 из dot-нотации.

Массив целых чисел, строк, boolean

Типичная задача – убедиться, что каждый элемент массива определённого типа:

$request->validate([
    'category_ids' => 'required|array|min:1',
    'category_ids.*' => 'integer|min:1',

    'permissions' => 'required|array',
    'permissions.*' => 'string|in:read,write,delete',

    'features' => 'required|array',
    'features.*' => 'boolean',
]);

category_ids.* = каждый элемент должен быть целым числом. permissions.* = каждый элемент – строка из допустимого набора. features.* = каждый элемент true/false.

Когда поле может быть строкой или массивом (фильтр API, который принимает одно значение или список), валидация усложняется. Самый надёжный подход – нормализовать в prepareForValidation():

protected function prepareForValidation(): void
{
    $status = $this->input('status');
    if (is_string($status)) {
        $this->merge(['status' => [$status]]);
    }
}

public function rules(): array
{
    return [
        'status' => 'required|array|min:1',
        'status.*' => 'string|in:active,archived,draft',
    ];
}

После нормализации status всегда массив – правила становятся однозначными. Подход через prepareForValidation() описан подробнее в статье про Form Request.

Ключи массива: array keys, required_array_keys

required_array_keys – обязательные ключи

Проверка, что массив содержит определённые ключи:

$request->validate([
    'config' => 'required|array|required_array_keys:host,port,database',
]);

config должен содержать ключи host, port и database. Дополнительные ключи допустимы. Для запрета лишних ключей комбинируйте с array:host,port,database.

in_array_keys – хотя бы один из ключей

Когда нужно убедиться, что массив содержит хотя бы один ключ из набора:

$request->validate([
    'notification' => 'required|array|in_array_keys:email,sms,push',
]);

Массив notification должен содержать хотя бы один из перечисленных ключей. Если ни одного из ключей нет – ошибка. Это полезно для форм, где пользователь выбирает способ уведомления – хотя бы один канал обязателен.

Проверка наличия конкретных ключей в любом порядке

Комбинация required_array_keys + array:keys даёт полный контроль над структурой:

$request->validate([
    'database' => [
        'required',
        'array:host,port,name,user,password',
        'required_array_keys:host,port,name',
    ],
]);

Ключи host, port, name обязательны. user и password опциональны, но других ключей быть не может. Такой подход защищает конфигурационные объекты от лишних полей.

Валидация значений по ключам

Когда ключи фиксированные, правила для каждого задаются через dot notation:

$request->validate([
    'dimensions' => 'required|array|required_array_keys:width,height',
    'dimensions.width' => 'required|integer|min:1|max:4096',
    'dimensions.height' => 'required|integer|min:1|max:4096',
]);

required_array_keys гарантирует наличие ключей, правила с dot notation проверяют значения. Это два слоя защиты: структура + содержимое.

in_array – значение существует в другом поле

in_array проверяет, что значение присутствует среди значений другого поля:

$request->validate([
    'available_colors' => 'required|array',
    'available_colors.*' => 'string',
    'default_color' => 'required|in_array:available_colors.*',
]);

Цвет по умолчанию должен быть одним из доступных цветов. Валидация сравнивает значения внутри запроса, а не в базе. Для проверки по базе используйте exists – об этом подробно в статье про unique и exists.

Обратный вариант – проверить, что значение не среди значений другого поля – Laravel не предоставляет из коробки. Если нужно, создайте собственное правило валидации.

list – массив-список

list гарантирует, что массив является списком – ключи идут последовательно от 0:

$request->validate([
    'steps' => 'required|list|min:2',
    'steps.*' => 'required|string|max:500',
]);

Ассоциативный массив ['a' => 1, 'b' => 2] не пройдёт – только [1, 2, 3]. Это важно для данных, где порядок имеет значение (шаги мастера, очередь задач). PHP-функция array_is_list() использует ту же логику.

list сочетается с min, max, size:

'steps' => 'required|list|between:3,10',

Если клиент отправит {"0": "a", "2": "b"} (пропущен индекс 1), list отклонит данные – ключи не последовательные.

contains и doesnt_contain

Проверка наличия или отсутствия конкретных значений в массиве:

use Illuminate\Validation\Rule;

$request->validate([
    'roles' => [
        'required',
        'array',
        Rule::contains(['admin']),
    ],
]);

Массив roles обязан содержать значение admin. Другие значения допустимы.

'tags' => [
    'required',
    'array',
    Rule::doesntContain(['banned', 'spam']),
],

Массив tags не должен содержать banned или spam. 'admin' и 'Admin' считаются разными значениями – регистр учитывается.

contains и doesnt_contain проверяют наличие/отсутствие среди значений массива, а не среди ключей. Для проверки ключей используйте required_array_keys или in_array_keys.

distinct – уникальность элементов

distinct проверяет, что внутри массива нет дубликатов:

$request->validate([
    'assignees' => 'required|array|min:1',
    'assignees.*' => 'integer|distinct',
]);

Отправка [3, 7, 3] провалит валидацию – ID 3 повторяется. По умолчанию сравнение нестрогое. Варианты:

'items.*.sku' => 'string|distinct:strict',       // строгое сравнение типов
'emails.*' => 'email|distinct:ignore_case',       // регистронезависимое

distinct:ignore_case полезен для email – [email protected] и [email protected] будут считаться дубликатами.

distinct проверяет уникальность внутри одного запроса. Для проверки по базе данных (email ещё не зарегистрирован) нужен unique – это другое правило с другой задачей. Часто нужны оба:

'emails.*' => 'email|distinct:ignore_case|unique:users,email',

distinct – нет дубликатов в массиве. unique – нет совпадений с базой. О правилах валидации паролей и подтверждении (confirmed, same) – в статье про валидацию паролей.

Rule::forEach – динамические правила для элементов

Rule::forEach() позволяет назначить правила каждому элементу массива в зависимости от его значения. Замыкание получает значение и полный путь атрибута, возвращает плоский массив правил:

use App\Rules\HasPermission;
use Illuminate\Validation\Rule;

$request->validate([
    'company_ids' => 'required|array|min:1',
    'company_ids.*' => Rule::forEach(function (string|null $value, string $attribute) {
        return [
            'integer',
            Rule::exists('companies', 'id'),
            new HasPermission('manage-company', $value),
        ];
    }),
]);

Каждый ID компании проверяется на существование в базе и на наличие у пользователя прав на управление этой конкретной компанией. Замыкание возвращает плоский массив правил – как если бы вы писали 'company_ids.*' => ['integer', ...], только с доступом к значению через $value.

$attribute содержит путь с индексом, например company_ids.0, company_ids.1. Через $value доступно значение текущего элемента.

Для условной валидации внутри элементов-объектов (разные правила в зависимости от type или age) используйте $validator->sometimes() с двумя аргументами в замыкании – это описано в статье про условную валидацию.

Валидация JSON-строк

Правило json

json проверяет, что значение является валидной JSON-строкой:

$request->validate([
    'metadata' => 'required|json',
]);

Поле metadata должно содержать строку, которую json_decode() обработает без ошибок. Пустая строка, число или массив не пройдут – нужна именно строка формата JSON. Правило не делает различий между JSON-объектом "{}", JSON-массивом "[]" и JSON-скаляром "42" – все считаются валидным JSON.

Валидация содержимого JSON

Правило json проверяет только формат, но не структуру. Для проверки содержимого распарсите JSON в prepareForValidation() и валидируйте как массив:

use Illuminate\Foundation\Http\FormRequest;

class ImportRequest extends FormRequest
{
    protected function prepareForValidation(): void
    {
        if (is_string($this->input('payload'))) {
            $decoded = json_decode($this->input('payload'), true);
            if (is_array($decoded)) {
                $this->merge(['payload' => $decoded]);
            }
        }
    }

    public function rules(): array
    {
        return [
            'payload' => 'required|array',
            'payload.type' => 'required|string|in:import,sync',
            'payload.items' => 'required|array|min:1',
            'payload.items.*.id' => 'required|integer',
            'payload.items.*.data' => 'required|array',
        ];
    }
}

Клиент отправляет JSON-строку, prepareForValidation() декодирует её, дальше валидация работает как с обычным массивом. Это единственный надёжный способ проверить «JSON-схему» в Laravel – встроенного правила json_schema нет.

Если JSON невалиден, json_decode() вернёт null, и $this->merge() не сработает. Валидация 'payload' => 'required|array' провалится с понятной ошибкой – поле осталось строкой.

JSON-массив vs JSON-объект

API-клиенты иногда отправляют JSON-массив [{...}, {...}] вместо объекта {"items": [...]}. Для валидации корневого JSON-массива декодируйте его и оберните:

protected function prepareForValidation(): void
{
    $body = $this->getContent();
    $decoded = json_decode($body, true);

    if (is_array($decoded) && array_is_list($decoded)) {
        $this->merge(['items' => $decoded]);
    }
}

public function rules(): array
{
    return [
        'items' => 'required|list|min:1|max:100',
        'items.*.id' => 'required|integer',
        'items.*.action' => 'required|in:create,update,delete',
    ];
}

array_is_list() проверяет, что это именно список, а не ассоциативный массив.

Для JSON-объекта, приходящего как тело запроса (не строка в поле), Laravel декодирует его автоматически. Правило json не нужно – данные уже массив:

// Запрос: POST /api/events с Content-Type: application/json
// Тело: {"title": "Conf", "date": "2026-05-01"}
// В контроллере: 'Conf' === $request->input('title')

$request->validate([
    'title' => 'required|string',
    'date' => 'required|date',
]);

Различие: json – для строковых полей формы. Для JSON API-тел – обычные правила массивов и скаляров. Если клиент отправляет Content-Type: application/json с телом {"items": [...]}, то $request->input('items') уже будет PHP-массивом – декодирование происходит автоматически в middleware Laravel.

Частая ошибка при работе с JSON API – добавить правило json для поля, которое уже декодировано. Валидация провалится, потому что поле содержит массив, а не строку.

Сообщения об ошибках для массивов

Wildcard работает и в кастомных сообщениях. Placeholders :index (с 0), :position (с 1) и :ordinal-position (1st, 2nd… – вывод всегда на английском независимо от локали) подставляют номер элемента:

public function messages(): array
{
    return [
        'users.*.email.required' => 'Email пользователя #:position обязателен.',
        'users.*.email.email' => 'Email пользователя #:position некорректен.',
        'items.*.product_id.exists' => 'Товар в строке #:position не найден.',
    ];
}

Для многоуровневой вложенности: second-index, second-position, third-index и так далее:

'order.items.*.options.*.value.required' => 'Опция #:second-position товара #:position не заполнена.',

Вложенные объекты: ключ-значение

Формы часто отправляют ассоциативные массивы – настройки, метаданные, переводы:

$request->validate([
    'settings' => 'required|array:theme,locale,notifications',
    'settings.theme' => 'required|string|in:light,dark,auto',
    'settings.locale' => 'required|string|size:2',
    'settings.notifications' => 'required|array',
    'settings.notifications.email' => 'required|boolean',
    'settings.notifications.sms' => 'required|boolean',
]);

Здесь settings – не список элементов, а объект с известными ключами. array:theme,locale,notifications запрещает клиенту добавить ключи вроде settings.admin.

Для произвольных пар ключ-значение (переводы, мета-поля):

$request->validate([
    'translations' => 'required|array|min:1',
    'translations.*' => 'required|string|max:5000',
]);

Ключи массива translations – коды языков, значения – тексты. Wildcard * покрывает все ключи, какими бы они ни были.

Для валидации самих ключей можно использовать кастомное правило или after():

public function after(): array
{
    return [
        function (\Illuminate\Validation\Validator $validator) {
            $validLocales = ['en', 'ru', 'de', 'fr', 'es', 'zh'];
            $keys = array_keys($this->input('translations', []));

            foreach ($keys as $key) {
                if (false === in_array($key, $validLocales, true)) {
                    $validator->errors()->add(
                        "translations.{$key}",
                        "Неизвестный код языка: {$key}"
                    );
                }
            }
        },
    ];
}

Стандартные правила валидируют значения, но не ключи. after() закрывает этот пробел. Как работает after() и другие пост-валидационные хуки – в статье про условную валидацию.

Ещё один вариант – разрешить только определённые ключи через array:en,ru,de,fr,es,zh и валидировать значения через wildcard:

$request->validate([
    'translations' => 'required|array:en,ru,de,fr,es,zh|min:1',
    'translations.*' => 'required|string|max:5000',
]);

Проще, чем after(), но ограничивает набор ключей жёстко. Подход с after() гибче, если языки хранятся в конфиге.

Полный пример: форма с динамическими блоками

Форма создания мероприятия с участниками и расписанием – несколько уровней вложенности и разные типы данных:

class CreateEventRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            // Основные поля
            'title' => 'required|string|max:255',
            'date' => 'required|date|after:today',

            // Участники: список объектов
            'participants' => 'required|list|min:1|max:200',
            'participants.*.name' => 'required|string|max:255',
            'participants.*.email' => 'required|email|distinct:ignore_case',
            'participants.*.role' => 'required|in:speaker,attendee,organizer',

            // Расписание: ассоциативный массив по слотам
            'schedule' => 'required|array|min:1',
            'schedule.*.time' => 'required|date_format:H:i',
            'schedule.*.title' => 'required|string|max:100',
            'schedule.*.speaker_email' => [
                'nullable',
                'email',
                'in_array:participants.*.email',
            ],

            // Метаданные: объект с фиксированными ключами
            'meta' => 'required|array:location,capacity,is_online',
            'meta.location' => 'required_unless:meta.is_online,true|string|max:500',
            'meta.capacity' => 'required|integer|min:1|max:10000',
            'meta.is_online' => 'required|boolean',

            // Теги: простой список строк
            'tags' => 'nullable|array|max:10',
            'tags.*' => 'string|max:30|distinct:ignore_case',
        ];
    }

    public function messages(): array
    {
        return [
            'participants.*.email.distinct' => 'Email участника #:position дублируется.',
            'schedule.*.speaker_email.in_array' => 'Спикер в слоте #:position не найден среди участников.',
        ];
    }
}

Здесь комбинируются list (участники – упорядоченный список), array с ограниченными ключами (мета), in_array (спикер должен быть среди участников), distinct (email не дублируются), условная валидация (локация не нужна для онлайн-мероприятия).

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

Каждое правило с wildcard * применяется к каждому элементу. Для массива из 100 элементов с тремя правилами на элемент – 300 проверок. Сами по себе правила формата (string, integer, email) лёгкие. Тяжёлые – exists и unique, потому что генерируют SQL-запросы.

Для больших массивов с проверкой по БД используйте подход из статьи про unique и exists: поставьте array и exists на родительское поле для группировки в один WHERE IN, или вынесите проверку в after() с одним ручным запросом.

Ограничивайте максимальный размер массивов через max:

'items' => 'required|array|max:500',

Без max клиент может отправить массив из 100 000 элементов и нагрузить валидацию. Это особенно критично для публичных API без аутентификации.

Правила формата (string, integer, in) работают быстро – десятки тысяч проверок за миллисекунды. Узкие места – правила с обращением к БД и к внешним сервисам.

Для отладки количества запросов при валидации массивов:

DB::enableQueryLog();
$request->validate([...]);
$queries = DB::getQueryLog();
// Посмотреть, сколько SQL сгенерировала валидация

Массивы файлов

HTML-формы с <input type="file" name="photos[]" multiple> отправляют массив файлов. Валидация работает через тот же wildcard:

$request->validate([
    'photos' => 'required|array|min:1|max:10',
    'photos.*' => 'image|max:5120',
]);

Каждый файл проверяется отдельно – тип и размер. Для разных слотов с разными правилами:

$request->validate([
    'documents' => 'required|array',
    'documents.*.file' => 'required|file|mimes:pdf,docx|max:10240',
    'documents.*.label' => 'required|string|max:100',
]);

Каждый документ – объект с файлом и подписью. Подробнее о валидации файлов и изображений – в статье про файловую валидацию.

Массивы и validated()

Метод validated() возвращает только провалидированные данные. Для массивов есть нюанс: если правила описаны только для users.*.email, но не для users.*.name, то name всё равно попадёт в validated(), потому что весь элемент массива считается «провалидированным».

// Правила
'users' => 'required|array',
'users.*.email' => 'required|email',
// Данные: [['email' => '[email protected]', 'name' => 'attacker', 'is_admin' => true]]
// validated() вернёт ВСЁ, включая is_admin

Защита – ограничить ключи:

'users' => 'required|array',
'users.*' => 'array:email,name',
'users.*.email' => 'required|email',
'users.*.name' => 'required|string|max:255',

Теперь is_admin не пройдёт через array:email,name. Другой вариант – использовать $request->safe()->only(['users']) и вручную фильтровать структуру. Метод safe() возвращает объект ValidatedInput, который поддерживает only(), except() и all() для гибкого доступа к провалидированным данным. При работе с fill() моделей этот контроль критичен – лишние поля не должны попадать в массовое присвоение.

Типичные ошибки

required|array и пустой массив. required|array отклонит пустой массив [] – пустой массив считается «пустым значением». Но min:1 делает намерение явным для читателя кода и защищает от путаницы.

Пропущенные правила для вложенных полей. Если объявить 'users' => 'array' без 'users.*.email' => 'required|email', Laravel не проверит содержимое элементов. Данные попадут в validated() без валидации. Это одна из самых коварных проблем – код работает, тесты зелёные, но невалидные данные проскальзывают в базу. Всегда описывайте правила для каждого уровня вложенности.

Лишние ключи в validated(). Без ограничения array:key1,key2 метод validated() вернёт все ключи массива, даже если правила описаны только для части из них. Клиент может подсунуть is_admin: true, и оно пройдёт в fill().

Wildcard и строковые поля. Правило 'tags.*' => 'string' не сработает, если tags – строка, а не массив. Добавьте 'tags' => 'array' – оно проверит тип и при провале остановит дальнейшую валидацию вложенных полей. Без него wildcard-правило просто пропустится, и невалидные данные попадут в validated().

Ассоциативный массив вместо списка. JavaScript отправляет {0: 'a', 1: 'b'} как объект, не как массив. PHP декодирует это в ассоциативный массив ['0' => 'a', '1' => 'b'], который пройдёт array, но не list. Если порядок элементов важен, всегда добавляйте list.

distinct без ignore_case для email. distinct по умолчанию чувствителен к регистру. [email protected] и [email protected] не будут считаться дубликатами. Добавляйте distinct:ignore_case или нормализуйте данные перед валидацией.

JSON как string, не как array. Если API принимает JSON-тело через Content-Type: application/json, Laravel автоматически декодирует его – поле уже приходит массивом. Правило json нужно только для полей, где JSON передаётся как строка внутри формы (textarea, hidden input).

Массив или объект в JSON. JavaScript-клиенты отправляют [] как пустой массив, а {} как пустой объект. PHP декодирует оба в массив, но [] будет пустым списком, а {} – пустым ассоциативным массивом. Для Laravel оба пройдут array, но list примет только [].

Тестирование

Массивы и вложенные структуры требуют тестов с разными формами данных:

use Tests\TestCase;

class OrderValidationTest extends TestCase
{
    public function test_order_requires_at_least_one_item(): void
    {
        $response = $this->postJson('/api/orders', [
            'items' => [],
        ]);

        $response->assertJsonValidationErrors(['items']);
    }

    public function test_each_item_validated_independently(): void
    {
        $response = $this->postJson('/api/orders', [
            'items' => [
                ['product_id' => 1, 'quantity' => 5],
                ['product_id' => null, 'quantity' => -1],
            ],
        ]);

        $response->assertJsonValidationErrors([
            'items.1.product_id',
            'items.1.quantity',
        ]);
        $response->assertJsonMissingValidationErrors([
            'items.0.product_id',
            'items.0.quantity',
        ]);
    }

    public function test_extra_array_keys_rejected(): void
    {
        $response = $this->postJson('/api/settings', [
            'settings' => [
                'theme' => 'dark',
                'locale' => 'ru',
                'is_admin' => true,
            ],
        ]);

        $response->assertJsonValidationErrors(['settings']);
    }

    public function test_json_string_decoded_and_validated(): void
    {
        $payload = json_encode([
            'type' => 'import',
            'items' => [['id' => 1, 'data' => ['name' => 'test']]],
        ]);

        $response = $this->postJson('/api/import', [
            'payload' => $payload,
        ]);

        $response->assertJsonMissingValidationErrors(['payload']);
    }

    public function test_distinct_catches_duplicate_ids(): void
    {
        $response = $this->postJson('/api/teams', [
            'member_ids' => [5, 12, 5],
        ]);

        $response->assertJsonValidationErrors(['member_ids.2']);
    }
}

Во втором тесте ошибки привязаны к конкретному индексу (items.1.product_id), а не к массиву целиком. Это позволяет фронтенду подсветить конкретную строку формы.

При тестировании JSON-строк передавайте данные через postJson() – метод автоматически устанавливает Content-Type: application/json. Для тестирования поля с JSON-строкой внутри формы используйте post() с массивом данных, где JSON – обычная строка.

Массивы с distinct тестируйте парами: отправьте массив без дубликатов (должен пройти) и с дубликатами (конкретный индекс должен провалиться). Laravel помечает ошибкой все элементы-дубликаты, а не только последний. Для list – отправьте ассоциативный массив и убедитесь, что валидация его отклоняет.

Условная валидация массивов (правила зависят от значения внутри элемента) разобрана в статье про условную валидацию. Проверка существования элементов в базе – в статье про unique и exists. О правилах для отдельных полей внутри массива (строки, числа, даты) – в соответствующих руководствах: строки и числа, даты. Основы валидации – в руководстве по валидации.