Form Requests en Laravel 13

Un controlador que valida, autoriza, sanea datos y ejecuta la lógica de negocio deja de ser legible pasadas las 40 líneas. Un Form Request separa esa responsabilidad: la validación y la autorización viven en su propia clase, y el controlador recibe datos ya limpios. El resultado es un controlador más delgado, una clase de validación que se puede testear de forma aislada y un único lugar donde modificar reglas cuando cambian los requisitos.

El ciclo de vida de un Form Request sigue un orden estricto:

  1. prepareForValidation() – sanear/normalizar input
  2. authorize() – comprobar permisos
  3. rules() – evaluar reglas de validación
  4. after() – hooks de validación adicional
  5. passedValidation() – transformar la salida

Entender esta secuencia aclara la mayoría de comportamientos inesperados. Por ejemplo, en prepareForValidation aún no hay datos validados (las reglas no se han ejecutado), y en after() los errores de las reglas ya están registrados pero el controlador todavía no tiene acceso a los datos.

Para conceptos generales de validación, consulta la guía de inicio rápido. La sintaxis de reglas individuales está en la referencia de reglas.

Crear un Form Request

php artisan make:request GuardarFacturaRequest

El archivo se genera en app/Http/Requests. Por defecto contiene dos métodos – authorize() y rules():

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

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

    public function rules(): array
    {
        return [
            'cliente_id' => ['required', 'exists:clientes,id'],
            'lineas'     => ['required', 'array', 'min:1'],
            'lineas.*.producto_id' => ['required', 'exists:productos,id'],
            'lineas.*.cantidad'    => ['required', 'integer', 'min:1'],
            'notas'      => ['nullable', 'string', 'max:500'],
        ];
    }
}

En el controlador basta con inyectar la clase por type-hint. Laravel valida la petición antes de ejecutar el método del controlador:

public function store(GuardarFacturaRequest $request): RedirectResponse
{
    $datos = $request->validated();

    Factura::create($datos);

    return redirect()->route('facturas.index');
}

Si la validación falla, Laravel genera un redirect con los errores en sesión. Si la petición es XHR (Ajax/fetch), devuelve un JSON con status 422. La distinción depende del header Accept – más sobre esto en la sección de APIs.

El método authorize

authorize() determina si el usuario autenticado tiene permiso para esta acción. Cuando devuelve false, Laravel responde con un 403 sin ejecutar el controlador:

public function authorize(): bool
{
    $proyecto = $this->route('proyecto');

    return $this->user()->can('editar', $proyecto);
}

El método route() da acceso a los parámetros de la URL. Si la aplicación usa route model binding, el modelo resuelto está disponible como propiedad del request:

public function authorize(): bool
{
    return $this->user()->can('editar', $this->proyecto);
}

Un error habitual: el generador make:request crea authorize() con return false. Los desarrolladores obtienen un 403 en lugar de errores de validación y depuran la capa equivocada. Si la autorización se gestiona en otro lugar (middleware, policy), basta con devolver true o eliminar el método por completo.

Si necesitas inyectar dependencias, puedes declararlas en la firma de authorize() – Laravel las resuelve desde el contenedor:

public function authorize(PermisoService $permisos): bool
{
    return $permisos->puedeCrearFactura($this->user());
}

Cuando authorize() devuelve false, Laravel lanza una AuthorizationException que se traduce en una respuesta 403. Es diferente de un error de validación (422): el controlador ni siquiera se ejecuta. En APIs, el JSON de respuesta contiene {"message": "This action is unauthorized."} sin detalles de campos – lo cual puede confundir si se espera el formato de errores de validación.

Personalizar mensajes y atributos

El método messages() permite redefinir los mensajes de error para reglas concretas:

public function messages(): array
{
    return [
        'cliente_id.required' => 'Selecciona un cliente.',
        'cliente_id.exists'   => 'El cliente seleccionado no existe.',
        'lineas.min'          => 'La factura necesita al menos una línea.',
    ];
}

El método attributes() cambia el nombre que aparece en los mensajes (el placeholder :attribute):

public function attributes(): array
{
    return [
        'cliente_id'           => 'cliente',
        'lineas.*.producto_id' => 'producto de la línea',
        'lineas.*.cantidad'    => 'cantidad',
    ];
}

Sin attributes(), un mensaje genérico diría “El campo lineas.0.producto_id es obligatorio”. Con él: “El campo producto de la línea es obligatorio”.

Para arrays con comodín, el placeholder :position permite incluir la posición del elemento:

public function messages(): array
{
    return [
        'lineas.*.producto_id.required' => 'El producto en la línea :position es obligatorio.',
        'lineas.*.cantidad.min'         => 'La cantidad en la línea :position debe ser al menos :min.',
    ];
}

