Mensajes de error de validación en Laravel 13

Cuando una regla de validación falla, Laravel guarda los errores en la sesión y redirige al usuario al formulario, o devuelve un JSON con código 422 si la petición esperaba JSON. Detrás de ese comportamiento por defecto hay varios mecanismos configurables: el MessageBag que se inyecta en las vistas, los archivos de idioma con sus secciones custom/attributes/values, los placeholders en los textos, los named error bags, la excepción ValidationException y los hooks de los Form Requests. Este artículo recorre cada uno con ejemplos prácticos.

Los conceptos generales de validación se cubren en la guía de inicio. La personalización de reglas y mensajes dentro de un Form Request tiene su propio detalle en el artículo de Form Requests.

Cómo Laravel gestiona los errores

Cualquier vía de validación – $request->validate(), un Form Request o Validator::make()->validate() – termina lanzando Illuminate\Validation\ValidationException cuando alguna regla no se cumple. Lo que ocurre a partir de ahí lo decide el exception handler global a partir del tipo de petición:

La decisión se toma en el global exception handler consultando $request->expectsJson(). Internamente equivale a wantsJson() (el primer tipo aceptable del Accept contiene /json o +json) o la combinación ajax() && !pjax() && acceptsAnyContentType() (donde ajax() exige X-Requested-With: XMLHttpRequest y acceptsAnyContentType() exige que el primer tipo aceptable sea */* o *).

Aplicado a clientes concretos:

La regla práctica es fijar Accept: application/json explícitamente. Es la única opción 100% predecible en código manual.

La consecuencia en tests: con $this->post('/api/...') sin cabeceras no llegará Accept: application/json y la respuesta será 302. Hay dos formas idiomáticas de probar el JSON: $this->postJson('/api/...') (atajo que añade la cabecera y serializa el body) o $this->withHeaders(['Accept' => 'application/json'])->post('/api/...'). La primera es más corta y la convención en las pruebas; la segunda existe cuando ya tienes un payload multipart o headers especiales.

Otra consecuencia práctica: las rutas del grupo api no tienen sesión por defecto, así que withErrors() y old() no aplican. El cliente recibe los errores como JSON y la UI los pinta sobre cada campo. En el grupo web, el middleware ShareErrorsFromSession lee los errores flasheados y los publica como $errors en todas las vistas.

Códigos de respuesta y formato

El código de error que Laravel emite para una validación fallida depende del tipo de petición: 302 para formularios web tradicionales (redirect a la página anterior) y 422 para clientes que esperan JSON. No hay un único “código de validación” – son dos rutas distintas con códigos distintos.

Respuesta para formularios web

Cuando un POST tradicional falla la validación, Laravel:

  1. Flashea los errores en sesión: el redirect construido por withErrors($validator, $bag) crea (o reutiliza) un ViewErrorBag y le añade el MessageBag bajo el nombre del bag (default si no se pasa otro) vía ViewErrorBag::put().
  2. Flashea el input depurado: session()->flash('_old_input', Arr::except($request->input(), $dontFlash)), donde $dontFlash contiene current_password, password, password_confirmation por defecto.
  3. Devuelve un redirect a url()->previous() con código 302.

El código 302 no significa “validación errónea” por sí mismo, sino “redirección”. El navegador sigue el redirect, vuelve al formulario y allí $errors aparece poblado en la sesión. Por eso los endpoints sin sesión (grupo api) no pueden depender de este flujo: no hay sesión donde flashear, y un cliente JSON espera el cuerpo de la respuesta, no un Location.

Respuesta JSON

Para peticiones que esperan JSON, Laravel construye una respuesta 422 con esta forma:

{
    "message": "The name field is required. (and 2 more errors)",
    "errors": {
        "name": [
            "The name field is required."
        ],
        "email": [
            "The email field must be a valid email address.",
            "The email field must not be greater than 255 characters."
        ]
    }
}

La clave message contiene el texto del primer error. Si hay más de un error, se le añade un contador entre paréntesis ((and 2 more errors)); con un solo error, ese sufijo no aparece. La clave errors es un objeto donde cada campo apunta a un array de strings. Los campos anidados aparecen en notación de punto: direccion.ciudad, items.0.nombre.

El consumo en el cliente sigue siempre el mismo patrón – iterar errors, asignar cada array al input correspondiente:

try {
    await axios.post('/api/pedidos', datos);
} catch (e) {
    if (422 === e.response?.status) {
        const errors = e.response.data.errors;
        Object.entries(errors).forEach(([campo, mensajes]) => {
            mostrarErrorEnCampo(campo, mensajes[0]);
        });
    }
}

Parsear la string message para extraer información es una mala práctica habitual: la cadena cambia con cada localización y el contador “(and N more errors)” se rompe con cualquier traducción. Trabajar siempre sobre errors.

El código 422 Unprocessable Content (definido originalmente en RFC 4918 y trasladado al estándar HTTP base en RFC 9110) se convirtió en convención habitual para errores de validación – Rails y Laravel lo popularizaron, no es un mandato de ninguna norma REST. Otros equipos eligen 400 Bad Request, que también es válido según HTTP. Laravel emite 422 por defecto; cambiarlo requiere lanzar ValidationException::withMessages(...)->status(400) manualmente o reescribir la respuesta en bootstrap/app.php, dos opciones que veremos más adelante.

La variable $errors en Blade

El middleware ShareErrorsFromSession, incluido en el grupo web, comparte la variable $errors con cada vista. Es una instancia de Illuminate\Support\ViewErrorBag que delega al MessageBag por defecto. Dentro del grupo web la variable siempre existe: cuando no hay errores se devuelve un bag vacío, así que $errors->isEmpty() da true y no hace falta comprobar isset() antes de usarla. En rutas fuera de ese grupo (api, middleware ad-hoc), $errors puede estar indefinida porque el middleware no se ejecutó.

Bloque general de errores arriba del formulario:

@if ($errors->any())
    <div class="alert alert-danger">
        <ul>
            @foreach ($errors->all() as $mensaje)
                <li>{{ $mensaje }}</li>
            @endforeach
        </ul>
    </div>
@endif

$errors->any() indica si existe al menos un error de cualquier campo. $errors->all() aplana todas las claves en un array plano de strings, en el orden de declaración de las reglas. Para una alerta única arriba del formulario este patrón basta.

Si la ruta no está en el grupo web (por ejemplo, un middleware aislado que renderiza HTML desde un endpoint pseudo-API), $errors puede no estar definido porque ShareErrorsFromSession no se ejecutó. Para esos casos hay que pasar la variable manualmente al view(), o mejor mover la ruta al grupo web si la vista necesita el flujo estándar.

Mostrar errores junto a cada campo

Para acercar el mensaje al input concreto, la directiva @error es el camino corto:

<label for="email">Correo electrónico</label>
<input
    type="email"
    name="email"
    id="email"
    value="{{ old('email') }}"
    class="form-control @error('email') is-invalid @enderror"
>
@error('email')
    <p class="invalid-feedback">{{ $message }}</p>
@enderror

Dentro del bloque @error, la variable $message contiene el primer error del campo. La directiva se puede usar también dentro de un atributo (class="@error(...) ... @enderror") para aplicar un estado visual al input sin abrir otro bloque.

Para mostrar todos los mensajes de un campo (no solo el primero), el helper es $errors->get():

@if ($errors->has('email'))
    <ul class="error-list">
        @foreach ($errors->get('email') as $mensaje)
            <li>{{ $mensaje }}</li>
        @endforeach
    </ul>
@endif

Para campos anidados se usa notación de punto en la clave:

@error('direccion.ciudad')
    <p class="error">{{ $message }}</p>
@enderror

Cuando el patrón “label + input + error + old()” se repite en varios formularios, un componente Blade reutilizable evita duplicación y mantiene la consistencia visual:

{{-- resources/views/components/campo.blade.php --}}
@props(['nombre', 'tipo' => 'text', 'etiqueta', 'valor' => null])

<div class="grupo-campo">
    <label for="{{ $nombre }}">{{ $etiqueta }}</label>
    <input
        type="{{ $tipo }}"
        id="{{ $nombre }}"
        name="{{ $nombre }}"
        value="{{ old($nombre, $valor) }}"
        class="form-control {{ $errors->has($nombre) ? 'is-invalid' : '' }}"
    >
    @error($nombre)
        <span class="invalid-feedback">{{ $message }}</span>
    @enderror
</div>

Uso: <x-campo nombre="email" tipo="email" etiqueta="Correo" />. El componente se encarga de old(), de la clase de error y del mensaje. Cuando hay un valor por defecto (por ejemplo en una pantalla de edición), old($nombre, $valor) lo combina con los datos antiguos.

Repoblar el formulario con old()

Cuando un POST tradicional falla la validación, Laravel guarda automáticamente el input en la sesión bajo la clave _old_input. El helper old('campo') recupera ese valor en la siguiente petición:

<input type="text" name="titulo" value="{{ old('titulo') }}">
<textarea name="contenido">{{ old('contenido') }}</textarea>

<select name="categoria">
    @foreach ($categorias as $cat)
        <option value="{{ $cat->id }}" @selected(old('categoria') == $cat->id)>
            {{ $cat->name }}
        </option>
    @endforeach
</select>

<input type="checkbox" name="acepto" @checked(old('acepto'))>

El segundo argumento de old() es el valor por defecto: si no hay input antiguo flasheado, se devuelve ese valor. Esto resulta útil al editar registros existentes, donde se quiere combinar el modelo con cualquier input fallido posterior:

<input type="text" name="titulo" value="{{ old('titulo', $articulo->titulo) }}">

Si la validación nunca falló, sale el título del artículo. Si falló, sale lo que el usuario escribió.

Hay un par de campos que conviene no repoblar:

Para añadir más campos a la exclusión, la API canónica en Laravel 11+/13.x es $exceptions->dontFlash([...]) dentro de withExceptions() en bootstrap/app.php:

// bootstrap/app.php
->withExceptions(function (Exceptions $exceptions) {
    $exceptions->dontFlash([
        'cvv',
        'token_pago',
    ]);
})

dontFlash() se acumula con la lista por defecto, así que pasar ['cvv'] no quita password/current_password/password_confirmation – los conserva y añade cvv. Declarar protected $dontFlash en un Form Request no funciona: el handler lee la propiedad únicamente desde sí mismo, no desde el request.

La API de MessageBag

Tanto $errors en Blade como el resultado de $validator->errors() son instancias de MessageBag (o un ViewErrorBag que envuelve uno o varios bags). Sus métodos clave:

MétodoDevuelvePara qué
first('campo')stringPrimer mensaje del campo. Sin argumento, primer mensaje del primer campo.
get('campo')arrayTodos los mensajes del campo.
all()arrayMensajes planos de todos los campos.
has('campo')boolSi el campo tiene al menos un error.
hasAny(['a','b'])boolSi alguno de los campos tiene error.
any()boolSi hay al menos un error en cualquier campo.
count()intTotal de mensajes en el bag.
isEmpty() / isNotEmpty()boolAtajos opuestos a any().
keys()arrayLista de campos con error.
toArray()arrayEstructura [campo => [mensajes]].
toJson()stringJSON serializado del bag.

Para campos de array con comodín, get('items.*') devuelve un array asociativo [items.0 => [...], items.1 => [...]]:

foreach ($errors->get('items.*') as $clave => $mensajes) {
    // $clave = "items.0", "items.1", ...
}

Esto es útil para iterar errores de arrays sin saber de antemano cuántos elementos llegaron.

merge() combina dos bags – por ejemplo, para añadir mensajes provenientes de un servicio externo al bag del validator. El merge debe formar parte del flujo de validación, típicamente registrado dentro de un callback $validator->after(...) sobre una instancia creada con Validator::make(). Ese callback se ejecuta dentro de validate()/fails(), tras las reglas declarativas y antes de que el Validator decida lanzar ValidationException. Llamar a errors()->merge() directamente sobre $request->validate() no es posible: cuando falla, esa línea lanza la excepción y la ejecución sale del método, por lo que cualquier código siguiente queda inaccesible. Si se quiere añadir errores tras un try/catch, hay que hacerlo sobre $e->validator->errors() y reconstruir manualmente la response – más simple es delegar todo al after():

$externos = new \Illuminate\Support\MessageBag([
    'envio' => ['No hay rutas disponibles a la dirección indicada.'],
]);

$validator->after(function ($v) use ($externos) {
    $v->errors()->merge($externos);
});

Named error bags para varios formularios en una página

Cuando una vista contiene varios formularios independientes (login + registro, perfil + cambio de contraseña), los errores de uno pueden mostrarse en el otro si todos comparten el bag default. Los named error bags resuelven este caso aislando cada conjunto de errores bajo un nombre.

Al crear el validator manualmente, el bag se asigna en withErrors:

return redirect('/cuenta')
    ->withErrors($validator, 'cambio_pass');

Con validateWithBag en una sola línea desde el Request:

$request->validateWithBag('cambio_pass', [
    'password_actual' => 'required|current_password',
    'password'        => 'required|confirmed|min:8',
]);

En un Form Request, el bag se declara con el atributo #[ErrorBag]:

use Illuminate\Foundation\Http\Attributes\ErrorBag;
use Illuminate\Foundation\Http\FormRequest;

#[ErrorBag('cambio_pass')]
class CambiarPasswordRequest extends FormRequest
{
    // ...
}

En la vista, el bag se accede por nombre como propiedad de $errors:

@if ($errors->cambio_pass->any())
    <div class="alert alert-warning">
        {{ $errors->cambio_pass->first() }}
    </div>
@endif

La directiva @error también acepta el bag como segundo argumento:

@error('password_actual', 'cambio_pass')
    <p>{{ $message }}</p>
@enderror

Un patrón completo con login y registro en la misma vista:

<form method="POST" action="/login">
    @csrf
    <input type="email" name="email" value="{{ old('email') }}">
    @error('email', 'login')
        <span>{{ $message }}</span>
    @enderror

    <input type="password" name="password">
    @error('password', 'login')
        <span>{{ $message }}</span>
    @enderror
    <button type="submit">Entrar</button>
</form>

<form method="POST" action="/registro">
    @csrf
    <input type="text" name="nombre" value="{{ old('nombre') }}">
    @error('nombre', 'registro')
        <span>{{ $message }}</span>
    @enderror

    <input type="email" name="email" value="{{ old('email') }}">
    @error('email', 'registro')
        <span>{{ $message }}</span>
    @enderror
    <button type="submit">Registrarse</button>
</form>

El bag por defecto se llama default. $errors->first('email') equivale a $errors->default->first('email'). Mezclar formularios sin bag y con bag en la misma vista lleva al error frecuente de olvidar el segundo argumento de @error: el mensaje “desaparece” porque se busca en default cuando está en login.

Una limitación importante: los named bags solo aíslan los mensajes, no los inputs flasheados. La función old() lee siempre de la clave de sesión global _old_input, que es única por petición. Si el usuario envía el formulario de login con un email inválido y vuelve a la página, el <input name="email"> del formulario de registro también aparece relleno con ese valor, porque ambos consultan old('email'). Para separar los inputs hay que renombrar los campos (login_email y registro_email) o limpiar el input antiguo a mano en el controlador antes de la siguiente render.

Personalizar los mensajes de error

Los textos por defecto vienen en lang/en/validation.php (o el archivo que se publique con lang:publish). Personalizarlos puede hacerse en tres niveles, de más local a más global.

Nivel 1: inline

Tercer argumento de validate(), Validator::make() o validateWithBag():

$request->validate([
    'titulo' => 'required|max:200',
    'cuerpo' => 'required',
], [
    'titulo.required' => 'El artículo necesita un título.',
    'titulo.max'      => 'El título no puede superar :max caracteres.',
    'cuerpo.required' => 'El contenido no puede estar vacío.',
]);

Una clave sin punto ('required' => '...') afecta a todos los campos cuyo error provenga de esa regla. Una clave con punto ('campo.regla' => '...') afecta solo a la combinación indicada. Mezclar ambos niveles es habitual:

$messages = [
    'required'         => 'El campo :attribute es obligatorio.',
    'email.email'      => 'Indica un correo electrónico válido.',
    'email.unique'     => 'Ya hay una cuenta con ese correo.',
];

Nivel 2: método messages() en un Form Request

class GuardarArticuloRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'titulo'       => 'required|max:200',
            'categoria_id' => 'required|exists:categorias,id',
        ];
    }

    public function messages(): array
    {
        return [
            'titulo.required'       => 'Sin título no podemos guardar el artículo.',
            'categoria_id.required' => 'Elige una categoría.',
            'categoria_id.exists'   => 'Esa categoría no existe.',
        ];
    }
}

Funciona igual que el tercer argumento, pero centralizado junto a las reglas. Conviene cuando varias acciones de un controlador requieren las mismas reglas y los mismos mensajes – heredando de un BaseRequest se evita la repetición.

Nivel 3: archivo de idioma

Para mensajes globales que se quieren aplicar a toda la aplicación, los textos viven en lang/{locale}/validation.php. Esto es lo más escalable cuando hay decenas de formularios con campos comunes (email, nombre, telefono).

Prioridad de los mensajes

Cuando Laravel necesita el texto de un error, consulta varias fuentes en este orden y se queda con el primer match:

  1. Mensajes inline pasados a validate(), Validator::make() o devueltos por messages() del Form Request. Se busca primero la clave específica campo.regla, después la clave general regla.
  2. Sección custom del archivo de idioma: custom.campo.regla (por ejemplo custom.email.required).
  3. Sección principal del archivo de idioma: regla directamente (por ejemplo required).

Las traducciones JSON en lang/{locale}.json se consultan a nivel de Translator (que las prioriza sobre los archivos PHP), pero en la práctica solo entran en juego si alguien las usa con claves dotted como "validation.required"; con la convención habitual (texto inglés literal como clave), no chocan con la validación. Más detalles en la sección de JSON translations.

Ejemplo concreto: si el campo email falla en required, Laravel mira primero $messages['email.required'], luego $messages['required'], luego custom.email.required en el archivo de idioma, luego required en el archivo de idioma.

Este orden permite combinar enfoques: un texto global razonable en validation.php ('required' => 'El campo :attribute es obligatorio.') y excepciones en messages() cuando algún formulario necesita una redacción de negocio (“Selecciona un plan antes de continuar”). La sección custom queda para casos donde un mismo campo aparece en muchos formularios con la misma redacción especial – se define una vez y se hereda en todas partes.

Placeholders en los mensajes

Los textos pueden contener placeholders que Laravel reemplaza automáticamente:

PlaceholderQué sustituye
:attributeNombre del campo (con guiones bajos convertidos en espacios, o el valor de attributes())
:inputValor enviado por el usuario para el campo bajo validación
:valueValor del campo comparado en reglas como required_if, accepted_if
:otherNombre de otro campo (para same, different, gt, lt)
:min, :max, :sizeParámetros numéricos de la regla
:valuesLista de valores permitidos (para in, not_in)
:dateParámetro de fecha (en reglas after, before)
:indexÍndice base 0 del elemento de array
:positionPosición base 1 del elemento de array
:ordinal-positionPosición ordinal (1st, 2nd, 3rd) en inglés

Ejemplo con varios placeholders:

$mensajes = [
    'price.between'  => 'El precio :input no entra entre :min y :max euros.',
    'fecha_fin.after'=> 'La fecha de fin debe ser posterior a :date.',
    'password.same'  => 'La contraseña debe coincidir con :other.',
    'rol.in'         => 'El rol debe ser uno de: :values.',
];

Algunas reglas tienen variantes según el tipo de dato. La regla max, por ejemplo, compara longitud para strings, valor numérico para números, tamaño en kilobytes para archivos y cantidad de elementos para arrays. El archivo de idioma lo expresa como array anidado:

'max' => [
    'string'  => 'El campo :attribute no puede tener más de :max caracteres.',
    'numeric' => 'El campo :attribute no puede ser mayor que :max.',
    'file'    => 'El archivo :attribute no puede pesar más de :max KB.',
    'array'   => 'El campo :attribute no puede tener más de :max elementos.',
],

Laravel elige la variante según las reglas hermanas (numeric, integer, file, array) y el tipo real del valor (un UploadedFile siempre se trata como file, un array como array). Sin ninguna regla de tipo y con un valor escalar, el comportamiento por defecto es tratar el dato como string. De ahí un bug clásico: 'edad' => 'max:5' con valor 100 pasa (3 caracteres), mientras que con 100000 falla con mensaje de longitud. Si validas un número con max, declara siempre numeric o integer como hermana para que la comparación sea numérica.

El placeholder :ordinal-position se renderiza mediante Illuminate\Support\Number::ordinal(), que delega en NumberFormatter con la locale activa: en en produce 1st, 2nd, 3rd; en es produce 1.º, 2.º, 3.º (la versión abreviada del ordinal masculino). Necesita la extensión PHP intl. El método replaceOrdinalPositionPlaceholder del validador comprueba extension_loaded('intl') antes de invocar Number::ordinal: sin la extensión, el texto del mensaje queda con la cadena literal :ordinal-position sin sustituir, sin lanzar excepciones. Si tu locale o tu redacción exige el ordinal completo (primero, segunda), :position con texto cuidado es más predecible.

Placeholders personalizados

Los placeholders estándar cubren la mayoría de los casos. Si una regla personalizada necesita parámetros que no son :min ni :max, hay dos caminos.

Validator::replacer (para reglas registradas en Validator)

Validator::extend() registra el callback que decide si un valor pasa la regla. Si el mensaje asociado necesita placeholders más allá de :attribute, hay que registrar adicionalmente un Validator::replacer() con el mismo nombre de regla. Son dos llamadas independientes – el extend funciona sin replacer cuando el mensaje no usa parámetros propios. Ambos suelen vivir en AppServiceProvider::boot():

use Illuminate\Support\Facades\Validator;

public function boot(): void
{
    Validator::extend('max_palabras', function ($attribute, $value, $parameters) {
        // \p{L}+ con flag /u cuenta secuencias de letras Unicode - str_word_count
        // trata caracteres no-ASCII como separadores y rompe con tildes y eñes.
        preg_match_all('/\p{L}+/u', (string) $value, $coincidencias);

        return count($coincidencias[0]) <= (int) $parameters[0];
    });

    Validator::replacer('max_palabras', function ($message, $attribute, $rule, $parameters) {
        return str_replace(':max_palabras', $parameters[0], $message);
    });
}

Después, en cualquier mensaje del archivo de idioma o inline:

'bio.max_palabras' => 'La biografía no puede pasar de :max_palabras palabras.',

Placeholders en reglas de clase

Para las reglas implementadas como clase (Illuminate\Contracts\Validation\ValidationRule), no hace falta replacer. La clase puede construir el mensaje directamente y llamar a $fail() con el texto ya interpolado:

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class MaxPalabras implements ValidationRule
{
    public function __construct(private int $limite) {}

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        preg_match_all('/\p{L}+/u', (string) $value, $coincidencias);

        if (count($coincidencias[0]) > $this->limite) {
            $fail("El campo :attribute no puede tener más de {$this->limite} palabras.");
        }
    }
}

Laravel reemplaza :attribute antes de mostrar el mensaje. Los parámetros propios ($this->limite) se interpolan en la cadena que se pasa a $fail(). Se usa preg_match_all('/\p{L}+/u', ...) en lugar de str_word_count porque el segundo trata caracteres no-ASCII como separadores y devuelve cuentas erróneas con palabras que contienen tildes o eñes (niño, mañana).

Mensajes en reglas personalizadas

Las reglas personalizadas hacen pasar el mensaje de error a través del callback $fail. Hay tres formas de proporcionar el texto.

Texto literal

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class FormatoTelefono implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (0 === preg_match('/^\+34\d{9}$/', (string) $value)) {
            $fail('El campo :attribute debe ser un teléfono español en formato +34XXXXXXXXX.');
        }
    }
}

Laravel sustituye :attribute y devuelve el mensaje tal cual al MessageBag.

Clave de traducción

Cuando la aplicación tiene varias localizaciones, conviene emitir una clave en lugar de un literal y dejar que Laravel busque el texto en lang/{locale}/validation.php:

public function validate(string $attribute, mixed $value, Closure $fail): void
{
    if (0 === preg_match('/^\+34\d{9}$/', (string) $value)) {
        $fail('validation.formato_telefono')->translate();
    }
}

Y en el archivo de idioma:

// lang/es/validation.php
return [
    // ...
    'formato_telefono' => 'El campo :attribute debe ser un teléfono español válido.',
];

Si la regla necesita pasar valores adicionales al texto, translate() acepta un array de reemplazos y una locale opcional:

$fail('validation.formato_telefono')->translate([
    'pais' => 'España',
], 'es');

Dentro del texto, :pais se sustituye por España.

En closures

Las reglas declaradas como closure usan exactamente la misma firma $fail(string $mensaje):

$request->validate([
    'usuario' => [
        'required',
        'string',
        function (string $attribute, mixed $value, Closure $fail) {
            if (str_contains($value, ' ')) {
                $fail("El campo :attribute no puede contener espacios.");
            }
        },
    ],
]);

El callback también acepta el formato $fail('validation.clave')->translate() cuando se trabaja con locales.

Archivos de idioma

Para localizar mensajes a gran escala, los textos viven en archivos PHP dentro de lang/{locale}/. El directorio no existe en una instalación nueva de Laravel – hay que publicarlo:

php artisan lang:publish

El comando copia los archivos por defecto del framework a lang/en/, donde ya pueden editarse. Añadir otra locale exige tres pasos: 1) duplicar el archivo, 2) traducir su contenido, 3) activar la locale en config/app.php o en runtime con App::setLocale() (cubierto en la siguiente sección). Copiar sin activar deja los mensajes en inglés aunque el archivo es/validation.php exista:

cp lang/en/validation.php lang/es/validation.php

Estructura de validation.php

El archivo contiene el array principal de mensajes y tres secciones adicionales:

return [
    'required' => 'El campo :attribute es obligatorio.',
    'email'    => 'El campo :attribute debe ser un correo válido.',
    'max'      => [
        'string'  => 'El campo :attribute no puede tener más de :max caracteres.',
        'numeric' => 'El campo :attribute no puede ser mayor que :max.',
        'file'    => 'El archivo :attribute no puede pesar más de :max KB.',
        'array'   => 'El campo :attribute no puede tener más de :max elementos.',
    ],

    // ... resto de reglas ...

    'custom' => [
        'codigo_promocional' => [
            'exists' => 'Ese código promocional no existe.',
        ],
    ],

    'attributes' => [
        'email'        => 'correo electrónico',
        'password'     => 'contraseña',
        'phone_number' => 'número de teléfono',
        'categoria_id' => 'categoría',
    ],

    'values' => [
        'tipo_pago' => [
            'cc'     => 'tarjeta de crédito',
            'transfer' => 'transferencia bancaria',
        ],
    ],
];

Sección custom

Define mensajes para combinaciones campo + regla de forma centralizada. Útil para campos transversales que aparecen en muchos formularios y donde queremos una redacción única:

'custom' => [
    'fecha_fin_suscripcion' => [
        'after' => 'La fecha de fin de suscripción debe ser futura.',
    ],
    'avatar' => [
        'dimensions' => 'El avatar debe tener al menos 200x200 píxeles.',
    ],
],

En lugar de repetir esos mensajes en cada messages(), se declaran una sola vez aquí.

Sección attributes

Sustituye los nombres técnicos de los campos cuando se interpola :attribute:

'attributes' => [
    'body'       => 'contenido',
    'expired_at' => 'fecha de expiración',
    'qty'        => 'cantidad',
],

En lugar de “El campo expired_at es obligatorio”, el usuario verá “El campo fecha de expiración es obligatorio”. La sustitución afecta a cualquier mensaje que use :attribute, esté en validation.php, en messages() o inline.

Para campos de array con comodín, la clave también admite el patrón:

'attributes' => [
    'items.*.nombre' => 'nombre del producto',
    'items.*.precio' => 'precio del producto',
],

Sección values

Sustituye valores brutos en mensajes condicionales. La regla required_if:tipo_pago,cc genera “El campo CVV es obligatorio cuando tipo_pago es cc”. Con la siguiente declaración:

'values' => [
    'tipo_pago' => [
        'cc'     => 'tarjeta de crédito',
        'wire'   => 'transferencia bancaria',
    ],
    'estado' => [
        'pending'  => 'pendiente',
        'approved' => 'aprobado',
    ],
],

El mensaje pasa a “El campo CVV es obligatorio cuando tipo_pago es tarjeta de crédito”. Se combina bien con attributes para reescribir tanto el nombre del campo como el valor: “El campo CVV es obligatorio cuando tipo de pago es tarjeta de crédito”.

Sobre las reglas condicionales hay más detalle en su propio artículo.

Localización y cambio de idioma

Paquetes de la comunidad

Traducir manualmente cerca de 150 mensajes por idioma es trabajo improductivo. El paquete laravel-lang/common mantiene traducciones revisadas para decenas de locales:

composer require laravel-lang/common --dev
php artisan lang:add es

El comando genera lang/es/validation.php con los mensajes traducidos. También trae auth.php, pagination.php y los textos de la regla Password. Algunas formulaciones pueden necesitar ajuste fino, pero el punto de partida ahorra horas.

Las secciones custom, attributes y values siguen siendo responsabilidad del proyecto – los paquetes no las rellenan porque dependen del dominio.

Configurar la locale por defecto

La locale activa se define en config/app.php:

'locale'          => 'es',
'fallback_locale' => 'en',

fallback_locale actúa cuando una clave no existe en la locale principal. Si lang/es/validation.php no define phone, Laravel mira lang/en/validation.php. Conviene mantener siempre en como fallback porque es el archivo más completo.

Cambio de locale por petición

Para aplicaciones multi-idioma, la locale suele depender del usuario, del primer segmento de la URL o de la cabecera Accept-Language. El cambio se hace con App::setLocale() antes de que la validación se ejecute. Lo más limpio es un middleware:

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;

class FijarLocale
{
    public function handle(Request $request, Closure $next)
    {
        $soportados = ['es', 'en', 'pt'];
        $locale = $request->segment(1);

        if (in_array($locale, $soportados, true)) {
            App::setLocale($locale);
        } elseif ($preferida = $request->user()?->locale) {
            App::setLocale($preferida);
        }

        return $next($request);
    }
}

Registrado en bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) {
    $middleware->web(append: [
        \App\Http\Middleware\FijarLocale::class,
    ]);
})

Es crítico que FijarLocale se ejecute antes de que el controlador sea invocado, porque la validación del Form Request corre durante la resolución de sus argumentos (después de toda la pila de middleware). Registrar FijarLocale en el grupo web con append ya cumple este requisito: cualquier middleware del grupo corre antes del controlador. El antipatrón es llamar a App::setLocale() desde el propio controlador – cuando esa línea se ejecuta, la validación del Form Request ya falló (o pasó) con la locale anterior.

Para flujos sin URL prefijada, otra opción es leer Accept-Language con $request->getPreferredLanguage(['es', 'en']) y aplicar la locale negociada.

JSON translations

Además de los archivos PHP, Laravel soporta traducciones en lang/{locale}.json. La clave es el texto en inglés y el valor su traducción:

{
    "The :attribute field is required.": "El campo :attribute es obligatorio.",
    "The :attribute field must be a valid email address.": "El campo :attribute debe ser un correo válido."
}

El orden real de búsqueda en Translator::get() es al revés del que se suele asumir: primero se consulta lang/{locale}.json y, si la clave no aparece allí, se baja al archivo PHP correspondiente. Sin embargo, la convención manda que las claves JSON sean el texto literal en inglés ("The :attribute field is required."), mientras que la validación interna construye claves con notación de punto (validation.required, validation.max.string). Como esas dos formas no colisionan, el JSON casi nunca interfiere con la validación. La trampa aparece si alguien añade en lang/es.json una clave dotted como "validation.required": entonces sí ganaría al PHP. El formato JSON encaja mejor para textos de interfaz (botones, etiquetas) donde la fuente es la cadena entera en inglés; para validación, los archivos PHP son la opción correcta porque cubren todas las variantes (max.string, max.numeric, etc.) sin riesgo de colisión.

Añadir errores manualmente

A veces la validación de un dato no cabe en una regla declarativa: depende del estado de la base, de la respuesta de un servicio externo o de una comprobación de negocio. El hook after() permite añadir errores tras la validación inicial.

En Validator::make

$validator = Validator::make($request->all(), [
    'card_token' => 'required|string',
    'monto'      => 'required|numeric|min:1',
]);

$validator->after(function ($validator) use ($gateway, $request) {
    if ($validator->errors()->isNotEmpty()) {
        return;
    }

    $resultado = $gateway->preAutorizar($request->input('card_token'));

    if (false === $resultado->ok) {
        $validator->errors()->add(
            'pago',
            'Pago rechazado: ' . $resultado->motivo
        );
    }
});

if ($validator->fails()) {
    return back()->withErrors($validator)->withInput();
}

El callback recibe el Validator y se ejecuta tras las reglas declarativas. La guarda isNotEmpty() evita llamar a servicios externos cuando ya hay errores en los datos básicos – no tiene sentido pedirle a la pasarela que valide una tarjeta cuando el campo card_token ni siquiera estaba presente.

En un Form Request

El método after() de FormRequest cumple el mismo rol:

public function after(): array
{
    return [
        function (\Illuminate\Validation\Validator $v) {
            if (\Carbon\Carbon::now()->isWeekend() && 'urgente' === $this->input('prioridad')) {
                $v->errors()->add('prioridad', 'Las solicitudes urgentes solo se aceptan en días laborables.');
            }
        },
    ];
}

after() devuelve un array de callables. Cada uno recibe la instancia del Validator. La ventaja sobre $validator->after() directo es la encapsulación: la lógica vive con las reglas.

Una nota sobre los textos

Los errores añadidos con $validator->errors()->add() no pasan por messages() ni por ninguna sustitución de placeholders, incluido :attribute. Lo que pases entra literal: si escribes 'El campo :attribute es obligatorio', el usuario verá los dos puntos. Hay que redactarlo completo y legible o invocar __() con los reemplazos manualmente:

$validator->errors()->add(
    'codigo',
    __('mensajes.codigo_expirado', ['fecha' => $codigo->fecha_fin->format('d/m/Y')])
);

ValidationException: control manual

En la mayoría de los casos Laravel maneja la excepción solo. El control manual se necesita cuando se valida fuera del flujo HTTP (en un job, una consola, un servicio) o cuando la respuesta no sigue el formato estándar.

Captura y propiedades

use Illuminate\Validation\ValidationException;

try {
    $datos = $request->validate([
        'monto' => 'required|numeric|min:1',
        'email' => 'required|email',
    ]);
} catch (ValidationException $e) {
    $errores = $e->errors();    // ['monto' => [...], 'email' => [...]]
    $status  = $e->status;      // 422
    $bag     = $e->errorBag;    // 'default'

    logger()->warning('Validation failed', [
        'errors' => $errores,
        'input'  => $request->except(['password', 'cvv']),
    ]);

    return response()->json([
        'ok'     => false,
        'errors' => $errores,
    ], $status);
}

Propiedades clave del objeto:

Propiedad / métodoSignificado
errors()Array [campo => [mensajes]]
statusCódigo HTTP (por defecto 422)
errorBagNombre del bag donde flashean los errores
validatorInstancia del Validator que la lanzó
responseResponse prefabricada, si se asignó
redirectToURL custom para redirigir tras el fallo

Generar errores sin reglas: withMessages

Para emitir errores desde un servicio o un controlador sin pasar por reglas declarativas:

use Illuminate\Validation\ValidationException;

throw ValidationException::withMessages([
    'codigo'  => ['El código promocional ya se ha usado.'],
    'total'   => ['El pedido mínimo es de 10 euros.'],
]);

El handler global captura la excepción y aplica el mismo flujo: redirect con $errors para HTML, JSON 422 para XHR. Es la mejor forma de devolver errores de validación desde un service object sin acoplarlo al ciclo de request:

class CrearPedidoService
{
    public function ejecutar(array $datos): Pedido
    {
        if ($this->codigoYaUsado($datos['codigo'] ?? null)) {
            throw ValidationException::withMessages([
                'codigo' => ['El código promocional ya se ha usado.'],
            ]);
        }

        // ...
    }
}

Cambiar el status code

throw ValidationException::withMessages([
    'cuenta' => ['La cuenta está bloqueada.'],
])->status(403);

403 cuando el fallo es de permisos, 409 para conflictos de estado, 410 para recursos expirados. El cliente recibe el JSON { message, errors } con el nuevo código.

Redirigir a otra ruta

throw ValidationException::withMessages([
    'paso' => ['Rellena los datos básicos antes de continuar.'],
])->redirectTo(route('checkout.paso1'));

Tras flashear los errores, Laravel redirige al destino indicado en lugar del referer.

Personalizar la respuesta JSON

El formato { message, errors } cubre los frontends habituales (Vue, React, Inertia, Livewire). Cuando el cliente espera otra envoltura – por ejemplo { ok: false, code: "VALIDATION_FAILED", details: {...} } – hay dos formas de adaptarlo.

Por Form Request (failedValidation)

namespace App\Http\Requests\Api;

use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\Exceptions\HttpResponseException;

abstract class ApiFormRequest extends FormRequest
{
    protected function failedValidation(Validator $validator): void
    {
        throw new HttpResponseException(
            response()->json([
                'ok'      => false,
                'code'    => 'VALIDATION_FAILED',
                'details' => $validator->errors()->toArray(),
            ], 422)
        );
    }
}

Todos los Form Requests de la API heredan de ApiFormRequest y obtienen el mismo formato. La técnica con HttpResponseException convierte el resultado en una respuesta inmediata: el handler global de Laravel reconoce esa excepción y devuelve la response embebida tal cual. La consecuencia práctica es que cualquier render(ValidationException $e ...) registrado en bootstrap/app.php no se aplica desde aquí – la exception lanzada es HttpResponseException, no ValidationException.

Globalmente en bootstrap/app.php

Para no atar el formato a una clase concreta, el handler global puede interceptar ValidationException:

// bootstrap/app.php
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;

->withExceptions(function (Exceptions $exceptions) {
    $exceptions->render(function (ValidationException $e, Request $request) {
        if ($request->expectsJson()) {
            return response()->json([
                'ok'      => false,
                'code'    => 'VALIDATION_FAILED',
                'details' => $e->errors(),
            ], $e->status);
        }
    });
})

Devolver null o no retornar nada en el closure deja que Laravel use el flujo por defecto – útil para condicionar el formato a determinadas rutas (if ($request->is('api/*'))).

El primer enfoque es local: solo afecta a peticiones que pasan por ApiFormRequest. El segundo afecta a todo el proyecto. En aplicaciones mixtas (Blade + API), suele convenir el primero por aislamiento. En APIs puras, el segundo evita duplicar failedValidation en cada clase.

Índices y posiciones en arrays

Cuando se validan arrays, la clave de error usa notación de punto con el índice numérico:

$request->validate([
    'items'         => 'required|array|min:1',
    'items.*.nombre' => 'required|string',
    'items.*.qty'    => 'required|integer|min:1',
]);

Si el tercer elemento falla, el bag tendrá la clave items.2.nombre. En Blade, la repoblación funciona con la misma notación:

@foreach (old('items', $items ?? []) as $i => $item)
    <input type="text" name="items[{{ $i }}][nombre]"
           value="{{ old("items.{$i}.nombre") }}">
    @error("items.{$i}.nombre")
        <span class="error">{{ $message }}</span>
    @enderror
@endforeach

En mensajes personalizados, los placeholders de posición son los que dan utilidad a los textos:

$mensajes = [
    'items.*.nombre.required' => 'Indica el nombre del producto #:position.',
    'items.*.qty.min'         => 'La cantidad del producto #:position debe ser al menos :min.',
];

Para arrays anidados, second-index, second-position, third-index, etc. dan acceso a los niveles internos. :position (equivalente a :first-position) apunta al primer segmento numérico del attribute path; :second-position apunta al segundo, y así sucesivamente. En paquetes.*.fotos.*.nombre, el primer numérico es el índice de paquete y el segundo es el de foto:

'paquetes.*.fotos.*.nombre' => 'La foto #:second-position del paquete #:position no es válida.',

Si un fallo viene de paquetes.0.fotos.2.nombre, el mensaje queda “La foto #3 del paquete #1”.

Sin :position, el mensaje por defecto incluye la clave técnica (items.0.qty), que confunde al usuario.

Redirigir a una página concreta tras el fallo

Por defecto, una validación fallida en un POST tradicional devuelve al referer. Para flujos en varios pasos (checkout, wizard de configuración) o cuando el formulario está en otra ruta de la que se envía, el destino se puede cambiar.

En un Form Request con atributo

use Illuminate\Foundation\Http\Attributes\RedirectTo;
use Illuminate\Foundation\Http\Attributes\RedirectToRoute;
use Illuminate\Foundation\Http\FormRequest;

#[RedirectTo('/checkout/paso-1')]
class ProcesarCheckoutRequest extends FormRequest
{
    // ...
}

// O por nombre de ruta:
#[RedirectToRoute('checkout.paso1')]
class ProcesarCheckoutRequest extends FormRequest
{
    // ...
}

Con propiedades del Form Request

class ProcesarCheckoutRequest extends FormRequest
{
    protected $redirect = '/checkout/paso-1';
    // o:
    protected $redirectRoute = 'checkout.paso1';
}

Las propiedades funcionan igual que los atributos – cualquiera de las dos formas vale. Los atributos son más recientes y juegan bien con #[ErrorBag] y #[StopOnFirstFailure] en el mismo Form Request.

Manualmente con redirect()->withErrors()

Cuando no se usa Form Request:

$validator = Validator::make($request->all(), $reglas);

if ($validator->fails()) {
    return redirect()->route('checkout.paso1')
        ->withErrors($validator)
        ->withInput();
}

withErrors() flashea los errores en sesión, withInput() flashea los valores del formulario.

Errores frecuentes

Los errores no aparecen en Blade. La ruta no está en el grupo web. Sin el middleware ShareErrorsFromSession, la variable $errors no se inyecta en las vistas. Mover la ruta al grupo web o aplicar el middleware de forma explícita.

La API devuelve 302 en lugar de 422. El cliente no envía Accept: application/json. Sin esa cabecera, $request->expectsJson() devuelve false y Laravel responde con redirect. En tests, postJson() añade la cabecera automáticamente; post() no.

Mensajes en inglés a pesar de tener traducciones. La locale no está cambiada antes de la validación. Si el middleware que llama a App::setLocale() se ejecuta después del Form Request, los textos salen en el idioma por defecto. Verificar el orden en bootstrap/app.php.

Aparece validation.required como texto. El archivo lang/{locale}/validation.php no existe o no contiene la clave required. Ejecutar php artisan lang:publish y comprobar que la locale activa tiene el archivo.

@error('email') no encuentra nada cuando hay un bag con nombre. Olvidar el segundo argumento de @error es el error frecuente con named error bags. La directiva sin nombre busca en default; los errores están en otro bag. Pasar siempre el bag: @error('email', 'login').

Inputs se pierden al fallar la validación. Falta withInput() en el redirect o el campo está en la lista $dontFlash. Las contraseñas se excluyen por defecto del flash y por buena razón – no aparezcan en el HTML renderizado.

:attribute muestra snake_case crudo. No hay entrada para ese campo en attributes() ni en la sección attributes del archivo de idioma. Si el HTML usa nombres_complicados, traducirlos a etiquetas legibles allí.

Solo aparece el primer mensaje por campo. La directiva @error expone únicamente $message con el primero. Para mostrar varios, iterar $errors->get('campo'). En la práctica, con la regla bail que detiene tras el primer fallo, ver más de uno es raro – pero conviene saberlo.

Pruebas de mensajes de error

Laravel proporciona varios asserts dedicados a errores de validación.

Errores en sesión (formularios web)

public function test_titulo_es_obligatorio(): void
{
    $response = $this->post('/articulos', [
        'cuerpo' => 'Texto del artículo',
    ]);

    $response->assertSessionHasErrors(['titulo']);
}

Para verificar también el texto:

$response->assertSessionHasErrors([
    'titulo' => 'El artículo necesita un título.',
]);

El array acepta el texto exacto del mensaje esperado. Para tests más estables frente a cambios de redacción suele usarse el formato de claves assertSessionHasErrors(['titulo']) (solo presencia) o combinarse con assertSessionHasErrors(['titulo' => 'texto exacto']) cuando interesa fijar la copia.

Errores en JSON (API)

public function test_api_devuelve_422(): void
{
    $response = $this->postJson('/api/articulos', []);

    $response->assertStatus(422)
        ->assertJsonValidationErrors(['titulo', 'cuerpo'])
        ->assertJson([
            'errors' => [
                'titulo' => ['El artículo necesita un título.'],
            ],
        ]);
}

assertJsonValidationErrors comprueba que las claves listadas estén dentro del objeto errors. Si se pasa un array asociativo ['titulo' => 'texto esperado'], también verifica el mensaje.

Sin errores

$response = $this->post('/articulos', [
    'titulo' => 'Nueva entrada',
    'cuerpo' => 'Contenido.',
]);

$response->assertSessionHasNoErrors();

Errores en un bag con nombre

$response->assertSessionHasErrors(
    keys: ['email'],
    errorBag: 'login'
);

Comprobar que un campo concreto NO tiene error

$response->assertSessionDoesntHaveErrors(['titulo']);

Útil cuando una prueba valida que un cambio no rompió una regla cercana.

Probar ValidationException en servicios

Cuando la validación vive en un service object:

public function test_servicio_valida_entrada(): void
{
    $this->expectException(ValidationException::class);

    $servicio = new ProcesarPedidoService;
    $servicio->ejecutar(['monto' => -1]);
}

Para inspeccionar los errores concretos:

public function test_servicio_devuelve_error_de_monto(): void
{
    try {
        (new ProcesarPedidoService)->ejecutar(['monto' => -1]);
        $this->fail('Se esperaba ValidationException.');
    } catch (ValidationException $e) {
        $this->assertArrayHasKey('monto', $e->errors());
        $this->assertStringContainsString('mínimo', $e->errors()['monto'][0]);
    }
}

Probar la locale en mensajes

Si la aplicación cambia la locale con un middleware, las pruebas pueden forzar el idioma:

public function test_mensajes_en_espanol(): void
{
    App::setLocale('es');

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

    $response->assertStatus(422)
        ->assertJsonValidationErrors(['titulo']);
}

assertJsonValidationErrors(['titulo']) solo comprueba presencia del campo en el objeto errors, sin atarse al texto exacto. Si interesa fijar también la traducción concreta (por ejemplo para garantizar que la locale de la aplicación es la esperada), pasar el array asociativo verifica ambas cosas a la vez: $response->assertJsonValidationErrors(['titulo' => 'El campo titulo es obligatorio.']). La ventaja sobre assertJsonFragment es que no obliga al test a conocer la estructura concreta del JSON (errors.titulo[0]).

Sobre el resto de la mecánica de testing de reglas personalizadas y de comprobaciones contra la base de datos hay detalles específicos en sus artículos.