Laravel Array and JSON Validation: Practical Recipes for Laravel 13
Multi-selects, dynamic “add another row” forms, JSON API payloads – all of them deliver arrays and nested structures to your controllers. Laravel validates these through dot notation and the wildcard * operator, so you rarely need to loop through elements yourself. This guide covers every rule that applies to arrays and JSON, with recipes you can drop straight into a Form Request.
For the basics of validation itself – validate(), Form Requests, the Validator facade – see the quick start guide.
Wildcard Validation: items.* and Beyond
The * character stands in for every index of an array. Combine it with dot notation to reach nested fields:
$request->validate([
'line_items' => 'required|array|min:1',
'line_items.*.product_id' => 'required|integer|exists:products,id',
'line_items.*.quantity' => 'required|integer|min:1|max:999',
'line_items.*.note' => 'nullable|string|max:500',
]);
Each element in line_items gets validated individually. If line_items.2.quantity fails, the error key will include the concrete index – not * – so the frontend knows exactly which row to highlight.
Nesting can go deeper. An order where every line item carries its own set of options:
$request->validate([
'rows' => 'required|array|min:1',
'rows.*.sku' => 'required|string|max:40',
'rows.*.modifiers' => 'nullable|array|max:5',
'rows.*.modifiers.*.key' => 'required|string',
'rows.*.modifiers.*.value' => 'required|string|max:200',
]);
Two levels of * – one for each row, one for each modifier inside that row. Laravel does not limit nesting depth, though readability suffers past three levels.
When keys are fixed rather than sequential, skip the wildcard and address them directly:
$request->validate([
'seo.title' => 'required|string|max:60',
'seo.description' => 'nullable|string|max:160',
'seo.canonical_url' => 'nullable|url',
]);
You can also target a specific index instead of using *:
$request->validate([
'phones.0' => 'required|string',
'phones.1' => 'nullable|string',
]);
The first phone number is mandatory, the second is optional – handy for forms with a fixed number of slots.
Escaping Dots in Field Names
If a field name contains a literal dot (e.g., v2.0), Laravel interprets it as a nesting separator. Escape with a backslash:
$request->validate([
'versions.v2\.0' => 'required|string',
]);
Without the escape, Laravel would look for key 0 inside key v2 inside versions – not what you want.
Ensuring an Array Is Not Empty
A common requirement: the field must be an array, and it must contain at least one element. The rule required|array|min:1 covers it:
$request->validate([
'recipients' => 'required|array|min:1|max:50',
'recipients.*' => 'email',
]);
min:1 guarantees a non-empty array. max:50 caps the array length to prevent abuse. For exact counts, use size:
'coordinates' => 'required|array|size:2',
'coordinates.*' => 'numeric',
between gives you a range:
'answers' => 'required|array|between:3,10',
All of these – min, max, size, between – check count() when applied to arrays. They work the same for lists and associative arrays.
If an empty array is acceptable but the key must be present in the request:
'filters' => 'present|array',
present requires the key to exist but permits []. In contrast, required|array rejects an empty array because Laravel considers [] an “empty” value.
For a fully optional array that may be absent from the request entirely:
'tags' => 'nullable|array',
'tags.*' => 'string|max:50',
With nullable, the field can be null or missing. When present, it must be an array. The difference between nullable, sometimes, and present is covered in the conditional validation article.
Typed Arrays: Integers, Strings, Mixed
Validating an array of integers – for instance, a batch of category IDs:
$request->validate([
'category_ids' => 'required|array|min:1',
'category_ids.*' => 'integer|min:1',
]);
An array of strings from a tag input:
$request->validate([
'tags' => 'required|array|max:15',
'tags.*' => 'string|max:30',
]);
When an API filter accepts a single value or an array (the classic array or string dilemma), normalize the input before validation runs:
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',
];
}
After normalization, status is always an array. One set of rules covers both cases. More on prepareForValidation() and input preparation in the quick start guide.
Boolean arrays come up when a form sends feature toggles:
$request->validate([
'features' => 'required|array',
'features.*' => 'boolean',
]);
Remember that Laravel’s boolean rule accepts true, false, 1, 0, "1", and "0" – not just strict booleans.
Validating an Array of Objects (Dynamic Forms)
Front-end forms that let users add and remove rows submit an array of objects. Each object has a known structure:
class StoreInvoiceRequest extends FormRequest
{
public function rules(): array
{
return [
'client_id' => 'required|integer|exists:clients,id',
'due_date' => 'required|date|after:today',
'items' => 'required|list|min:1|max:100',
'items.*.description' => 'required|string|max:255',
'items.*.unit_price' => 'required|numeric|min:0.01',
'items.*.qty' => 'required|integer|min:1',
'items.*.tax_rate' => 'nullable|numeric|between:0,100',
];
}
}
Notice list instead of array – it enforces consecutive integer keys starting from 0. A payload like {"0": {...}, "3": {...}} (gap in indices) will fail. This matters for ordered data where the sequence must be intact.
To restrict which keys each object may contain, add array:key1,key2 at the element level:
'items.*' => 'array:description,unit_price,qty,tax_rate',
Without this constraint, a client could send items.0.is_free: true. The validation factory enables excludeUnvalidatedArrayKeys by default, but the stripping only kicks in when the parent has an array (or list) rule and at least one nested wildcard rule – the flag check lives in Validator::validated(). The raw value still reaches $request->all() regardless. Adding array:key1,key2 makes the intent explicit and turns the silent strip into a validation error – defence in depth when the validated output feeds into $model->fill().
Array Unique Values with distinct
The distinct rule rejects duplicate values within an array:
$request->validate([
'assignee_ids' => 'required|array|min:1',
'assignee_ids.*' => 'integer|distinct',
]);
Submitting [5, 12, 5] fails – the duplicated 5 triggers an error. By default, comparison is loose and case-sensitive. Two modifiers change this:
// Strict type comparison – "3" and 3 are different
'ids.*' => 'integer|distinct:strict',
// Case-insensitive – catches [email protected] vs [email protected]
'emails.*' => 'email|distinct:ignore_case',
distinct checks uniqueness within the submitted array. It says nothing about the database. To enforce both – no duplicates in the request and no collisions with existing records – combine the two:
'emails.*' => 'email|distinct:ignore_case|unique:subscribers,email',
distinct handles the request-level check, unique handles the database lookup. More on unique in the unique and exists guide.
in_array vs in – A Common Confusion
These two rules solve different problems. in checks against a hardcoded list of acceptable values:
'role' => 'required|in:admin,editor,viewer',
in_array checks that a value exists within another field’s values in the same request:
$request->validate([
'available_slots' => 'required|array',
'available_slots.*' => 'date_format:H:i',
'chosen_slot' => 'required|in_array:available_slots.*',
]);
The chosen time slot must be one of the available slots submitted in the same payload. This is request-level cross-referencing – no database involved. If you need database lookups, reach for exists instead (covered in unique and exists).
A practical scenario: an event form where each schedule entry references a speaker from the participants list:
$request->validate([
'speakers' => 'required|array|min:1',
'speakers.*.email' => 'required|email',
'schedule' => 'required|array|min:1',
'schedule.*.speaker_email' => 'required|in_array:speakers.*.email',
'schedule.*.topic' => 'required|string|max:200',
]);
contains and doesnt_contain
These rules verify that an array includes (or excludes) specific values:
use Illuminate\Validation\Rule;
$request->validate([
'permissions' => [
'required',
'array',
Rule::contains(['read']),
],
]);
The permissions array must include the value read. Other values are allowed alongside it. The fluent Rule::contains() syntax reads well when the list comes from a variable.
The inverse:
'tags' => [
'required',
'array',
Rule::doesntContain(['spam', 'banned']),
],
Neither spam nor banned may appear in the array. Comparison is case-sensitive – Spam would pass.
String syntax works too when you prefer it compact: contains:read,write or doesnt_contain:spam,banned.
Both rules check values, not keys. To verify the presence of specific keys, reach for required_array_keys or in_array_keys instead.
A practical pattern – enforcing that a role selection includes a mandatory base role:
$request->validate([
'roles' => ['required', 'array', 'min:1', Rule::contains(['viewer'])],
'roles.*' => 'string|in:viewer,editor,admin,billing',
]);
Every user must have the viewer role. They can hold additional roles on top of it, but viewer is non-negotiable.
required_array_keys and Allowed Keys
required_array_keys verifies that an associative array contains specific keys:
$request->validate([
'db_config' => 'required|array|required_array_keys:host,port,name',
'db_config.host' => 'required|string',
'db_config.port' => 'required|integer|between:1,65535',
'db_config.name' => 'required|string|max:64',
]);
Extra keys (like password or charset) are still allowed. To lock the structure down completely, combine both rules:
'db_config' => 'required|array:host,port,name,user,password|required_array_keys:host,port,name',
array:host,port,name,user,password limits which keys may exist. required_array_keys:host,port,name demands that at least those three are present. Together they define a strict schema: five allowed keys, three mandatory.
When you need at least one key from a set but not necessarily all of them, in_array_keys is the right fit:
$request->validate([
'contact' => 'required|array|in_array_keys:email,phone,telegram',
]);
The contact array must have at least one of the listed keys. A user can provide just email, just phone, or all three.
list vs array
Both rules accept PHP arrays, but list is stricter: it requires sequential integer keys starting at 0. Think of it as the difference between a JavaScript array and a JavaScript object after json_decode.
// Accepts both ['a', 'b'] and ['x' => 1, 'y' => 2]
'data' => 'required|array',
// Only accepts ['a', 'b'] – rejects ['x' => 1, 'y' => 2]
'steps' => 'required|list',
Use list when element order matters – wizard steps, ranked preferences, queue items. Use array when the data is key-value pairs – settings, translations, configuration objects.
A subtle trap: JavaScript clients sometimes send {"0": "a", "5": "b"} with gaps in the indices instead of a clean ["a", "b"]. PHP decodes the former into [0 => "a", 5 => "b"] – a valid array but not a list, since keys are not consecutive. If your API consumers include mobile apps or third-party integrations, list catches this kind of malformed payload.
list combines with all the same size rules: list|min:2, list|between:3,10, list|size:5. Under the hood, it runs PHP’s array_is_list() check.
Validating Key-Value Structures
Associative arrays – settings objects, translations, metadata – need a different approach than sequential lists. When the keys are known:
$request->validate([
'preferences' => 'required|array:theme,locale,timezone',
'preferences.theme' => 'required|string|in:light,dark,system',
'preferences.locale' => 'required|string|size:2',
'preferences.timezone' => 'required|string|timezone',
]);
array:theme,locale,timezone rejects any extra keys the client tries to slip in.
For open-ended key-value pairs where the keys themselves need validation – like a translations map with locale codes as keys – use an after() hook to inspect the keys, since standard rules only validate values:
public function after(): array
{
return [
function (\Illuminate\Validation\Validator $validator) {
$allowed = ['en', 'fr', 'de', 'es', 'pt', 'ja'];
foreach (array_keys($this->input('translations', [])) as $key) {
if (false === in_array($key, $allowed, true)) {
$validator->errors()->add("translations.{$key}", "Unknown locale: {$key}");
}
}
},
];
}
Standard validation covers the values with 'translations.*' => 'required|string|max:5000'. The after() hook closes the gap for key validation.
JSON Validation
The json Rule
The json rule checks that a value is a valid JSON string:
$request->validate([
'payload' => 'required|json',
]);
It accepts any valid JSON – objects, arrays, even scalar strings like "42". An empty string or a PHP array will fail. Under the hood, Laravel calls json_validate(), so it verifies syntax without actually decoding the payload. The rule tells you nothing about the internal structure.
Validating JSON Object Structure
Since json does not inspect the content, decode the JSON first and validate the result as an array. A Form Request makes this clean:
class WebhookRequest extends FormRequest
{
protected function prepareForValidation(): void
{
if (is_string($this->input('config'))) {
$decoded = json_decode($this->input('config'), true);
if (is_array($decoded)) {
$this->merge(['config' => $decoded]);
}
}
}
public function rules(): array
{
return [
'config' => 'required|array',
'config.url' => 'required|url',
'config.events' => 'required|array|min:1',
'config.events.*' => 'string|in:push,pull_request,issue',
'config.secret' => 'nullable|string|min:16',
];
}
}
The client submits a JSON string in the config field, prepareForValidation() decodes it, and from that point forward it validates like any nested array. This decode-then-validate pattern is the standard way to handle JSON schema validation in Laravel – there is no built-in json_schema rule.
If the JSON string is malformed, json_decode() returns null, the merge has no effect, and 'config' => 'required|array' fails with a clear message – the field is still a string.
JSON API Bodies vs JSON String Fields
When a client sends Content-Type: application/json, Laravel decodes the body automatically. Fields arrive as PHP arrays and scalars – the json rule is not needed:
// Request: POST /api/projects
// Content-Type: application/json
// Body: {"name": "Acme", "settings": {"private": true}}
$request->validate([
'name' => 'required|string|max:255',
'settings' => 'required|array',
'settings.private' => 'required|boolean',
]);
The json rule is only for fields where JSON arrives as a string inside a larger form – a textarea, a hidden input, or a multipart field. Applying json to a field that Laravel already decoded will fail, because the field is an array, not a string. This is one of the most frequent mistakes in JSON API validation – adding the json rule to every field out of habit.
For a JSON array body ([{...}, {...}] at the root level), decode and wrap it:
protected function prepareForValidation(): void
{
$body = $this->getContent();
$decoded = json_decode($body, true);
if (is_array($decoded) && array_is_list($decoded)) {
$this->merge(['entries' => $decoded]);
}
}
public function rules(): array
{
return [
'entries' => 'required|list|min:1|max:200',
'entries.*.id' => 'required|integer',
'entries.*.action' => 'required|in:create,update,delete',
];
}
Human-Readable Error Messages for Arrays
By default, error messages for array fields read something like “The items.0.name field is required” – not great for end users. Laravel provides placeholders that insert the element’s position:
| Placeholder | Output | Example |
|---|---|---|
:index | 0-based index | 0, 1, 2 |
:position | 1-based position | 1, 2, 3 |
:ordinal-position | Ordinal (requires intl ext.) | 1st, 2nd, 3rd |
Use them in custom messages inside a Form Request:
public function messages(): array
{
return [
'items.*.name.required' => 'Product name in row :position is required.',
'items.*.price.min' => 'Price for the :ordinal-position item must be at least :min.',
'guests.*.email.email' => 'Guest #:position has an invalid email.',
];
}
For deeply nested arrays, prefix-qualified placeholders address each nesting level: second-index, second-position, third-index, and so on:
'order.items.*.addons.*.name.required' =>
'Addon :second-position of item :position needs a name.',
You can also override the :attribute placeholder itself through the attributes() method in a Form Request:
public function attributes(): array
{
return [
'items.*.name' => 'product name',
'items.*.price' => 'unit price',
'guests.*.email' => 'guest email',
];
}
With this in place, the default message for items.0.name changes from “The items.0.name field is required” to “The product name field is required” – far more readable without writing custom messages for every rule. For a thorough look at message customization, see the error messages article.
Rule::forEach for Dynamic Per-Element Rules
When elements of an array need different rules depending on their content, Rule::forEach() gives you a closure that runs for each element:
use Illuminate\Validation\Rule;
$request->validate([
'documents' => 'required|array|min:1',
'documents.*.type' => 'required|in:passport,license,id_card',
'documents.*.number' => Rule::forEach(function (string|null $value, string $attribute, mixed $data, mixed $context) {
$type = $context['type'] ?? null;
$rules = ['required', 'string'];
if ('passport' === $type) {
$rules[] = 'regex:/^\d{9}$/';
} elseif ('license' === $type) {
$rules[] = 'regex:/^[A-Z]{2}\d{6}$/';
}
return $rules;
}),
]);
The closure receives the current value and the full attribute path (e.g., documents.0.number) – those two are documented. The third ($data) and fourth ($context) arguments are available since Laravel 11 but not covered in the official docs. $context holds the parent element’s sibling fields, which saves you from parsing the attribute path manually. If you want to stay with the documented API only, capture $request with use ($request) and read siblings from there. The closure returns a flat array of rules for that specific element.
For simpler conditional scenarios – skipping a field entirely when another field has a certain value – see conditional validation with exclude_if, exclude_unless, and exclude_without.
Performance with Large Arrays
Every wildcard rule multiplies by the number of elements. Three rules on items.* with 200 items means 600 checks. Format rules (string, integer, email) are cheap. Rules that hit the database – exists, unique – generate a SQL query per element, which can add up fast.
For a batch import endpoint accepting thousands of rows, consider a manual loop with early exit instead of wildcard validation:
$items = $request->input('items', []);
if (false === is_array($items) || 0 === count($items)) {
abort(422, 'Items must be a non-empty array.');
}
if (5000 < count($items)) {
abort(422, 'Too many items.');
}
$errors = [];
foreach ($items as $i => $item) {
if (false === is_string($item['sku'] ?? null)) {
$errors["items.{$i}.sku"] = 'SKU must be a string.';
}
if (false === is_numeric($item['qty'] ?? null) || 1 > $item['qty']) {
$errors["items.{$i}.qty"] = 'Quantity must be at least 1.';
}
// Bail after 20 errors to avoid flooding the response
if (count($errors) >= 20) {
break;
}
}
if ([] !== $errors) {
throw ValidationException::withMessages($errors);
}
This approach can be 30x faster than wildcard rules on very large payloads, because it skips the Validator overhead per element and allows batch DB lookups outside the loop.
Always cap the maximum array length with max:
'rows' => 'required|array|max:500',
Without max, a client can submit 100,000 elements and stall your validation layer. This is especially important for public-facing APIs.
To diagnose how many queries wildcard validation generates, enable the query log:
DB::enableQueryLog();
$request->validate($rules);
$queryCount = count(DB::getQueryLog());
// Check whether exists/unique rules are generating N+1 queries
If you see hundreds of queries for a batch endpoint, consider grouping the existence check into a single WHERE IN query inside an after() hook or a custom rule. The unique and exists article covers batch-friendly patterns in detail.
Full Recipe: Event Registration Form
Bringing together several techniques – nested objects, distinct, in_array, custom messages, and key restrictions:
class RegisterEventRequest extends FormRequest
{
public function rules(): array
{
return [
'event_name' => 'required|string|max:255',
'date' => 'required|date|after:today',
'attendees' => 'required|list|min:1|max:150',
'attendees.*.name' => 'required|string|max:100',
'attendees.*.email' => 'required|email|distinct:ignore_case',
'attendees.*.role' => 'required|in:speaker,guest,volunteer',
'sessions' => 'required|array|min:1',
'sessions.*.time' => 'required|date_format:H:i',
'sessions.*.title' => 'required|string|max:120',
'sessions.*.presenter_email' => [
'required',
'email',
'in_array:attendees.*.email',
],
'settings' => 'required|array:venue,capacity,is_virtual',
'settings.venue' => 'required_unless:settings.is_virtual,true|string|max:300',
'settings.capacity' => 'required|integer|min:1|max:5000',
'settings.is_virtual' => 'required|boolean',
'tags' => 'nullable|array|max:10',
'tags.*' => 'string|max:30|distinct:ignore_case',
];
}
public function messages(): array
{
return [
'attendees.*.email.distinct' => 'Attendee #:position has a duplicate email.',
'sessions.*.presenter_email.in_array' => 'Presenter in session #:position is not listed as an attendee.',
'settings.venue.required_unless' => 'Venue is required for in-person events.',
];
}
}
list enforces sequential indices on attendees. distinct:ignore_case prevents the same person from being listed twice. in_array cross-references the presenter against submitted attendees. array:venue,capacity,is_virtual locks down the settings object. Conditional logic on venue uses required_unless – more on conditional rules in the conditional validation guide.
Common Mistakes
Forgetting the array rule on the parent field. Writing items.* rules without 'items' => 'array' means Laravel will not reject a string or integer for items. The wildcard rules simply never fire, and the non-array value slips into validated().
distinct is case-sensitive by default. [email protected] and [email protected] are treated as different values. For email fields, always use distinct:ignore_case.
required_array_keys does not work on nested paths. It checks top-level keys of the array it is applied to. To verify nested key existence, use wildcard rules on each level.
Trusting validated() without restricting keys. validated() only excludes unvalidated keys when the parent has an array/list rule AND nested wildcard rules are declared, otherwise extra fields can slip through. Add array:email,name on the parent to make the allowed set explicit, or filter the output with $request->safe()->only() before feeding it to $model->fill().
Confusing in_array with in. in validates against a hardcoded list. in_array validates against the values of another field in the same request. Mixing them up causes rules that silently pass or fail in unexpected ways. If you need to check membership in a static list, use in or Rule::in() – see the rules reference for details.
Applying json to an already-decoded field. When the request has Content-Type: application/json, the body is decoded before it reaches your validator. The field is already a PHP array, so the json rule fails. Use json only for fields where JSON is submitted as a raw string.
No max on array length. Every wildcard rule multiplies by the element count. Without an upper bound, a malicious client can submit a massive array and tie up your server. Treat max on arrays the same way you treat max on strings – always set it for public endpoints.
Nested error messages are unreadable without :attribute replacement. Default messages for nested array fields produce keys like items.0.options.1.value – meaningless to end users. Always provide custom messages or override attributes() in Form Requests that handle array input.
in_array rule requires a wildcard path. Writing in_array:colors will not work – the rule expects a dotted path to the values of another field, such as in_array:colors.*. Without the .* suffix, the comparison target is the array itself (a single value), not its elements.
Building a custom rule is the right call when none of the built-in array rules match your constraint – for example, validating that array values follow a specific sort order or that a JSON structure matches an external schema definition.