Validación en Laravel 13: guía rápida

Cualquier dato que llega desde el navegador, una API externa o un archivo cargado tiene que pasar por un filtro antes de tocar la base de datos. Un campo precio que acepta un string vacío, un email sin arroba o un rol que el cliente inyecta a mano son los casos clásicos que terminan en una excepción en producción o, peor, en datos inválidos ya persistidos.

Laravel ofrece tres formas de validar los datos de una solicitud: el método validate() directamente sobre el Request, una clase dedicada llamada Form Request y la creación manual de un validador con la fachada Validator. Cada una encaja en un escenario distinto.

Validar dentro del controlador

La forma más directa es invocar $request->validate() en el propio método del controlador. La llamada valida los datos del formulario (o cualquier dato de la solicitud) y devuelve únicamente los campos que pasaron las reglas:

public function store(Request $request): RedirectResponse
{
    $validated = $request->validate([
        'titulo'  => 'required|unique:articulos|max:255',
        'cuerpo'  => 'required|string',
    ]);

    Articulo::create($validated);

    return to_route('articulos.index');
}

Si los datos no superan alguna regla, Laravel lanza una ValidationException. Para una solicitud HTTP normal eso significa redirección hacia atrás con los errores almacenados en la sesión. Para una solicitud XHR o de API (cuando llega la cabecera Accept: application/json), Laravel responde con un 422 y un cuerpo JSON.

Esto implica algo fácil de pasar por alto: la ejecución se detiene en validate() cuando algo falla, así que el código que viene después solo corre si todas las reglas pasaron.

Sintaxis con string o array

Las reglas se pueden escribir como una cadena separada por barras o como un array. El resultado es idéntico:

// Cadena con barras: compacta y útil para reglas simples
'email' => 'required|email|max:255',

// Array: obligatorio cuando una regla contiene comas u objetos Rule
'email' => ['required', 'email:rfc,dns', Rule::unique('users')->ignore($user->id)],

La forma de array también es necesaria para patrones regex que contengan |, ya que de lo contrario la barra se interpreta como separador de reglas.

Detener en el primer fallo: bail

Por defecto Laravel evalúa todas las reglas de cada campo y junta todos los errores en una pasada. Añadir bail a un campo corta su cadena de validación al primer fallo:

$validated = $request->validate([
    'email'  => 'bail|required|email|unique:users',
    'nombre' => 'required|string|max:100',
]);

Esto evita trabajo innecesario: si email no supera required, no tiene sentido lanzar la consulta unique. El campo nombre se sigue validando independientemente.

Si quieres llevar el corte aún más lejos y parar la validación entera al primer fallo (no solo dentro de un campo, sino entre campos distintos), usa stopOnFirstFailure() sobre un validador manual o el atributo #[StopOnFirstFailure] en una Form Request:

use Illuminate\Foundation\Http\Attributes\StopOnFirstFailure;

#[StopOnFirstFailure]
class ImportarFilaRequest extends FormRequest
{
    // ...
}

Con el validador manual:

if ($validator->stopOnFirstFailure()->fails()) {
    // $validator->errors() contiene un único error
}

Esto resulta útil sobre todo en importaciones y operaciones por lotes, donde fallar rápido ahorra recursos.

Form Request: clase dedicada para la validación

Cuando el método del controlador acumula diez reglas, mensajes personalizados y lógica de autorización, conviene mover todo eso a una clase Form Request:

php artisan make:request StoreArticuloRequest

La clase generada vive en app/Http/Requests:

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreArticuloRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('create', Articulo::class);
    }

    public function rules(): array
    {
        return [
            'titulo'      => ['required', 'string', 'max:255'],
            'cuerpo'      => ['required', 'string', 'min:50'],
            'categoria_id'=> ['required', 'exists:categorias,id'],
            'tags'        => ['nullable', 'array', 'max:5'],
            'tags.*'      => ['string', 'max:30'],
        ];
    }
}

Basta con declarar la clase como dependencia del controlador para que Laravel ejecute la validación antes de entrar al cuerpo del método:

