Validar arrays y JSON en Laravel 13
Los formularios con filas dinámicas, selectores múltiples y campos repetitivos envían los datos como arrays. Las APIs reciben JSON con estructuras anidadas. Laravel valida ambos formatos con la misma sintaxis: notación de punto y comodines *. Este artículo cubre desde arrays simples hasta estructuras profundamente anidadas, pasando por reglas específicas como distinct, in_array, contains y la exclusión condicional de campos.
Para los conceptos generales de validación, consulta la guía de inicio rápido. Las reglas que se aplican a cada elemento individual (como string, integer, email) están en la referencia de reglas.
La regla array y la regla list
La regla array confirma que el campo es un array PHP. Sin argumentos, acepta cualquier array asociativo o indexado. Con argumentos, restringe las claves permitidas:
$request->validate([
'filtros' => 'array:estado,categoria,orden',
'filtros.estado' => 'nullable|string',
'filtros.categoria' => 'nullable|integer',
'filtros.orden' => 'nullable|in:asc,desc',
]);
Si el input contiene filtros[prioridad], la validación falla porque prioridad no está en la lista de claves permitidas. Esto evita que claves inesperadas lleguen al validated() y de ahí a una consulta o a un Model::create().
La regla list es más restrictiva: exige que el array sea una lista (claves numéricas consecutivas empezando desde 0). La diferencia importa cuando se reciben datos de un formulario con filas dinámicas:
$request->validate([
'etiquetas' => 'required|list|min:1',
'etiquetas.*' => 'string|max:50',
]);
Si alguien envía etiquetas[5 => 'rojo', 8 => 'azul'] (claves no consecutivas), list rechaza el input. array lo aceptaría. Cuando el frontend elimina filas del formulario y los índices quedan salteados, hay que reindexar antes de enviar o usar array en vez de list.
La combinación de ambas reglas permite validar con precisión lo que se espera. Para un array asociativo con claves conocidas (un formulario de filtros), array:clave1,clave2. Para una lista ordenada de valores (tags, IDs seleccionados), list. Para un array sin restricciones en las claves, array sin argumentos.
Validar arrays de tipos específicos
Para arrays donde cada elemento debe ser de un tipo concreto, las reglas de tipo van en el comodín:
// Rechaza enteros, booleanos u otros tipos mezclados
$request->validate([
'etiquetas' => 'required|array',
'etiquetas.*' => 'string|max:50',
]);
// Solo IDs positivos, sin ceros ni strings numéricos
$request->validate([
'ids' => 'required|array',
'ids.*' => 'integer|min:1',
]);
// Sin duplicados por capitalización: [email protected] = [email protected]
$request->validate([
'emails' => 'required|array|min:1',
'emails.*' => 'email|distinct:ignore_case',
]);
La combinación array + regla de tipo en * cubre la gran mayoría de validaciones de listas homogéneas. Si el array puede contener tipos mixtos (strings y enteros), se necesita una regla personalizada o un closure – las reglas estándar validan un tipo por campo.
Validar cada elemento con el comodín *
El comodín * aplica reglas a cada elemento del array:
$request->validate([
'productos' => 'required|array|min:1',
'productos.*.nombre' => 'required|string|max:200',
'productos.*.precio' => 'required|numeric|min:0',
'productos.*.cantidad' => 'required|integer|min:1',
]);
Un punto que no es evidente: si se define solo productos.* con reglas, pero no se valida productos como array, un input que envíe productos como string pasará las reglas del comodín (porque no hay elementos sobre los que iterar) y el campo no se rechazará. La regla array en el campo padre es la primera línea de defensa.
Para arrays de valores escalares sin clave, las reglas van directamente en el comodín:
// Array de IDs: [3, 17, 42]
$request->validate([
'categorias' => 'required|array',
'categorias.*' => 'integer|exists:categorias,id',
]);
Validar un array no vacío
La combinación required|array|min:1 garantiza que el campo es un array presente y con al menos un elemento. required solo comprueba que el campo exista y no esté vacío; min:1 (con la regla array activa) comprueba que la longitud del array sea al menos 1. Cuando se necesita limitar la longitud máxima:
$request->validate([
'imagenes' => 'required|array|min:1|max:10',
'imagenes.*' => 'image|max:5120',
]);
max:10 limita el array a 10 elementos. max:5120 en el comodín limita el tamaño de cada archivo a 5 MB. El contexto de la regla max cambia según el tipo del campo – en arrays se refiere al conteo, en archivos al peso en kilobytes.
Arrays anidados
La notación de punto funciona a cualquier profundidad:
$request->validate([
'pedido' => 'required|array',
'pedido.cliente' => 'required|array',
'pedido.cliente.nombre' => 'required|string|max:100',
'pedido.cliente.email' => 'required|email',
'pedido.lineas' => 'required|array|min:1',
'pedido.lineas.*.producto_id' => 'required|integer|exists:productos,id',
'pedido.lineas.*.cantidad' => 'required|integer|min:1|max:999',
'pedido.lineas.*.opciones' => 'nullable|array',
'pedido.lineas.*.opciones.*.nombre' => 'required_with:pedido.lineas.*.opciones|string',
'pedido.lineas.*.opciones.*.valor' => 'required_with:pedido.lineas.*.opciones|string',
]);
Cada nivel de profundidad se puede validar de forma independiente. Las reglas required_with en las opciones garantizan que si se envían opciones, cada una tenga nombre y valor.
Un patrón frecuente es el de formularios con filas dinámicas donde el frontend permite añadir y eliminar líneas. El HTML envía los datos como lineas[0][producto_id], lineas[1][producto_id], etc. Si el usuario elimina la fila del medio, los índices pueden quedar como 0 y 2 (sin 1). El comodín * maneja esto sin problemas – itera sobre las claves que existan, no asume un rango continuo.
Para validar que un campo referenciado por otro existe dentro de la misma estructura anidada, se puede usar la notación completa del path con comodín:
$request->validate([
'secciones' => 'required|array',
'secciones.*.titulo' => 'required|string',
'secciones.*.responsable_email' => 'required|email',
'secciones.*.revisores' => 'nullable|array',
'secciones.*.revisores.*' => 'email|different:secciones.*.responsable_email',
]);
La regla different compara el email del revisor con el del responsable de la misma sección, no con los de otras secciones.
Rule::forEach para reglas dinámicas
Cuando las reglas de un elemento dependen del valor de otro campo en la misma iteración, Rule::forEach proporciona acceso al valor y al nombre expandido del atributo:
use Illuminate\Validation\Rule;
$request->validate([
'participantes' => 'required|array',
'participantes.*.tipo' => 'required|in:persona,empresa',
'participantes.*.documento' => [
'required',
Rule::forEach(function (string|null $valor, string $atributo) {
$indice = explode('.', $atributo)[1];
$tipo = request("participantes.{$indice}.tipo");
return 'empresa' === $tipo
? ['regex:/^[A-Z]\d{8}$/']
: ['regex:/^\d{8}[A-Z]$/'];
}),
],
]);
El closure recibe el valor actual del campo y su nombre completo (por ejemplo, participantes.0.documento). Desde ahí se puede extraer el índice y consultar otros campos de la misma iteración.
Valores únicos: distinct
La regla distinct comprueba que no haya duplicados dentro del array enviado. No consulta la base de datos – eso lo hace unique. La comparación por defecto es loose (==):
$request->validate([
'invitados' => 'required|array',
'invitados.*.email' => 'required|email|distinct',
]);
Si se envía el mismo email dos veces, la validación falla en ambas ocurrencias. Para una comparación estricta (===), que distingue entre 1 y '1':
'codigos.*' => 'distinct:strict'
Para ignorar diferencias de mayúsculas y minúsculas (útil en emails y nombres de usuario):
'invitados.*.email' => 'required|email|distinct:ignore_case'
Sin ignore_case, [email protected] y [email protected] se consideran valores distintos y ambos pasan. Con ignore_case, el segundo se marca como duplicado.
in_array vs in
in_array y in son reglas diferentes que se confunden con frecuencia.
in comprueba que el valor esté en una lista fija definida en la regla:
'estado' => 'in:pendiente,procesando,completado'
in_array comprueba que el valor exista dentro de otro campo del mismo input:
$request->validate([
'roles_disponibles' => 'required|array',
'roles_disponibles.*' => 'string',
'rol_seleccionado' => 'required|in_array:roles_disponibles.*',
]);
Aquí rol_seleccionado debe ser uno de los valores presentes en roles_disponibles. El comodín .* es obligatorio – sin él, in_array compara contra el array como valor, no contra sus elementos.
in_array_keys
La regla in_array_keys valida que un array contenga al menos una de las claves especificadas:
$request->validate([
'configuracion' => 'required|array|in_array_keys:zona_horaria,idioma',
]);
El array configuracion debe tener al menos la clave zona_horaria o idioma (o ambas). Para exigir que todas las claves estén presentes, la regla es required_array_keys:
$request->validate([
'configuracion' => 'required|array|required_array_keys:zona_horaria,idioma',
]);
contains y doesnt_contain
La regla contains verifica que un array incluya todos los valores indicados:
use Illuminate\Validation\Rule;
$request->validate([
'permisos' => [
'required',
'array',
Rule::contains(['leer', 'escribir']),
],
]);
El array permisos debe contener al menos 'leer' y 'escribir', aunque puede tener más elementos. La inversa es doesnt_contain:
$request->validate([
'roles' => [
'required',
'array',
Rule::doesntContain(['superadmin']),
],
]);
Ambas reglas usan la forma fluent con Rule::contains() y Rule::doesntContain(). También se pueden escribir en formato string (contains:leer,escribir), pero la forma fluent es más legible cuando la lista de valores viene de una variable.
Excluir campos condicionales en arrays
Las reglas exclude_if, exclude_unless, exclude_with y exclude_without controlan qué campos llegan a validated(). En formularios con secciones dinámicas, esto evita que campos irrelevantes contaminen los datos validados:
$request->validate([
'tipo_envio' => 'required|in:domicilio,tienda',
'direccion' => 'exclude_unless:tipo_envio,domicilio|required|string|max:255',
'codigo_postal' => 'exclude_unless:tipo_envio,domicilio|required|string|size:5',
'tienda_id' => 'exclude_unless:tipo_envio,tienda|required|exists:tiendas,id',
]);
Si tipo_envio es tienda, los campos direccion y codigo_postal no aparecen en los datos validados aunque el usuario los haya enviado. La exclusión ocurre antes de que las reglas del campo se evalúen – required no falla para un campo excluido.
La forma fluent Rule::excludeIf() acepta un booleano o un closure:
use Illuminate\Validation\Rule;
$request->validate([
'cupon' => [
Rule::excludeIf(fn () => ! $request->has('aplicar_cupon')),
'required',
'string',
'exists:cupones,codigo',
],
]);
Dentro de arrays con comodín, las reglas exclude_* funcionan elemento por elemento. Un caso práctico: un formulario de contacto con múltiples destinatarios donde el teléfono solo se requiere si el canal es SMS:
$request->validate([
'destinatarios' => 'required|array|min:1',
'destinatarios.*.canal' => 'required|in:email,sms',
'destinatarios.*.email' => 'exclude_unless:destinatarios.*.canal,email|required|email',
'destinatarios.*.telefono' => 'exclude_unless:destinatarios.*.canal,sms|required|string',
]);
Cada destinatario se evalúa de forma independiente: si el primer destinatario usa email, su campo telefono se excluye del output de validated(), mientras que el segundo destinatario con canal sms excluye email. Los datos validados reflejan exactamente lo que corresponde a cada canal. La validación condicional se trata en profundidad en el artículo de validación condicional.
Validar datos JSON
La regla json
La regla json comprueba que un string sea JSON válido. Esto es relevante cuando un campo del formulario envía JSON como texto (un textarea con configuración, un campo oculto con datos serializados):
$request->validate([
'metadatos' => 'required|json',
]);
Un detalle: json solo valida que el string sea parseable. No comprueba la estructura interna. Un string "true", "42" o "null" pasan la regla json porque son JSON válido según la especificación.
Validar la estructura del JSON
Para validar las claves y valores internos, el enfoque estándar es decodificar y validar por separado. En un Form Request, prepareForValidation es el lugar adecuado:
use Illuminate\Foundation\Http\FormRequest;
class ConfiguracionRequest extends FormRequest
{
protected function prepareForValidation(): void
{
if (is_string($this->metadatos)) {
$decodificado = json_decode($this->metadatos, true);
if (is_array($decodificado)) {
$this->merge(['metadatos' => $decodificado]);
}
}
}
public function rules(): array
{
return [
'metadatos' => 'required|array',
'metadatos.color' => 'required|string|max:7',
'metadatos.tamano' => 'required|in:sm,md,lg,xl',
'metadatos.visible' => 'required|boolean',
];
}
}
Una vez decodificado en prepareForValidation, el campo se trata como un array asociativo y se valida con la notación de punto habitual.
JSON en peticiones de API
Cuando la petición llega con Content-Type: application/json, Laravel decodifica el body automáticamente. No hace falta usar la regla json ni decodificar manualmente – los campos del JSON están disponibles directamente en $request:
// POST /api/pedidos con body JSON:
// { "cliente": { "nombre": "Ana", "email": "[email protected]" }, "items": [...] }
$request->validate([
'cliente' => 'required|array',
'cliente.nombre' => 'required|string',
'cliente.email' => 'required|email',
'items' => 'required|array|min:1',
'items.*.producto_id' => 'required|integer',
'items.*.cantidad' => 'required|integer|min:1',
]);
La regla json es para campos individuales que contienen JSON como string dentro de una petición mayor (un formulario HTML con un campo oculto, por ejemplo). Si toda la petición es JSON, la decodificación ya está hecha.
JSON como array de objetos en APIs
Un caso habitual en APIs es recibir un array de objetos JSON. La validación combina array, list y comodines:
// POST /api/inventario/ajuste
// Body: [{"sku": "ABC-001", "cantidad": 5}, {"sku": "DEF-002", "cantidad": -3}]
$request->validate([
'*' => 'required|array',
'*.sku' => 'required|string|exists:productos,sku',
'*.cantidad' => 'required|integer|not_in:0',
]);
Cuando el body entero es un array (no un objeto), se valida directamente con * como raíz. Cada elemento se trata como un array asociativo con sus propias reglas. Este patrón es menos común que envolver los datos en un objeto ({"items": [...]}), pero ocurre en APIs que siguen convenciones de otros frameworks.
Mensajes de error en arrays
Los errores de campos con comodín usan claves en notación de punto: productos.0.nombre, productos.1.precio. Sin personalización, el mensaje incluye esa clave técnica como nombre de atributo. Para mensajes legibles:
$request->validate([
'productos.*.nombre' => 'required|string',
'productos.*.precio' => 'required|numeric|min:0',
], [
'productos.*.nombre.required' => 'El nombre del producto #:position es obligatorio.',
'productos.*.precio.required' => 'Indica el precio del producto #:position.',
'productos.*.precio.min' => 'El precio del producto #:position no puede ser negativo.',
]);
:position empieza en 1, :index en 0. La numeración sigue el orden de los comodines de izquierda a derecha en la ruta del atributo: :position corresponde al primer *, :second-position al segundo, :third-position al tercero, etc.:
'pedidos.*.items.*.nombre.required' =>
'Falta el nombre del artículo #:second-position en el pedido #:position.'
Los atributos personalizados también funcionan con comodines en el archivo de idioma:
// lang/es/validation.php
'custom' => [
'productos.*.nombre' => [
'required' => 'Cada producto necesita un nombre.',
],
],
'attributes' => [
'productos.*.nombre' => 'nombre del producto',
'productos.*.precio' => 'precio del producto',
],
Más detalles sobre personalización de mensajes, placeholders y named error bags en el artículo de mensajes de error.
Rendimiento con arrays grandes
La validación con comodines crea una instancia de regla por cada elemento del array. Para un array con 50 productos y 3 campos cada uno, Laravel evalúa 150 reglas individuales. Esto funciona sin problema. Para arrays con miles de elementos (importaciones CSV, migraciones masivas), la validación puede volverse lenta.
Dos alternativas para estos casos:
// Opción 1: validar el array como bloque y los elementos en un bucle
$datos = $request->validate([
'registros' => 'required|array|max:5000',
]);
$errores = [];
foreach ($datos['registros'] as $i => $registro) {
$v = Validator::make($registro, [
'email' => 'required|email',
'nombre' => 'required|string|max:100',
]);
if ($v->fails()) {
$errores["registros.{$i}"] = $v->errors()->all();
}
}
// Opción 2: validar solo la estructura y delegar el contenido a un job
$request->validate([
'registros' => 'required|array|min:1|max:10000',
'registros.*' => 'array',
]);
ImportarRegistros::dispatch($request->validated()['registros']);
La primera opción permite parar con bail tras N errores. La segunda delega la validación detallada a un job en background, donde el tiempo de ejecución no bloquea al usuario.
Errores frecuentes
Olvidar la regla array en el campo padre – si productos no se valida como array, un string en productos no falla (el comodín no itera). La regla array en el padre es obligatoria.
Confundir in_array con in – in recibe una lista fija de valores (in:a,b,c). in_array compara contra otro campo del input (in_array:campo_origen.*). Sin el .* en in_array, la comparación no funciona como se espera.
distinct case-sensitive – por defecto, distinct distingue mayúsculas de minúsculas. Dos emails que solo difieren en capitalización ([email protected] y [email protected]) se consideran distintos. Usar distinct:ignore_case cuando los valores deben ser únicos sin importar capitalización.
json no valida estructura – la regla json confirma que el string es JSON parseable, no que tenga las claves o tipos esperados. Para validar estructura, hay que decodificar (en prepareForValidation o middleware) y aplicar reglas con notación de punto sobre el array resultante.
Mensajes ilegibles en arrays – el mensaje por defecto incluye la clave técnica (productos.0.nombre). Sin mensajes personalizados con :position o atributos en el archivo de idioma, el usuario ve texto incomprensible. Para formularios de cara al público, los mensajes para campos de array deben personalizarse siempre.
required_array_keys vs array:clave1,clave2 – ambas validan claves, pero con lógica inversa. array:clave1,clave2 rechaza claves que no estén en la lista (whitelist). required_array_keys:clave1,clave2 exige que esas claves existan, pero permite claves adicionales. Elegir la que corresponda al caso de uso.
Comodín * en niveles profundos – la regla required_with:pedido.lineas.*.opciones referencia el comodín en la ruta del campo. Laravel expande el comodín al índice real de cada iteración. No hay que escribir required_with:pedido.lineas.0.opciones manualmente para cada posible índice.
validated() solo devuelve campos con reglas – si un array tiene campos que no aparecen en las reglas de validación, validated() los excluye del resultado. Esto es intencional y funciona como whitelist implícita. Si se necesita todo el input del array, se puede usar $request->input('campo'), pero se pierde la garantía de que los datos pasaron validación. Para incluir un campo sin validarlo, basta añadirlo con una regla vacía o con sometimes.
Para la lista completa de reglas de validación, consulta la referencia de reglas. Las reglas de exclusión y adición condicional de campos se cubren en el artículo de validación condicional. La creación de reglas propias para arrays se trata en reglas personalizadas.