wppw.org
enrues
Laravel 13.x · Docs

Form Requests | Laravel 13.x en español - validación y autorización encapsuladas

Actualizado 24 de mayo de 2026 Laravel 13.x 18 min lectura
En esta página 16

Cuando el array de reglas dentro de $request->validate() empieza a competir en tamaño con la lógica del controlador, llega el momento de extraerlo. Laravel ofrece tres puntos de entrada para validar: $request->validate() directo, Validator::make() desde la fachada, y la clase Form Request. Las tres vías lanzan la misma ValidationException y producen la misma respuesta 422. Lo que distingue al Form Request es que saca de en medio toda la maquinaria de validación, autorización y normalización antes de que el método del controlador se ejecute.

Cómo crear un Form Request

El comando Artisan genera el esqueleto en app/Http/Requests:

php artisan make:request StorePostRequest

El comando crea el directorio si no existe. La clase generada extiende Illuminate\Foundation\Http\FormRequest y trae dos métodos: authorize() y rules(). Para usarla, basta con type-hintarla en el método del controlador:

use App\Http\Requests\StorePostRequest;

public function store(StorePostRequest $request): RedirectResponse
{
    $post = Post::create($request->validated());

    return to_route('posts.show', $post);
}

Laravel resuelve el Form Request a través del service container, ejecuta authorize, luego rules, y solo después invoca tu método. Si la autorización falla, devuelve 403 sin ejecutar las reglas. Si las reglas fallan, lanza ValidationException (redirección o JSON 422 según el header Accept) y tampoco entra al método. Para detalles del flujo general de validación, ver Conceptos básicos de validación.

El método authorize

authorize decide si el usuario tiene permiso para ejecutar la acción. Devuelve bool. Si devuelve false, Laravel responde con 403 sin ejecutar las reglas ni el controlador.

use App\Models\Comment;

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

    return $comment && $this->user()->can('update', $comment);
}

Si la ruta usa route model binding, el código se acorta:

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

Cuando authorize hace ruido

Si el método devuelve false (o sigue con el return false; del scaffold por defecto), cada petición que llegue recibirá 403. Es el origen más habitual del reporte “el Form Request no funciona” después de validación: el frontend pinta ‘no autorizado’ y el desarrollador busca el problema en las reglas. Si tu app maneja autorización fuera del Form Request (middleware, policies en el controlador), elimina el método o devuelve true explícitamente:

public function authorize(): bool
{
    return true;
}

El método rules

rules devuelve un array de pares campo => reglas. Las reglas pueden ser un string con pipes, un array de strings, o instancias del builder Rule. Es donde declaras todo lo que un campo debe satisfacer:

use Illuminate\Validation\Rule;

public function rules(): array
{
    return [
        'title' => 'required|string|max:255',
        'slug' => ['required', 'alpha_dash', Rule::unique('posts', 'slug')],
        'body' => 'required|string|min:10',
        'tags' => 'array|max:10',
        'tags.*' => 'string|max:20',
    ];
}

Las reglas se evalúan en el orden declarado. Si un campo lleva bail, el resto de reglas para ese campo se omiten cuando una falla. Para detener TODA la validación al primer error de cualquier campo, usa el atributo #[StopOnFirstFailure] que veremos más abajo. La lista completa de reglas y sus combinaciones está en Reglas de validación.

Mensajes personalizados y nombres de atributos

Los mensajes por defecto viven en lang/xx/validation.php. Si tu aplicación no tiene directorio lang, ejecuta php artisan lang:publish para crearlo. Dentro del Form Request, sobrescribir mensajes para reglas específicas se hace con messages:

public function messages(): array
{
    return [
        'title.required' => 'Necesitamos un título para el post.',
        'slug.alpha_dash' => 'El slug solo admite letras, números, guiones y guiones bajos.',
        'body.min' => 'El cuerpo del post debe tener al menos :min caracteres.',
    ];
}

La clave usa la sintaxis campo.regla. Si solo escribes 'required' => '...', ese mensaje aplica a TODOS los campos con required, lo cual es raro pero útil para textos genéricos.

El placeholder :attribute se reemplaza con el nombre del campo pasado a snake_case y con los underscores convertidos en espacios (email_address se renderiza como email address). Para textos traducidos o más naturales (“dirección de correo”), sobrescribe attributes:

public function attributes(): array
{
    return [
        'email_address' => 'dirección de correo',
        'phone_number' => 'teléfono',
    ];
}

Más detalles y patrones de localización en Mensajes de error.

prepareForValidation: antes de validar

Si los datos del request necesitan limpieza antes de validación (trim de strings, generación de slug, normalización de tipos), el hook prepareForValidation es el sitio:

use Illuminate\Support\Str;

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

merge añade o sobrescribe claves. Las claves no mencionadas en el array permanecen intactas. Para asignar un valor por defecto a un campo opcional del Form Request cuando el cliente no lo envía:

protected function prepareForValidation(): void
{
    $this->merge([
        'status' => $this->input('status', 'draft'),
        'sort_order' => $this->input('sort_order', 0),
    ]);
}

replace($array) también existe, pero hay que usarlo con cuidado: vacía el bag de inputs y deja únicamente lo que pasas. Archivos subidos, query params y cualquier campo que no metas en el array desaparecen para el resto del ciclo de vida del request.

passedValidation: después de validación

Una vez las reglas han pasado, passedValidation sirve para ajustes que solo tienen sentido sobre datos ya validados. La doc oficial muestra el uso con replace:

protected function passedValidation(): void
{
    $this->replace(['name' => 'Taylor']);
}

Hay un detalle a tener en cuenta. validated() no consulta el request directamente, sino al Validator, y el Validator guarda su propia copia de los datos (tomada al construirse vía validationData()). $this->replace() dentro de passedValidation modifica el bag de inputs del request, pero no esa copia interna del Validator, así que el array que devuelve validated() no refleja el cambio. Si necesitas mutar lo que sale al controlador, lo más predecible es usar safe()->merge() desde el propio controlador:

use Illuminate\Support\Facades\Auth;

$post = Post::create(
    $request->safe()->merge(['user_id' => Auth::id()])->all()
);

Validación adicional con after

A veces una regla declarativa no expresa bien la condición (“la suma de cantidades por item no debe exceder el stock total”). El método after devuelve un array de callables o closures que reciben la instancia de Illuminate\Validation\Validator después de que las reglas estándar hayan corrido:

use Illuminate\Validation\Validator;

public function after(): array
{
    return [
        function (Validator $validator) {
            $total = collect($this->input('items', []))->sum('quantity');

            if ($total > $this->productStock()) {
                $validator->errors()->add(
                    'items',
                    'La suma de cantidades supera el stock disponible.'
                );
            }
        },
    ];
}

Cuando la lógica crece o se reutiliza, extrae cada callable a una clase invocable:

namespace App\Validation;

use Illuminate\Validation\Validator;

class ValidateStock
{
    public function __construct(private int $stock) {}

    public function __invoke(Validator $validator): void
    {
        // logica que añade errores al validator
    }
}

Y desde el Form Request:

public function after(): array
{
    return [
        new ValidateStock($this->productStock()),
        new ValidateShippingWindow,
    ];
}

Los mensajes añadidos vía $validator->errors()->add() no pasan por el array de messages() ni por los reemplazos de attributes(). Como consecuencia práctica, si tu app usa lang/es/validation.php o attributes() para renombrar campos, el texto que añadas en after() tendrá que llegar ya traducido y con el nombre legible que toque; el reemplazo automático no se aplica para errores manuales.

Acceder a los datos validados

Después de que el Form Request pase, el controlador tiene varias vías para leer los datos:

public function store(StorePostRequest $request): RedirectResponse
{
    $validated = $request->validated();              // array
    $partial = $request->safe()->only(['title']);    // subset
    $rest = $request->safe()->except(['admin']);     // todo menos
    $all = $request->safe()->all();                  // array
}

validated() y safe() devuelven la estructura declarada en las reglas, con la notación de puntos reconstruida como subarrays anidados. Si tu request trae title, body y csrf_token, pero las reglas declaran únicamente title y body, el csrf_token no aparece en el output.

Para arrays, conviene listar las claves permitidas explícitamente con 'meta' => 'array:nombre,color' en lugar de un 'meta' => 'array' genérico. El comportamiento exacto de un subarray cuyas claves internas no tienen reglas depende de la flag excludeUnvalidatedArrayKeys del factory del Validator (y ha cambiado entre versiones de Laravel), así que enumerar las claves quita ambigüedad. La sección Arrays y JSON cubre las variantes.