public function store(StoreArticuloRequest $request): RedirectResponse
{
    $articulo = Articulo::create($request->validated());

    return to_route('articulos.show', $articulo);
}

El método authorize() controla el acceso. Si devuelve false, el usuario recibe un 403 sin que las reglas lleguen a evaluarse. El stub de make:request genera authorize() con return false para forzar una decisión explícita; si te olvidas de modificarlo, todas las peticiones devuelven 403 y la búsqueda del fallo termina mirando en las reglas. Si la autorización vive en middleware o gates, elimina el método por completo de la subclase y se aplicará el return true del FormRequest base.

Convención de nombres: acción + entidad + Request. StoreArticuloRequest, UpdateUsuarioRequest, DestroyComentarioRequest. El nombre deja claro a qué acción corresponde la clase.

La cobertura completa de Form Request (prepareForValidation(), hooks after(), atributos, redirección personalizada) se encuentra en el artículo dedicado a Form Request.

Validator::make(): creación manual

La tercera vía construye un validador a mano con la fachada Validator. Es la opción adecuada cuando los datos no vienen de una solicitud HTTP, sino de un trabajo en cola, un comando Artisan o la importación de un CSV:

use Illuminate\Support\Facades\Validator;

$validator = Validator::make($fila, [
    'email' => ['required', 'email'],
    'edad'  => ['required', 'integer', 'min:18'],
]);

if ($validator->fails()) {
    Log::warning('Fila descartada', ['errores' => $validator->errors()->all()]);
    continue;
}

$limpio = $validator->validated();

Si llamas a $validator->validate() sin el if, el comportamiento es el mismo que $request->validate(): lanza ValidationException al fallar. En contexto web eso provoca la redirección; en un trabajo en cola tienes que capturar la excepción manualmente con un try/catch.

Cuándo elegir cada enfoque

Para controladores con dos a cinco reglas, $request->validate() es suficiente. Un formulario de contacto, un filtro de búsqueda, un interruptor en la página de ajustes: crear una clase entera para eso es excesivo.

Form Request gana sentido cuando se acumula lógica: mensajes personalizados, prepareForValidation(), hooks after(), una docena de reglas. También resulta más fácil de probar de forma aislada, ya que se pueden testear las reglas sin levantar todo el stack HTTP.

Validator::make() cubre cualquier escenario fuera de HTTP. Colas que procesan filas de CSV, comandos Artisan que comprueban configuración, servicios que reciben webhooks: ninguno de esos sitios tiene un objeto $request.

Guía rápida de decisión:

Las tres opciones usan las mismas reglas y producen la misma estructura de errores. Migrar de una a otra más adelante es cuestión de mover el array: la sintaxis no cambia, solo el contenedor.

Trabajar con los datos validados

Después de validar, no pases $request->all() al modelo. Usa la salida validada: así cierras la puerta a ataques de mass assignment donde un usuario añade campos como es_admin=1 a la solicitud:

// Array completo de datos validados
$datos = $request->validated();

// Seleccionar campos concretos
$solo = $request->safe()->only(['nombre', 'email']);

// Excluir campos
$sin = $request->safe()->except(['password_confirmation']);

// Añadir campos después de la validación
$mezclado = $request->safe()->merge(['ip' => $request->ip()]);

El método safe() devuelve una instancia de ValidatedInput, un objeto que contiene únicamente los campos que pasaron las reglas. Puedes iterar sobre él, acceder como si fuera un array o convertirlo en colección.

Un detalle que sorprende al principio: validated() devuelve solo las claves declaradas en las reglas. Si la solicitud trae un campo apodo que no está en el array, no aparecerá en validated(), aunque siga disponible vía $request->input('apodo').

Mostrar los errores en Blade

Cuando una solicitud web falla la validación, Laravel redirige hacia atrás y guarda los errores en la sesión. La variable $errors queda disponible en cualquier vista gracias al middleware ShareErrorsFromSession:

@if ($errors->any())
    <ul>
        @foreach ($errors->all() as $error)
            <li>{{ $error }}</li>
        @endforeach
    </ul>
@endif

Para errores por campo, la directiva @error resulta más cómoda:

