Validación en Laravel 13: Form Requests, reglas personalizadas y authorize()
Las reglas de validación repetidas entre store y update, la seguridad mezclada con la lógica de negocio y los mensajes de error esparcidos por el controlador: ese es el caos que los Form Requests de Laravel 13 eliminan. En esta guía construimos un formulario de artículos completo con reglas personalizadas, autorización y mensajes traducidos, paso a paso.
Por qué validar fuera del controlador
El problema de las reglas inline en store y update
Empezar validando dentro del controlador parece inofensivo: un array de reglas en store, otro casi idéntico en update. Al tercer formulario ya hay duplicación, el método authorize (quién puede hacer esto) termina siendo un return true genérico y los mensajes de error se personalizan con validators ad hoc. El resultado es código que cuesta mantener y, peor, huecos de seguridad silenciosos.
Qué aporta un Form Request a la arquitectura
Un Form Request es una clase que encapsula toda la validación de una request: las reglas, la autorización, los mensajes y los hooks previos y posteriores. Laravel la inyecta en el controlador y se encarga de todo: si falla, redirige con los errores en la sesión; si pasa, el controlador recibe únicamente los datos validados. El controlador vuelve a ser un lugar para orquestar, no para validar.
Tu primer Form Request con Artisan
php artisan make:request y la estructura de la clase
Artisan genera la clase en un segundo:
php artisan make:request StorePostRequestEl fichero resultante, en app/Http/Requests, trae los dos métodos núcleo del sistema: authorize(), que determina si el usuario autenticado puede realizar la acción, y rules(), que devuelve las reglas de validación que se aplican a los datos. Sobre ellos se apoyan los hooks que veremos después.
rules() y validated(): datos limpios en el controlador
Las reglas se escriben como un array asociativo campo => reglas, con las reglas integradas de Laravel (required, email, min, unique, image, array). El controlador ya no toca el validador: recibe el Form Request como dependencia y consume $request->validated(), que devuelve solo los campos que pasaron la validación, sin campos extra.
public function store(StorePostRequest $request)
{
$post = Post::create($request->validated());
return redirect()->route('posts.show', $post);
}Autorización dentro del Form Request: authorize()
El método authorize() devuelve true o false y se evalúa antes de la validación. Si devuelve false, Laravel responde con un 403 sin llegar a validar. Es el sitio natural para la comprobación de permisos, y combina perfectamente con las policies:
public function authorize(): bool
{
return $this->user()->can('create', Post::class);
}Combinar policies con authorize()
En lugar de repetir la lógica de permisos en cada controlador, la policy concentra la regla de negocio (quién puede crear o editar un post) y authorize() la invoca. Así, la seguridad de una acción vive en un único sitio y el Form Request se limita a delegar.
Reglas personalizadas con clases Rule
make:rule y el método passes()
Cuando una regla integrada no cubre tu caso, creas una clase Rule con Artisan:
php artisan make:rule ValidSlugLa clase define passes(), que recibe el valor y devuelve true o false, y message(), con el texto del error. También puedes registrar reglas como closures dentro de rules() cuando la lógica es trivial.
public function passes($attribute, $value): bool
{
return preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $value) === 1;
}
public function message(): string
{
return 'El slug solo puede contener minúsculas, números y guiones.';
}Validar el slug único en store y update
El clásico: el slug debe ser único, pero al editar hay que ignorar el registro en curso. La regla unique acepta un ignore con el id del modelo:
use Illuminate\Validation\Rule;
'slug' => ['required', new ValidSlug, Rule::unique('posts')->ignore($this->post?->id)]Con el operador nullsafe, la misma regla sirve para crear (no hay post, no se ignora nada) y para editar (se salta el id actual).
Validación condicional y datos preparados
required_if, required_unless y closures
Los formularios dinámicos necesitan reglas que dependan de otros campos. Las reglas condicionales resuelven la mayoría: required_if:published,true obliga un campo solo cuando otro cumple una condición; required_unless y required_with cubren el resto. Para lógica más compleja, una closure recibe el validador y añade reglas bajo demanda.
'published_at' => 'required_if:status,published|date',
'tags' => 'nullable|array|max:5'prepareForValidation(): normalizar antes de validar
prepareForValidation() se ejecuta justo antes de que comience la validación y permite manipular la entrada: generar el slug a partir del título, recortar espacios, unificar mayúsculas. Es el lugar estándar para normalizar, y también para adaptar la request a claves distintas entre creación y edición:
protected function prepareForValidation(): void
{
$this->merge([
'slug' => str($this->title)->slug(),
]);
}Hooks posteriores: passedValidation() y withValidator()
Después de validar también hay vida. passedValidation() se ejecuta solo si la validación pasa: es perfecto para normalizar datos finales o disparar efectos. withValidator() recibe el validador antes de que se resuelva y permite añadir callbacks, por ejemplo after() para comprobaciones que dependen de la base de datos:
public function withValidator(Validator $validator): void
{
$validator->after(function ($validator) {
if (Post::where('slug', $this->slug)->exists()) {
$validator->errors()->add('slug', 'Ya existe un post con ese slug.');
}
});
}Mensajes de error y traducciones
messages() y el fichero lang/validation.php
Para mensajes a medida, el método messages() devuelve un array campo.regla => texto. La alternativa global es el fichero lang/validation.php, que centraliza el mensaje de cada regla integrada y admite parámetros como :attribute o :min. La combinación típica: mensajes genéricos en el fichero de idioma y excepciones puntuales en messages().
public function messages(): array
{
return [
'title.required' => 'El título es obligatorio.',
'slug.unique' => 'Ese slug ya está en uso.',
];
}Mostrar errores en Blade con @error
La directiva @error muestra el mensaje solo cuando existe un error para ese campo, y old() rellena el formulario tras el fallo:
<input name="title" value="{{ old('title') }}">
@error('title')
<p class="text-red-600">{{ $message }}</p>
@enderrorCompartir reglas entre Form Requests
Store y update suelen compartir el grueso de las reglas. Para no duplicarlas, extrae la base a una clase padre o a un trait (por ejemplo PostRules con un método baseRules() común) y deja que cada Form Request añada solo sus diferencias: update añade el ignore del id; store añade la imagen obligatoria. El resultado son clases pequeñas que cambian por un único motivo.
trait PostRules
{
protected function baseRules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'body' => ['required', 'string'],
'tags' => ['nullable', 'array', 'max:5'],
];
}
}Conclusión
Los Form Requests convierten la validación en una capa explícita de tu aplicación: reglas, autorización, mensajes y hooks en una clase por formulario, reutilizables entre store y update. El controlador se queda con una línea y la seguridad deja de depender de un return true olvidado. Si trabajas con Laravel 13, este es el patrón que separa un formulario mantenible de uno que da miedo tocar. Sigue explorando el blog para más guías de Laravel 13: validación, autorización y formularios robustos son solo el principio.