validated() no sustituye a $fillable/$guarded del modelo. Las reglas filtran por intención de validación (qué admites como input válido), mientras que $fillable controla qué columnas tolera Eloquent para mass-assignment. Si un Form Request se reutiliza entre flujos con permisos distintos (admin que puede tocar role, usuario público que no), una regla 'role' => 'in:admin,user' puesta por motivos legítimos en el flujo admin deja pasar role=admin también desde el flujo público. El muro real contra mass-assignment está en el modelo.

Ese mismo comportamiento sorprende cuando alguien refactoriza y elimina una regla pensando que el campo seguirá llegando: el campo se silencia en el array validado. Si necesitas un valor que no quieres validar pero sí pasar al modelo, añade 'campo' => 'present' o léelo por $request->input('campo').

Un mismo Form Request para store y update

El patrón clásico: una clase para crear y otra para actualizar, con reglas casi idénticas excepto por unique con ignore. Para evitar duplicación, comparte un Form Request y deriva las reglas según el método HTTP o la ruta:

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class PostRequest extends FormRequest
{
    public function rules(): array
    {
        $postId = $this->route('post')?->id;

        return [
            'title' => 'required|string|max:255',
            'slug' => [
                'required',
                'alpha_dash',
                Rule::unique('posts', 'slug')->ignore($postId),
            ],
            'body' => 'required|string|min:10',
        ];
    }
}

El ignore($postId) es el detalle clave: sin él, al actualizar el post 42 con su slug actual, la regla unique encuentra el propio post 42 en la tabla y la validación falla. El ID que pasas debe venir del modelo cargado por la ruta (route model binding) o del registro autenticado, NUNCA del input del usuario: si aceptas ignore($request->id), un cliente puede mandar el id de otro registro y saltarse la verificación de unicidad contra él, pisando el slug o el email de ese otro registro. Para más patrones y combinaciones de unique/exists, ver Unique y Exists.

Si el cuerpo de reglas crece y diverge entre create y update más allá de un ignore, mejor dos Form Requests separados que un if con dos ramas largas.

Atributos PHP 8 para configurar el Form Request

Laravel ofrece varios atributos PHP 8 que se declaran sobre la clase del Form Request para ajustar comportamientos sin sobrescribir propiedades.

StopOnFirstFailure detiene toda la validación al primer error de cualquier campo:

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

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

RedirectTo cambia la URL de redirección tras un fallo de validación. RedirectToRoute hace lo mismo con una ruta nombrada:

use Illuminate\Foundation\Http\Attributes\RedirectToRoute;

#[RedirectToRoute('dashboard')]
class UpdateProfileRequest extends FormRequest
{
    // ...
}

ErrorBag envía los errores a un bag con nombre. Útil cuando tienes login y registro en la misma página y necesitas separar los errores de cada formulario:

use Illuminate\Foundation\Http\Attributes\ErrorBag;

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

En las vistas, el bag con nombre se accede así:

{{ $errors->login->first('email') }}

FailOnUnknownFields rechaza el request si llega cualquier campo del body que no aparezca en las reglas. Es una salvaguarda extra contra mass-assignment accidental cuando aceptas un esquema fijo:

use Illuminate\Foundation\Http\Attributes\FailOnUnknownFields;

#[FailOnUnknownFields]
class CreatePostRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'title' => 'required|string|max:255',
            'body' => 'required|string',
        ];
    }
}

Un campo extra como role=admin enviado en el body se traduce en un error validation.prohibited. El atributo solo inspecciona body y JSON; los query parameters extra pasan sin ruido y siguen necesitando reglas explícitas o filtrado manual. Para activarlo globalmente desde un service provider:

use Illuminate\Foundation\Http\FormRequest;

FormRequest::failOnUnknownFields();

Form Request para APIs JSON

Una queja recurrente con Form Requests: “no funciona con mi API”. El síntoma es el mismo: el endpoint devuelve un redirect a una página de login o al previous_url en vez de un JSON 422 con la lista de errores. La causa suele estar en cómo el cliente identifica el request.