<label for="email">Correo</label>
<input
    type="email"
    name="email"
    value="{{ old('email') }}"
    class="@error('email') border-red-500 @enderror"
>
@error('email')
    <p class="text-red-500">{{ $message }}</p>
@enderror

El helper old() recupera el valor anterior desde la sesión, así el usuario no pierde lo que había escrito cuando se produce un error.

Un matiz: $errors es siempre una instancia de ViewErrorBag, incluso cuando no hay errores en la sesión. Eso significa que isset($errors) siempre es true. Para saber si hay errores reales hay que llamar a $errors->any().

La personalización de mensajes y la localización están cubiertas en el artículo de mensajes de error.

Respuestas JSON para APIs

Cuando la solicitud entrante espera JSON (cabecera Accept: application/json o XHR), Laravel se salta la redirección y devuelve un 422:

{
    "message": "El campo título es obligatorio. (and 1 more error)",
    "errors": {
        "titulo": [
            "El campo título es obligatorio."
        ],
        "cuerpo": [
            "El campo cuerpo es obligatorio."
        ]
    }
}

No hace falta configuración adicional. Tanto $request->validate() como Form Request detectan el formato esperado de forma automática. Frameworks como Vue, React o Inertia reciben los errores con una estructura predecible donde las claves coinciden con los nombres de los campos.

El único detalle sutil está en cómo Laravel decide si la petición “espera JSON”: pasa por expectsJson(), que mira la cabecera Accept. Si tu cliente JavaScript dispara una petición sin esa cabecera, Laravel responde con redirección en lugar de 422, aunque la llamada venga de XHR. Sin Accept: application/json, el flujo JSON no se activa.

Campos opcionales y nullable

Laravel incluye dos middlewares globales relevantes para esto: TrimStrings y ConvertEmptyStringsToNull. El primero recorta espacios; el segundo convierte "" en null. La consecuencia práctica: un <input> opcional vacío llega como null, y sin nullable falla cualquier regla de tipo:

$request->validate([
    'titulo'      => ['required', 'string', 'max:255'],
    'subtitulo'   => ['nullable', 'string', 'max:255'],
    'publicar_en' => ['nullable', 'date', 'after:today'],
]);

Sin nullable, un subtitulo vacío se transforma en null y no supera la comprobación string. Regla práctica: cada campo opcional necesita nullable.

Las reglas específicas para fechas (after, before, date_format) se detallan en el artículo sobre fechas. Para subida de archivos hay un artículo sobre archivos. La diferencia entre nullable, sometimes, filled y present está en la referencia de reglas.

Preparar los datos antes de validar

El método prepareForValidation() en una Form Request transforma la entrada antes de que se ejecuten las reglas:

protected function prepareForValidation(): void
{
    $this->merge([
        'slug'     => Str::slug($this->titulo),
        'telefono' => preg_replace('/[^\d+]/', '', $this->telefono),
    ]);
}

Casos típicos: normalizar números de teléfono, generar slugs, recortar HTML; así las reglas reciben datos ya limpios.

El hook contrario, passedValidation(), corre tras una validación exitosa y permite ajustar los datos antes de que llegue el controlador.

Validación adicional con after()

El método after() en Form Request permite añadir comprobaciones que se ejecutan después de las reglas estándar. Es el sitio adecuado para lógica de negocio que no se puede expresar como una regla:

public function after(): array
{
    return [
        function (\Illuminate\Validation\Validator $validator) {
            if ($this->solapaConReservaExistente()) {
                $validator->errors()->add(
                    'fecha',
                    'La fecha seleccionada se solapa con otra reserva.'
                );
            }
        },
    ];
}

Para comprobaciones complejas conviene extraerlas a clases invocables:

public function after(): array
{
    return [
        new ValidateBookingAvailability,
        new ValidatePaymentMethod,
    ];
}

Para casos donde la lógica se reutiliza en varios endpoints, lo apropiado es una regla personalizada en lugar de un closure.

Mensajes de error personalizados

Los mensajes por defecto de Laravel son funcionales pero genéricos. En Form Request se sobrescriben mediante messages():

