Form Requests | Laravel 13.x en español - validación y autorización encapsuladas
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 headerAcceptcontiene/jsono+json. La lista se ordena porq=descendente, y entre tipos con la mismaqgana el orden textual.Accept: application/jsonyAccept: application/vnd.api+jsondisparan JSON.Accept: text/html, application/json(ambos conq=1implícito) NO, porquetext/htmlaparece 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 enwantsJson()y suele fallar.
Cómo se comportan los clientes habituales:
- jQuery añade
X-Requested-With: XMLHttpRequestpor defecto, así que suele entrar por la rama XHR. - axios no manda
X-Requested-With, pero poneAccept: application/json, text/plain, */*por defecto.application/jsonqueda primero en la lista, así que cae porwantsJson()sin configuración extra. fetch()crudo no manda ninguno de los dos. Sin unAcceptexplí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()devuelvefalseo sigue con elreturn false;del scaffold por defecto. Sustituir porreturn 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. ForzarAccept: application/jsonen los defaults del cliente HTTP. validated()no incluye un campo que sí está en el request. No tiene regla enrules(). Añadir'campo' => 'present'si quieres exigir presencia sin restringir el contenido, o leer el campo por$request->input().prepareForValidationparece no ejecutarse. Causas típicas: la clase extiendeIlluminate\Http\Requesten vez deIlluminate\Foundation\Http\FormRequest, el controlador type-hinteaRequestgenérica en lugar de la Form Request concreta, typo en el nombre del método (prepareValidationen vez deprepareForValidation), o el método está declaradoprivate(protectedypublicambos sirven).- Mensajes con
:attributemuestran el nombre normalizado del campo en vez de un label legible. Sobrescribirattributes()con nombres humanos. uniquefalla en update con el propio email/slug. UsarRule::unique('users')->ignore($user->id). Nunca pasar input del usuario alignore(): un cliente puede mandar el id de otro registro y saltarse la verificación.replace()enprepareForValidationpierde archivos. El método vacía el bag de inputs; las subidas y campos no listados desaparecen para el resto del ciclo de vida. Usarmerge()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 deattributes()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?
¡Gracias por tu opinión!