Detalles sobre cómo personalizar mensajes en el artículo de mensajes de error.

prepareForValidation – sanear antes de validar

prepareForValidation() se ejecuta antes de que se evalúen las reglas. Es el lugar adecuado para normalizar datos de entrada:

use Illuminate\Support\Str;

protected function prepareForValidation(): void
{
    $this->merge([
        'slug' => Str::slug($this->titulo),
        'email' => strtolower(trim($this->email)),
    ]);
}

$this->merge() modifica solo las claves indicadas; el resto del input no cambia. Si necesitas reemplazar todo el input, existe $this->replace(), aunque rara vez es lo que se busca.

Un caso práctico frecuente: el frontend envía fechas en formato local (15/06/2026) pero la base de datos necesita Y-m-d. Convertir en prepareForValidation() permite que las reglas trabajen con el formato normalizado:

protected function prepareForValidation(): void
{
    if ($this->fecha_evento) {
        $parsed = Carbon::createFromFormat('d/m/Y', $this->fecha_evento);
        if ($parsed) {
            $this->merge(['fecha_evento' => $parsed->format('Y-m-d')]);
        }
    }
}

Más sobre validación de fechas y la interacción con prepareForValidation en el artículo de fechas.

Valores por defecto

Para asignar un valor por defecto cuando un campo no viene en la petición:

protected function prepareForValidation(): void
{
    $this->mergeIfMissing([
        'moneda' => 'EUR',
        'impuesto' => 21,
    ]);
}

mergeIfMissing() no sobrescribe campos que ya existen – solo rellena los ausentes. Combinado con la regla sometimes, permite que un campo tenga un valor por defecto sin forzar su presencia:

protected function prepareForValidation(): void
{
    $this->mergeIfMissing([
        'idioma' => 'es',
    ]);
}

public function rules(): array
{
    return [
        'idioma' => ['sometimes', 'string', 'in:es,en,fr,de'],
    ];
}

Si el cliente no envía idioma, el valor será es. Si lo envía, se valida contra la lista permitida.

passedValidation – transformar después de validar

passedValidation() se ejecuta cuando la validación ha pasado, antes de que el controlador reciba los datos:

protected function passedValidation(): void
{
    $this->merge([
        'nombre' => mb_convert_case($this->nombre, MB_CASE_TITLE),
    ]);
}

Mientras prepareForValidation sanea la entrada (antes de las reglas), passedValidation transforma la salida (después de las reglas). Juntos forman un pipeline: sanear, validar, normalizar.

Un ejemplo combinado – un formulario de perfil donde el teléfono llega con espacios y guiones, y el nombre necesita capitalización:

protected function prepareForValidation(): void
{
    $this->merge([
        'telefono' => preg_replace('/[\s\-]/', '', $this->telefono ?? ''),
    ]);
}

protected function passedValidation(): void
{
    $this->merge([
        'nombre' => mb_convert_case($this->nombre, MB_CASE_TITLE),
    ]);
}

El controlador recibe telefono sin espacios y nombre con la primera letra de cada palabra en mayúscula, sin preocuparse de la transformación.

after() – validación adicional con lógica compleja

El método after() devuelve un array de callables que se ejecutan después de que pasen las reglas. Reciben una instancia del Validator, lo que permite añadir errores de forma programática:

use Illuminate\Validation\Validator;

public function after(): array
{
    return [
        function (Validator $validator) {
            if ($this->descuento > $this->subtotal) {
                $validator->errors()->add(
                    'descuento',
                    'El descuento no puede superar el subtotal.'
                );
            }
        },
    ];
}

Los errores añadidos en after() no pasan por el método messages() – usan el texto literal que proporciones, no las sustituciones de :attribute. Tenlo en cuenta al redactar el mensaje.

Clases invocables para validaciones reutilizables

Cuando la validación cruzada se repite en varios Form Requests, se puede extraer a una clase invocable:

namespace App\Validation;

use Illuminate\Validation\Validator;

class ValidarStockDisponible
{
    public function __invoke(Validator $validator): void
    {
        $lineas = $validator->getValue('lineas') ?? [];

        foreach ($lineas as $i => $linea) {
            $stock = Producto::find($linea['producto_id'])?->stock ?? 0;
            if ($linea['cantidad'] > $stock) {
                $validator->errors()->add(
                    "lineas.{$i}.cantidad",
                    "Stock insuficiente para el producto."
                );
            }
        }
    }
}

Y en el Form Request:

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

Atributos PHP 8

Laravel ofrece atributos como alternativa a propiedades y métodos para ciertas configuraciones:

#[StopOnFirstFailure]

Detiene la validación de todos los campos tras el primer error:

use Illuminate\Foundation\Http\Attributes\StopOnFirstFailure;

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

#[RedirectTo] y #[RedirectToRoute]

Cambian adónde redirige el form request cuando falla:

use Illuminate\Foundation\Http\Attributes\RedirectTo;

#[RedirectTo('/registro')]
class RegistroRequest extends FormRequest
{
    // ...
}

Con ruta nombrada:

use Illuminate\Foundation\Http\Attributes\RedirectToRoute;

#[RedirectToRoute('registro.formulario')]
class RegistroRequest extends FormRequest
{
    // ...
}

#[ErrorBag]

Los errores se almacenan en el error bag default por defecto. Para usar un bag distinto:

use Illuminate\Foundation\Http\Attributes\ErrorBag;

#[ErrorBag('login')]
class LoginRequest extends FormRequest
{
    // ...
}

Resulta necesario cuando una misma vista tiene varios formularios y cada uno necesita su propio grupo de errores. Más sobre named error bags en el artículo de mensajes de error.

#[FailOnUnknownFields]

Rechaza campos que no están cubiertos por ninguna regla:

use Illuminate\Foundation\Http\Attributes\FailOnUnknownFields;

#[FailOnUnknownFields]
class ActualizarPerfilRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'nombre' => ['required', 'string', 'max:255'],
            'email'  => ['required', 'email'],
        ];
    }
}

Si la petición incluye un campo rol que no aparece en las reglas, la validación falla. Protege contra mass assignment accidental, pero hay que tener cuidado con reglas wildcard de arrays: si validas items.* pero no listas items como clave explícita, podría dar falsos positivos. También se puede activar globalmente para todos los Form Requests en un Service Provider:

FormRequest::failOnUnknownFields();

Esto evita tener que añadir el atributo a cada clase. Si algún Form Request concreto necesita permitir campos extra, se sobrescribe con #[FailOnUnknownFields(false)].

Form Request en APIs

Cuando un Form Request falla en una petición web, Laravel genera un redirect. En peticiones XHR, devuelve JSON con status 422. La distinción la hace el header Accept:

Accept: application/json  →  JSON 422
Accept: text/html         →  Redirect 302

El problema más común cuando un form request “no funciona” en una API es que el frontend no envía Accept: application/json. Sin ese header, Laravel asume HTML y responde con un redirect 302 – el cliente API recibe una redirección en lugar de los errores de validación en JSON.

// Axios lo configura por defecto. Con fetch:
fetch('/api/facturas', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'Accept': 'application/json',
    },
    body: JSON.stringify(datos),
});

El formato del JSON de error:

{
    "message": "The cliente id field is required.",
    "errors": {
        "cliente_id": ["The cliente id field is required."],
        "lineas": ["The lineas field is required."]
    }
}

Para peticiones JSON, los datos del body se validan automáticamente. No hace falta nada especial en el Form Request.

En APIs stateless (sin sesión), el redirect no tiene sentido. Si el proyecto es exclusivamente API, se puede forzar la respuesta JSON sobrescribiendo failedValidation():

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

protected function failedValidation(Validator $validator): void
{
    throw new HttpResponseException(
        response()->json([
            'errores' => $validator->errors(),
        ], 422)
    );
}

Esto garantiza JSON 422 independientemente del header Accept. La mayoría de proyectos no lo necesitan si el frontend envía el header correcto, pero en microservicios internos donde no se controla el cliente puede ser un seguro útil.

Validar query parameters

Por defecto, validationData() devuelve $this->all(), que incluye el body de la petición y los query string parameters. Los route parameters ({id} en la ruta) no se incluyen – esos se acceden con $this->route('id').

Si necesitas asegurarte de que los query params se validen incluso cuando colisionen con claves del body, sobrescribe validationData():

public function validationData(): array
{
    return array_merge($this->all(), $this->query());
}

El motivo: cuando el body y la query string tienen claves con el mismo nombre, el body tiene prioridad y los query params se pierden. Para endpoints GET esto no suele ser problema (no hay body), pero en endpoints POST con query params adicionales puede causar confusión.

Un caso típico: un endpoint de listado con filtros y paginación:

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

    public function validationData(): array
    {
        return array_merge($this->all(), $this->query());
    }

    public function rules(): array
    {
        return [
            'estado'  => ['sometimes', 'string', 'in:pendiente,pagado,enviado'],
            'desde'   => ['sometimes', 'date_format:Y-m-d'],
            'hasta'   => ['sometimes', 'date_format:Y-m-d', 'after_or_equal:desde'],
            'orden'   => ['sometimes', 'string', 'in:fecha,total,cliente'],
            'por_pagina' => ['sometimes', 'integer', 'min:1', 'max:100'],
        ];
    }
}