public function messages(): array
{
    return [
        'titulo.required' => 'Cada artículo necesita un título.',
        'titulo.max'      => 'El título no puede superar :max caracteres.',
        'email.unique'    => 'Ese correo ya está registrado.',
    ];
}

Con Validator::make() los mensajes se pasan como tercer argumento:

$validator = Validator::make($datos, $reglas, [
    'required' => 'El campo :attribute no puede estar vacío.',
]);

El placeholder :attribute se reemplaza por el nombre del campo. Para mostrar “correo electrónico” en lugar de “email”, se sobrescribe attributes():

public function attributes(): array
{
    return [
        'email'         => 'correo electrónico',
        'fecha_nac'     => 'fecha de nacimiento',
        'compania_id'   => 'compañía',
    ];
}

La guía completa sobre localización, archivos de idioma y placeholders adicionales está en el artículo de mensajes de error.

Varios formularios en la misma página

Dos formularios independientes en la misma vista (login y registro, filtro y búsqueda, dos modales en el admin) comparten una bolsa de errores común, así que un envío fallido en uno enciende los bloques @error del otro. Las bolsas con nombre los aíslan: $request->validateWithBag('registro', [...]) en flujos con FormRequest, redirect()->withErrors($validator, 'login') o ->validateWithBag('login') con validador manual. En Blade los errores se leen por nombre: $errors->registro->first('email') o @error('email', 'registro'). Un ejemplo completo con dos formularios en una misma vista, la semántica de withErrors y la interacción con Validator::make están en el artículo de mensajes de error.

Reglas condicionales

Las reglas pueden depender de otros campos de la solicitud. Las más sencillas son required_if, required_with y required_without:

$request->validate([
    'tipo_cuenta'  => ['required', 'in:personal,empresa'],
    'nombre_empresa' => ['required_if:tipo_cuenta,empresa', 'string', 'max:200'],
    'cif'          => ['required_with:nombre_empresa', 'string', 'size:9'],
]);

Para condiciones más complejas, el validador manual ofrece sometimes() con un closure:

use Illuminate\Support\Fluent;

$validator->sometimes('nombre_empresa', 'required|string|max:200', function (Fluent $input) {
    return 'empresa' === $input->tipo_cuenta;
});

El conjunto completo de reglas condicionales está en el artículo de validación condicional.

Atributos anidados y notación con punto

Para datos anidados se usan puntos en los nombres de los campos:

$request->validate([
    'direccion.ciudad'    => ['required', 'string'],
    'direccion.cp'        => ['required', 'string', 'size:5'],
    'items.*.producto_id' => ['required', 'exists:productos,id'],
    'items.*.cantidad'    => ['required', 'integer', 'min:1'],
]);

El comodín * aplica las reglas a cada elemento del array. Si el array está vacío y es opcional, ninguna regla con comodín se dispara.

Si el nombre de un campo contiene un punto literal, hay que escaparlo con barra invertida:

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

Más sobre validación de arrays, comodines y la regla distinct en el artículo de arrays y JSON.

Validación fuera de HTTP

Validator::make() funciona en cualquier sitio, no solo en controladores. Por ejemplo, en un comando Artisan:

class ImportUsuariosCommand extends Command
{
    protected $signature = 'usuarios:importar {archivo}';

    public function handle(): int
    {
        $filas = CsvReader::read($this->argument('archivo'));
        $descartadas = 0;

        foreach ($filas as $i => $fila) {
            $validator = Validator::make($fila, [
                'email'  => ['required', 'email', 'unique:users'],
                'nombre' => ['required', 'string', 'max:100'],
            ]);

            if ($validator->fails()) {
                $this->warn("Fila {$i}: " . $validator->errors()->first());
                $descartadas++;
                continue;
            }

            User::create($validator->validated());
        }

        $this->info("Listo. Descartadas: {$descartadas}");

        return self::SUCCESS;
    }
}

El mismo patrón vale para jobs de cola, seeders y clases de servicio: en tareas en segundo plano no hay redirección, así que los errores se manejan explícitamente con fails() y errors(). Cuando el origen del dato es un webhook o una llamada entre servicios, a veces interesa dejar que la excepción suba hasta el controlador y la lógica del servicio no tenga que ocuparse del caso de error:

class PaymentWebhookService
{
    public function process(array $payload): void
    {
        $validated = Validator::make($payload, [
            'event'    => ['required', 'in:payment.success,payment.failed'],
            'order_id' => ['required', 'exists:orders,id'],
            'amount'   => ['required', 'numeric', 'min:0.01'],
        ])->validate();

        $order = Order::findOrFail($validated['order_id']);

        if ('payment.success' === $validated['event']) {
            $order->markAsPaid($validated['amount']);
        }
    }
}

Llamar a ->validate() sobre la instancia del validador lanza ValidationException si algo falla; el controlador que recibe el webhook la captura y devuelve 422.

Validación al actualizar

Actualizar un registro se diferencia de crearlo en un solo punto: la regla unique debe ignorar el registro actual. Sin esto, el usuario no puede guardar su propio perfil porque su email “ya está en uso”:

use Illuminate\Validation\Rule;

class UpdateUsuarioRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'nombre' => ['required', 'string', 'max:100'],
            'email'  => [
                'required',
                'email',
                Rule::unique('users')->ignore($this->user()),
            ],
        ];
    }
}

Más sobre unique, exists, soft deletes y restricciones multicolumna en el artículo sobre unique y exists.

Ejemplo completo: formulario de registro

Rutas:

Route::get('/registro', [RegisterController::class, 'create']);
Route::post('/registro', [RegisterController::class, 'store']);

Form Request:

class RegisterRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'nombre'   => ['required', 'string', 'max:60'],
            'email'    => ['required', 'email:rfc,dns', 'unique:users'],
            'password' => ['required', 'confirmed', Password::min(8)->mixedCase()->numbers()],
        ];
    }
}

El modo email:rfc,dns comprueba a la vez la conformidad RFC y la existencia del dominio en DNS; el resto de modos están descritos en el artículo de strings y números. El builder Password impone requisitos de complejidad; los detalles están en el artículo sobre contraseñas.

Controlador:

class RegisterController extends Controller
{
    public function create(): View
    {
        return view('auth.register');
    }

    public function store(RegisterRequest $request): RedirectResponse
    {
        $usuario = User::create($request->validated());

        Auth::login($usuario);

        return to_route('dashboard');
    }
}

Fragmento del formulario en Blade:

<form method="POST" action="/registro">
    @csrf

    <input type="text" name="nombre" value="{{ old('nombre') }}">
    @error('nombre') <span>{{ $message }}</span> @enderror

    <input type="email" name="email" value="{{ old('email') }}">
    @error('email') <span>{{ $message }}</span> @enderror

    <input type="password" name="password">
    @error('password') <span>{{ $message }}</span> @enderror

    <input type="password" name="password_confirmation">

    <button type="submit">Registrarse</button>
</form>

Personalizar la redirección al fallar

Por defecto, una validación fallida redirige a la página anterior. En Form Request se ajusta con un atributo:

use Illuminate\Foundation\Http\Attributes\RedirectTo;

#[RedirectTo('/panel')]
class UpdatePerfilRequest extends FormRequest
{
    // ...
}

O hacia una ruta con nombre:

use Illuminate\Foundation\Http\Attributes\RedirectToRoute;

#[RedirectToRoute('perfil.editar')]
class UpdatePerfilRequest extends FormRequest
{
    // ...
}

Con un validador manual, el control de la redirección recae en ti:

if ($validator->fails()) {
    return redirect('/ajustes')
        ->withErrors($validator)
        ->withInput();
}

La llamada a withInput() guarda los valores enviados en la sesión para que old() los recupere en el formulario.

Validación en vivo con Precognition

Laravel Precognition permite validar campo a campo según el usuario los rellena, antes de enviar el formulario. Encaja bien en interfaces con mucho AJAX donde esperar al envío para mostrar errores se siente lento:

Route::post('/articulos', [ArticuloController::class, 'store'])
    ->middleware('precognitive');

Y en el frontend (ejemplo con Vue):

import { useForm } from 'laravel-precognition-vue';

const form = useForm('post', '/articulos', {
    titulo: '',
    cuerpo: '',
});