Laravel decide entre redirect y JSON evaluando expectsJson() sobre el request. Ese método devuelve true en dos casos:

  • wantsJson(): el primer tipo aceptable del header Accept contiene /json o +json. La lista se ordena por q= descendente, y entre tipos con la misma q gana el orden textual. Accept: application/json y Accept: application/vnd.api+json disparan JSON. Accept: text/html, application/json (ambos con q=1 implícito) NO, porque text/html aparece primero.
  • Rama XHR: el request es XHR (X-Requested-With: XMLHttpRequest), no es pjax, y el Accept está ausente o vale */*. Si mandas XHR junto a un Accept específico no-JSON, la decisión recae en wantsJson() y suele fallar.

Cómo se comportan los clientes habituales:

  • jQuery añade X-Requested-With: XMLHttpRequest por defecto, así que suele entrar por la rama XHR.
  • axios no manda X-Requested-With, pero pone Accept: application/json, text/plain, */* por defecto. application/json queda primero en la lista, así que cae por wantsJson() sin configuración extra.
  • fetch() crudo no manda ninguno de los dos. Sin un Accept explícito, no recibirás JSON.

La regla más segura para cualquier cliente HTTP moderno es fijar Accept: application/json en los defaults:

POST /api/posts
Accept: application/json
Content-Type: application/json

{"title": "Mi post"}

Con ese header, la respuesta para validación fallida será:

{
    "message": "The title field is required.",
    "errors": {
        "title": ["The title field is required."]
    }
}

El campo message contiene la primera línea de error y un sufijo de conteo. La forma exacta del sufijo depende del número total de errores: con uno solo no hay sufijo; con dos, el sufijo es (and 1 more error) en singular; con tres o más, (and N more errors) en plural. En el cliente, parsear message como texto crudo se rompe en cuanto hay más de un fallo: lo robusto es leer el objeto errors y recorrer los arrays por campo.

failedValidation: forzar JSON sin depender del cliente

Para servicios internos o backends-for-frontends donde no controlas qué cliente HTTP llega, depender únicamente del Accept puede no bastar. Sobrescribir failedValidation permite lanzar la respuesta JSON 422 directamente desde el Form Request, sin que cuente la heurística de expectsJson():

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)
    );
}

El método se invoca cuando las reglas fallan. Al lanzar HttpResponseException con una JsonResponse, Laravel saltea el flujo normal de ValidationException (que es el que decide entre redirect y JSON según el Accept) y devuelve exactamente la respuesta que construyas. Útil cuando varias rutas comparten el mismo Form Request pero el contrato de error tiene que ser estable y traducido.

Validar query parameters y otros parámetros del request

El array que el Form Request valida por defecto sale de $this->all(), que combina body, query string y archivos. Para una ruta GET con filtros en la URL (/posts?status=published&tag=php), las reglas funcionan tal cual:

public function rules(): array
{
    return [
        'status' => 'in:draft,published,archived',
        'tag' => 'nullable|string|max:30',
        'page' => 'integer|min:1',
    ];
}

Si necesitas restringir la validación a una fuente específica (solo query, ignorando el body de un POST), sobrescribe validationData() en el Form Request:

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

validationData() está declarado public en FormRequest, así que el override debe mantener esa visibilidad (un protected provoca fatal error de PHP por reducir visibilidad). El método reemplaza los datos que ve el validador, no los suma: parámetros de ruta y body quedan fuera salvo que los incluyas explícitamente en el array devuelto. Por ejemplo, para validar query más un parámetro de ruta tenantId:

public function validationData(): array
{
    return array_merge($this->query(), [
        'tenant_id' => $this->route('tenantId'),
    ]);
}

A diferencia de $this->replace($this->query()) (que pisa el bag de inputs y deja al controlador sin el body), validationData() no toca el request: $request->input() y $request->all() siguen viendo body+query en el controlador. Lo que sí cambia es lo que devuelven $request->validated() y $request->safe(), que reflejan únicamente el subconjunto retornado por validationData().

Validar arrays y enums

Los Form Requests reciben sin fricción reglas con wildcard para arrays:

public function rules(): array
{
    return [
        'comments' => 'array|min:1|max:50',
        'comments.*.author' => 'required|string|max:100',
        'comments.*.body' => 'required|string|max:500',
        'comments.*.rating' => 'integer|between:1,5',
    ];
}