Mismo Form Request para crear y actualizar

Cuando las reglas de creación y actualización son casi idénticas, se puede reutilizar la misma clase con lógica condicional. El ejemplo clásico: la regla unique que en la actualización debe ignorar el registro actual:

use Illuminate\Validation\Rule;

class GuardarArticuloRequest extends FormRequest
{
    public function rules(): array
    {
        $reglaSlug = Rule::unique('articulos', 'slug');

        if ($this->route('articulo')) {
            $reglaSlug->ignore($this->route('articulo'));
        }

        return [
            'titulo' => ['required', 'string', 'max:255'],
            'slug'   => ['required', 'string', 'max:255', $reglaSlug],
            'cuerpo' => ['required', 'string'],
        ];
    }
}

$this->route('articulo') devuelve el modelo (con route model binding) o null en la ruta de creación. En la actualización, ignore() excluye el registro actual de la comprobación de unicidad. Más sobre unique e ignore en el artículo de unique y exists.

Si las reglas divergen mucho entre creación y actualización, es preferible tener dos Form Requests separados. Mantener una sola clase con demasiados if acaba siendo más difícil de leer que dos clases sencillas.

Otra variante: usar $this->isMethod('POST') para distinguir creación de actualización cuando ambas rutas apuntan al mismo controlador:

public function rules(): array
{
    $reglas = [
        'titulo' => ['required', 'string', 'max:255'],
        'cuerpo' => ['required', 'string'],
    ];

    if ($this->isMethod('POST')) {
        $reglas['imagen'] = ['required', 'image', 'max:2048'];
    } else {
        $reglas['imagen'] = ['nullable', 'image', 'max:2048'];
    }

    return $reglas;
}

En la creación la imagen es obligatoria; en la actualización (PUT/PATCH) es opcional. Más sobre validación de archivos en archivos e imágenes.

Obtener datos validados

Tras la validación, el controlador accede a los datos limpios de dos formas:

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

// Subconjunto con ValidatedInput
$solo = $request->safe()->only(['titulo', 'cuerpo']);
$sin  = $request->safe()->except(['notas']);
$todo = $request->safe()->all();

validated() devuelve solo los campos que tienen reglas definidas – los campos extra del request se descartan. safe() devuelve un objeto ValidatedInput que permite filtrar el subconjunto. Combinar Form Requests con validated() da una capa de protección contra mass assignment que complementa los $fillable del modelo.

safe() también permite iterar, convertir a collection o acceder como array:

$request->safe()->collect()->each(function ($valor, $campo) {
    logger()->info("{$campo}: {$valor}");
});

// Acceso directo
$titulo = $request->safe()['titulo'];

La diferencia entre $request->validated() y $request->all() es crítica: all() incluye todo lo que viene en la petición, validado o no. Pasar $request->all() a un create() anula la protección del Form Request. El patrón seguro es siempre $request->validated() o $request->safe().

Errores frecuentes

403 en lugar de errores de validaciónauthorize() devuelve false por defecto en la clase generada. Cambiar a return true si la autorización se maneja en middleware o policy.

Redirect 302 en API – falta el header Accept: application/json. Sin él, Laravel trata la petición como HTML y redirige en vez de devolver JSON 422.

prepareForValidation no afecta a todos los campos$this->merge() solo modifica las claves que se le pasan. Si se espera que otros campos cambien, hay que incluirlos explícitamente.

Errores en after() sin formato personalizado – los mensajes añadidos manualmente en after() no pasan por messages() ni por attributes(). Hay que escribir el texto completo en la llamada a $validator->errors()->add().

Query parameters no validadosvalidationData() por defecto devuelve $this->all(), que puede no incluir todos los query params. Sobrescribir el método para incluir $this->query().

$request->all() en lugar de $request->validated() – pasar datos no validados al modelo elude la protección del Form Request. El controlador debe usar siempre validated() o safe().

Enum en reglas – para validar que un valor pertenezca a un enum de PHP 8.1+:

use App\Enums\EstadoPedido;
use Illuminate\Validation\Rule;

public function rules(): array
{
    return [
        'estado' => ['required', Rule::enum(EstadoPedido::class)],
    ];
}

Rule::enum() compara contra los valores del enum (no los nombres). Si el enum es backed con strings, el input debe coincidir con el string; si es con integers, con el número. Solo funciona con backed enums – los unit enums (sin valor asociado) no son compatibles porque carecen del método tryFrom().

Para reglas condicionales dentro de un Form Request (validar un campo solo cuando otro tiene cierto valor), consulta validación condicional. La validación de arrays anidados se cubre en arrays y JSON. Para reglas personalizadas reutilizables, consulta reglas personalizadas.