// Disparar validación al perder el foco
function onBlur(field) {
    form.validate(field);
}

Precognition envía la solicitud con la cabecera Precognition: true. Laravel ejecuta los middlewares y resuelve las dependencias del controlador (incluida la Form Request), pero salta el cuerpo del método. Las reglas viven solo en la Form Request y se reutilizan tanto para las comprobaciones en tiempo real como para el envío final, sin duplicar lógica en JavaScript.

Hay adaptadores para Vue, React y Alpine. Inertia trae soporte nativo para Precognition, y esa combinación resulta la forma más limpia de añadir validación en vivo sin duplicar reglas en el cliente.

Probar la validación

Conviene cubrir tanto el escenario correcto como el rechazo:

public function test_registro_requiere_nombre(): void
{
    $response = $this->post('/registro', [
        'email'    => '[email protected]',
        'password' => 'SecurePass1',
        'password_confirmation' => 'SecurePass1',
    ]);

    $response->assertSessionHasErrors('nombre');
}

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

    $response->assertStatus(422)
             ->assertJsonValidationErrors(['producto_id', 'cantidad']);
}

public function test_datos_validos_crean_registro(): void
{
    $response = $this->post('/registro', [
        'nombre'   => 'Ana García',
        'email'    => '[email protected]',
        'password' => 'SecurePass1',
        'password_confirmation' => 'SecurePass1',
    ]);

    $response->assertSessionHasNoErrors();
    $this->assertDatabaseHas('users', ['email' => '[email protected]']);
}

assertSessionHasErrors para formularios web, assertJsonValidationErrors para APIs. Para probar una Form Request de forma aislada también se puede instanciar la clase, pasarle los datos vía setContainer() y resolver las reglas sin levantar el ciclo HTTP entero.

Errores comunes

El error que más veces aparece en revisiones de código es usar $request->all() en lugar de validated(). La primera forma pasa al modelo campos sin validar y deja la puerta abierta a ataques de mass assignment. Usa siempre $request->validated() o $request->safe().

Olvidar nullable es el clásico problema de quien empieza. Los campos vacíos se convierten en null por el middleware global, y sin nullable las reglas de tipo (string, date, etc.) rechazan el valor.

El método authorize() generado por make:request devuelve false por defecto: es una decisión consciente del stub para que el desarrollador piense la autorización antes de exponer la ruta. Olvidar este return false provoca un 403 sin pista alguna y mucha gente termina buscando el problema en las reglas. El true por defecto del FormRequest base solo se aplica si eliminas el método de la subclase.

Ignorar el valor que devuelve validate() es otro patrón que aparece a menudo, sobre todo en código que migra desde validación manual:

// Mal: ignora la salida validada
$request->validate(['titulo' => 'required']);
$titulo = $request->input('titulo');

// Bien: usa los datos validados
$validated = $request->validate(['titulo' => 'required']);
$titulo = $validated['titulo'];

Copiar las reglas entre Store y Update genera divergencia con el tiempo. Las reglas son casi idénticas, pero unique en el update tiene que ignorar el registro actual. Mejor extraer las reglas comunes a un trait o a un método base y dejar que cada Form Request añada sus diferencias.

Envolver validate() en try/catch casi nunca tiene sentido. Laravel ya captura ValidationException y produce la redirección o el 422; capturarla a mano solo se justifica cuando necesitas un formato de error no estándar o quieres registrar los fallos en una herramienta de monitorización.

Por último, el clásico “la validación no funciona”. Si los errores no aparecen en Blade, lo primero es comprobar que la ruta pasa por el grupo de middleware web: ahí vive ShareErrorsFromSession. Para rutas de API hay que asegurarse de que el cliente envía Accept: application/json; sin esa cabecera Laravel responde con redirección en lugar de 422.

Otras dos causas aparecen con cierta frecuencia. Una: $errors está vacío porque la página se abrió con un GET tras la redirección y la sesión ya consumió los datos. La otra: el action del formulario apunta a una ruta distinta de la esperada, con lo que se ejecuta otro controlador y las reglas nunca llegan a correr.

Siguiente