La regla array sobre el campo padre es obligatoria: sin ella, un input que no sea array (un string suelto) puede colarse antes de que las reglas .* se evalúen. Para sintaxis avanzada de mensajes con :position y :index y validación profunda, ver Arrays y JSON.

Para enums backed (PHP 8.1+), el builder Rule::enum() valida que el valor recibido corresponda a un caso del enum dentro del Form Request:

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

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

// Limitar a un subconjunto de casos
Rule::enum(PostStatus::class)->only([PostStatus::Draft, PostStatus::Published]);

Rule::enum requiere un backed enum (string o int).

Errores frecuentes

  • 403 en vez de errores de validación. El authorize() devuelve false o sigue con el return false; del scaffold por defecto. Sustituir por return true; o por lógica real.
  • API recibe redirect en vez de JSON 422. El Accept del cliente no dispara wantsJson() y el request no es XHR sin Accept. Forzar Accept: application/json en los defaults del cliente HTTP.
  • validated() no incluye un campo que sí está en el request. No tiene regla en rules(). Añadir 'campo' => 'present' si quieres exigir presencia sin restringir el contenido, o leer el campo por $request->input().
  • prepareForValidation parece no ejecutarse. Causas típicas: la clase extiende Illuminate\Http\Request en vez de Illuminate\Foundation\Http\FormRequest, el controlador type-hintea Request genérica en lugar de la Form Request concreta, typo en el nombre del método (prepareValidation en vez de prepareForValidation), o el método está declarado private (protected y public ambos sirven).
  • Mensajes con :attribute muestran el nombre normalizado del campo en vez de un label legible. Sobrescribir attributes() con nombres humanos.
  • unique falla en update con el propio email/slug. Usar Rule::unique('users')->ignore($user->id). Nunca pasar input del usuario al ignore(): un cliente puede mandar el id de otro registro y saltarse la verificación.
  • replace() en prepareForValidation pierde archivos. El método vacía el bag de inputs; las subidas y campos no listados desaparecen para el resto del ciclo de vida. Usar merge() salvo que quieras literalmente partir de cero.
  • Atributos PHP 8 ignorados. Importarlos desde Illuminate\Foundation\Http\Attributes\* y comprobar que la clase los declara antes del nombre, no dentro del cuerpo.
  • Errores de after() muestran el nombre crudo del campo. El reemplazo de attributes() no aplica a errores añadidos manualmente con $validator->errors()->add(); construir el mensaje completo, ya traducido y con el nombre legible.

Testing del Form Request

La forma más rápida y representativa es probar el endpoint vía HTTP test, no la clase aislada. Ese camino ejerce el flujo real: middleware, resolución de servicio, hooks, autorización, todo:

use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class StorePostRequestTest extends TestCase
{
    use RefreshDatabase;

    public function test_titulo_es_requerido(): void
    {
        $this->actingAs($this->createUser())
            ->postJson('/api/posts', ['body' => 'contenido'])
            ->assertStatus(422)
            ->assertJsonValidationErrors(['title']);
    }

    public function test_post_valido_se_crea(): void
    {
        $this->actingAs($this->createUser())
            ->postJson('/api/posts', [
                'title' => 'Mi post',
                'body' => 'cuerpo válido',
            ])
            ->assertStatus(201)
            ->assertJsonPath('data.title', 'Mi post');
    }
}

postJson añade automáticamente el header Accept: application/json, por lo que la respuesta es 422 y no un redirect. assertJsonValidationErrors(['title']) verifica que el objeto errors tiene la clave esperada.

Si necesitas probar rules() aisladamente para verificar que un cambio no rompe una combinación complicada, instancia el Form Request en el test y aliméntalo con datos:

$request = StorePostRequest::create('/posts', 'POST', [
    'title' => '',
    'body' => 'contenido',
]);

$validator = validator($request->all(), $request->rules());

$this->assertTrue($validator->fails());
$this->assertArrayHasKey('title', $validator->errors()->messages());

Este patrón omite middleware, prepareForValidation, authorize y after. Sirve para cubrir reglas, pero no sustituye un test HTTP cuando hay hooks involucrados.

¿Te ha resultado útil este artículo?

Reportar un error