Validación de formularios en Laravel: guía completa
Nunca confíes en los datos que llegan del usuario. Esta es una de las reglas más importantes en el desarrollo web, y Laravel lo sabe muy bien. Por eso incluye un sistema de validación extremadamente potente y fácil de usar. En este artículo vamos a repasar todo lo que necesitas saber para validar formularios de forma profesional.
¿Por qué validar los datos?
La validación no es opcional. Sin ella, tu aplicación es vulnerable a:
- Datos incorrectos que rompen la lógica de negocio
- Inyección SQL o XSS si los datos llegan directamente a la base de datos o al HTML
- Errores en tiempo de ejecución por tipos de datos inesperados
- Registros duplicados o inconsistentes en la base de datos
Laravel ofrece tres formas principales de validar: directamente en el controlador con $request->validate(), con clases Form Request, y con el Validator facade.
Validación básica en el controlador
La forma más rápida de validar es llamar a validate() directamente sobre el objeto request:
// app/Http/Controllers/PostController.php
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'body' => ['required', 'string', 'min:50'],
'email' => ['required', 'email', 'unique:users,email'],
'slug' => ['required', 'unique:posts,slug'],
'status' => ['required', 'in:draft,published,archived'],
]);
Post::create($validated);
return redirect()->route('posts.index')->with('success', 'Publicación creada.');
}
Si la validación falla, Laravel lanza automáticamente una excepción ValidationException que redirige al usuario de vuelta al formulario con los errores y los datos introducidos. Para peticiones AJAX, devuelve una respuesta JSON con los errores y código HTTP 422.
Reglas de validación más usadas
Laravel incluye más de 90 reglas. Estas son las que usarás con más frecuencia:
$request->validate([
// Presencia
'nombre' => ['required'],
'apellido' => ['nullable', 'string'], // Permite nulo
'bio' => ['sometimes', 'string'], // Solo valida si está presente
// Tipos y formato
'email' => ['required', 'email'],
'web' => ['nullable', 'url'],
'edad' => ['required', 'integer', 'min:18', 'max:120'],
'precio' => ['required', 'numeric', 'min:0'],
'nacimiento' => ['required', 'date', 'before:today'],
'codigo_postal' => ['required', 'digits:5'],
'telefono' => ['required', 'regex:/^[0-9]{9}$/'],
// Strings
'titulo' => ['required', 'string', 'min:5', 'max:255'],
'contenido' => ['required', 'string', 'min:100'],
// Base de datos
'email' => ['required', 'email', 'unique:users,email'], // Único en la tabla
'categoria_id' => ['required', 'exists:categories,id'], // Debe existir
// Confirmación de campos
'password' => ['required', 'string', 'min:8', 'confirmed'], // Necesita password_confirmation
'password_confirmation' => ['required'],
// Arrays
'tags' => ['required', 'array', 'min:1', 'max:5'],
'tags.*' => ['required', 'string', 'max:50'],
// Archivos
'avatar' => ['nullable', 'image', 'mimes:jpg,png,webp', 'max:2048'],
'documento' => ['required', 'file', 'mimes:pdf', 'max:5120'],
// Booleanos
'activo' => ['required', 'boolean'],
'acepta_terminos' => ['required', 'accepted'],
]);
La regla unique con excepciones
Cuando actualizas un registro, necesitas excluir el propio registro de la comprobación de unicidad:
// En el método update del controlador
public function update(Request $request, User $user): RedirectResponse
{
$request->validate([
// Ignora el usuario actual en la comprobación de unicidad
'email' => ['required', 'email', Rule::unique('users')->ignore($user->id)],
]);
// ...
}
Mensajes de error personalizados
Puedes personalizar los mensajes de error pasando un segundo array a validate():
$request->validate(
[
'email' => ['required', 'email', 'unique:users,email'],
'password' => ['required', 'min:8', 'confirmed'],
],
[
'email.required' => 'El correo electrónico es obligatorio.',
'email.email' => 'Introduce un correo electrónico válido.',
'email.unique' => 'Este correo ya está registrado. ¿Quieres iniciar sesión?',
'password.required' => 'La contraseña es obligatoria.',
'password.min' => 'La contraseña debe tener al menos :min caracteres.',
'password.confirmed'=> 'Las contraseñas no coinciden.',
]
);
Mostrar errores en Blade
Laravel guarda los errores de validación en la variable $errors que está disponible automáticamente en todas las vistas.
La directiva @error
<form method="POST" action="/posts">
@csrf
<div>
<label for="title">Título</label>
<input
type="text"
id="title"
name="title"
value="{{ old('title') }}"
class="{{ $errors->has('title') ? 'border-red-500' : 'border-gray-300' }}"
>
@error('title')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div>
<label for="body">Contenido</label>
<textarea
id="body"
name="body"
class="{{ $errors->has('body') ? 'border-red-500' : 'border-gray-300' }}"
>{{ old('body') }}</textarea>
@error('body')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<button type="submit">Crear publicación</button>
</form>
El helper old()
old('campo') devuelve el valor que el usuario introdujo antes de que fallara la validación. Esto evita que el formulario se limpie y el usuario tenga que rellenar todo de nuevo.
<input type="text" name="nombre" value="{{ old('nombre', $user->nombre) }}">
El segundo argumento de old() es el valor por defecto, útil cuando editas un registro existente.
Mostrar todos los errores de golpe
@if ($errors->any())
<div class="bg-red-100 border border-red-400 p-4 rounded mb-4">
<ul class="list-disc list-inside">
@foreach ($errors->all() as $error)
<li class="text-red-700">{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
Form Request: validación en clases dedicadas
Para formularios complejos o para mantener los controladores limpios, lo mejor es crear una clase Form Request:
php artisan make:request StorePostRequest
Esto crea el archivo app/Http/Requests/StorePostRequest.php:
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StorePostRequest extends FormRequest
{
/**
* Determina si el usuario está autorizado a hacer esta petición.
*/
public function authorize(): bool
{
// Retorna true si cualquier usuario puede hacer esto
// O añade lógica de autorización:
// return $this->user()->can('create', Post::class);
return true;
}
/**
* Las reglas de validación para la petición.
*/
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'unique:posts,slug'],
'body' => ['required', 'string', 'min:100'],
'category_id' => ['required', 'exists:categories,id'],
'tags' => ['nullable', 'array'],
'tags.*' => ['exists:tags,id'],
'status' => ['required', Rule::in(['draft', 'published'])],
'published_at'=> ['nullable', 'date'],
];
}
/**
* Mensajes de error personalizados.
*/
public function messages(): array
{
return [
'title.required' => 'El título es obligatorio.',
'body.min' => 'El contenido debe tener al menos :min caracteres.',
'category_id.exists' => 'La categoría seleccionada no es válida.',
'status.in' => 'El estado debe ser borrador o publicado.',
];
}
/**
* Nombres personalizados para los campos.
*/
public function attributes(): array
{
return [
'category_id' => 'categoría',
'published_at' => 'fecha de publicación',
];
}
}
Para usarlo en el controlador, simplemente reemplaza Request por tu Form Request:
use App\Http\Requests\StorePostRequest;
public function store(StorePostRequest $request): RedirectResponse
{
// Los datos ya están validados aquí
$validated = $request->validated();
Post::create($validated);
return redirect()->route('posts.index')->with('success', 'Publicación creada.');
}
Validación condicional
sometimes: solo si está presente
'telefono' => ['sometimes', 'required', 'string', 'digits:9'],
required_if: requerido si otro campo tiene cierto valor
$request->validate([
'tipo_pago' => ['required', 'in:tarjeta,transferencia'],
'numero_tarjeta' => ['required_if:tipo_pago,tarjeta', 'string', 'digits:16'],
'iban' => ['required_if:tipo_pago,transferencia', 'string'],
]);
Validación condicional con Validator::sometimes()
use Illuminate\Support\Facades\Validator;
$validator = Validator::make($request->all(), [
'tipo' => ['required', 'in:empresa,particular'],
'nombre' => ['required', 'string'],
]);
$validator->sometimes('cif', ['required', 'string', 'size:9'], function ($input) {
return $input->tipo === 'empresa';
});
$validator->sometimes('dni', ['required', 'string', 'size:9'], function ($input) {
return $input->tipo === 'particular';
});
if ($validator->fails()) {
return back()->withErrors($validator)->withInput();
}
Validar arrays
$request->validate([
'productos' => ['required', 'array', 'min:1'],
'productos.*.id' => ['required', 'exists:products,id'],
'productos.*.cantidad'=> ['required', 'integer', 'min:1'],
'productos.*.precio' => ['required', 'numeric', 'min:0'],
]);
Reglas de validación personalizadas
Cuando las reglas predefinidas no son suficientes, crea la tuya propia:
php artisan make:rule NifValido
// app/Rules/NifValido.php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class NifValido implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
// Lógica de validación del NIF español
$letras = 'TRWAGMYFPDXBNJZSQVHLCKE';
$value = strtoupper(trim($value));
if (! preg_match('/^[0-9]{8}[A-Z]$/', $value)) {
$fail("El :attribute no tiene el formato correcto (8 números + 1 letra).");
return;
}
$numero = (int) substr($value, 0, 8);
$letraEsperada = $letras[$numero % 23];
$letraIntroducida = substr($value, -1);
if ($letraIntroducida !== $letraEsperada) {
$fail("El :attribute no es válido.");
}
}
}
Usar la regla personalizada:
use App\Rules\NifValido;
$request->validate([
'nif' => ['required', 'string', new NifValido],
]);
Conclusión
La validación en Laravel es una de las partes más elegantes del framework. Desde la validación rápida en el controlador hasta las Form Requests con mensajes y autorización, tienes todas las herramientas para mantener tus datos limpios y tu código organizado. Recuerda: siempre valida los datos del usuario, sin excepciones.