Tema
Validación y Form Requests
Nunca confíes en lo que llega del navegador. En esta lección aprenderás a validar datos en Laravel: desde la validación rápida dentro del controlador hasta los Form Requests, la forma profesional de mantener tus controladores limpios. También verás cómo mostrar errores en Blade, mensajes en español y reglas personalizadas.
Concepto
Piensa en la validación como el guardia de la entrada de un edificio: revisa la identificación de cada persona antes de dejarla pasar. Si algo no cuadra, la regresa con una explicación ("te falta el gafete").
En Laravel funciona así:
- El usuario envía un formulario.
- Laravel revisa los datos contra unas reglas (
required,email,max:255...). - Si todo está bien, tu código continúa con los datos ya validados.
- Si algo falla, Laravel redirige automáticamente al formulario anterior con los errores y los valores que el usuario había escrito (para que no tenga que reescribir todo). Si la petición es de una API (espera JSON), responde con un
422y los errores en JSON.
No tienes que escribir ningún if para esto: Laravel lo hace por ti.
Ejemplo: validación en el controlador
Supón que tenemos un modelo Post con title, body, category_id y published_at.
php
<?php
namespace App\Http\Controllers;
use App\Models\Post;
use Illuminate\Http\Request;
class PostController extends Controller
{
public function store(Request $request)
{
// validate() devuelve SOLO los campos validados.
// Si algo falla, lanza una excepción y Laravel redirige con errores.
$validated = $request->validate([
'title' => ['required', 'string', 'min:5', 'max:255'],
'body' => ['required', 'string'],
'category_id' => ['required', 'integer', 'exists:categories,id'], // debe existir en la tabla
'published_at' => ['nullable', 'date', 'after_or_equal:today'],
'tags' => ['array'], // tags[] en el formulario
'tags.*' => ['string', 'max:30'], // cada elemento del array
]);
// Aquí ya es seguro usar los datos
$post = $request->user()->posts()->create($validated);
return redirect()
->route('posts.show', $post)
->with('status', 'Post creado correctamente.');
}
}Buena práctica
Guarda siempre $validated (o $request->validated() en Form Requests) en lugar de $request->all(). Así sólo se usan campos que pasaron por las reglas, y nadie puede colar un campo extra como is_admin.
Reglas más usadas
| Regla | Qué hace |
|---|---|
required | El campo debe venir y no estar vacío |
nullable | Puede venir vacío (se guarda null) |
sometimes | Sólo valida si el campo viene en la petición |
string, integer, numeric, boolean | Tipo de dato |
email | Formato de correo |
min:3, max:255 | Longitud (texto), valor (números) o tamaño en KB (archivos) |
between:1,10 | Rango |
in:borrador,publicado | Uno de los valores permitidos |
unique:users,email | No debe existir en la tabla |
exists:categories,id | Debe existir en la tabla |
confirmed | Debe existir campo_confirmation igual (contraseñas) |
date, after:today, before:2030-01-01 | Fechas |
image, mimes:jpg,png, file | Archivos |
url, ip, uuid, regex:/^[A-Z]{3}$/ | Formatos |
También puedes usar la clase Rule para reglas más expresivas:
php
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
$request->validate([
'status' => ['required', Rule::in(['draft', 'published'])],
// Al editar: el email debe ser único, IGNORANDO al propio usuario
'email' => ['required', 'email', Rule::unique('users')->ignore($user->id)],
// Reglas de contraseña fuerte
'password' => ['required', 'confirmed', Password::min(8)->letters()->numbers()->mixedCase()],
]);Mostrar errores en Blade
Laravel comparte en todas las vistas una variable $errors. Además tienes la directiva @error y el helper old() para recuperar lo que el usuario escribió.
blade
{{-- resources/views/posts/create.blade.php --}}
<x-app-layout>
{{-- Resumen de todos los errores (opcional) --}}
@if ($errors->any())
<div class="alert alert-danger">
<p>Revisa los siguientes campos:</p>
<ul>
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<form method="POST" action="{{ route('posts.store') }}">
@csrf
<label for="title">Título</label>
{{-- old('title') recupera el valor enviado si hubo error --}}
<input id="title" name="title" value="{{ old('title') }}"
class="@error('title') is-invalid @enderror">
@error('title')
<p class="error">{{ $message }}</p>
@enderror
<label for="body">Contenido</label>
<textarea id="body" name="body">{{ old('body') }}</textarea>
@error('body')
<p class="error">{{ $message }}</p>
@enderror
<label for="category_id">Categoría</label>
<select id="category_id" name="category_id">
@foreach ($categories as $category)
<option value="{{ $category->id }}" @selected(old('category_id') == $category->id)>
{{ $category->name }}
</option>
@endforeach
</select>
@error('category_id')
<p class="error">{{ $message }}</p>
@enderror
<button type="submit">Guardar</button>
</form>
</x-app-layout>En un formulario de edición, combina old() con el valor actual del modelo: old('title', $post->title) (dentro de las dobles llaves de Blade, como en el bloque anterior).
Form Requests: validación profesional
Cuando las reglas crecen, el controlador se ensucia. Un Form Request es una clase dedicada a validar (y autorizar) una petición concreta.
bash
php artisan make:request StorePostRequest
php artisan make:request UpdatePostRequestSe crean en app/Http/Requests/:
php
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StorePostRequest extends FormRequest
{
/**
* ¿El usuario puede hacer esta petición?
* Si devuelve false, Laravel responde 403 automáticamente.
*/
public function authorize(): bool
{
// Ejemplo: sólo usuarios con email verificado
return $this->user() !== null && $this->user()->hasVerifiedEmail();
}
/**
* Reglas de validación.
*/
public function rules(): array
{
return [
'title' => ['required', 'string', 'min:5', 'max:255'],
'slug' => ['required', 'alpha_dash', 'unique:posts,slug'],
'body' => ['required', 'string'],
'category_id' => ['required', 'exists:categories,id'],
'status' => ['required', Rule::in(['draft', 'published'])],
'published_at' => ['nullable', 'date', 'required_if:status,published'],
];
}
/**
* Mensajes personalizados (opcional).
*/
public function messages(): array
{
return [
'title.required' => 'Ponle un título a tu post.',
'slug.unique' => 'Ya existe un post con esa URL.',
'published_at.required_if' => 'Indica la fecha si el post está publicado.',
];
}
/**
* Nombres "bonitos" de los campos en los mensajes.
*/
public function attributes(): array
{
return [
'category_id' => 'categoría',
'published_at' => 'fecha de publicación',
];
}
/**
* Se ejecuta ANTES de validar: ideal para limpiar datos.
*/
protected function prepareForValidation(): void
{
$this->merge([
// Genera el slug a partir del título si no viene
'slug' => $this->slug ?: \Illuminate\Support\Str::slug($this->title ?? ''),
]);
}
}Y el controlador queda limpísimo: basta con tipar el parámetro. Laravel valida antes de entrar al método.
php
use App\Http\Requests\StorePostRequest;
use App\Http\Requests\UpdatePostRequest;
public function store(StorePostRequest $request)
{
// Si llegamos aquí, los datos ya son válidos y el usuario está autorizado
$post = $request->user()->posts()->create($request->validated());
return redirect()->route('posts.show', $post)->with('status', 'Post creado.');
}
public function update(UpdatePostRequest $request, Post $post)
{
$post->update($request->validated());
return back()->with('status', 'Cambios guardados.');
}Ignorar el registro actual al editar
En UpdatePostRequest, el slug debe ser único excepto para el post que estamos editando. El post llega por la ruta:
php
public function rules(): array
{
// $this->route('post') es el modelo resuelto por route model binding
$post = $this->route('post');
return [
'title' => ['required', 'string', 'max:255'],
'slug' => ['required', 'alpha_dash', Rule::unique('posts', 'slug')->ignore($post->id)],
'body' => ['required', 'string'],
];
}Error común
Olvidar ->ignore($modelo->id) al editar. Resultado: al guardar sin cambiar el email o slug, Laravel dice "ya está en uso" porque encuentra… el mismo registro.
Mensajes en español
Por defecto los mensajes están en inglés. Para traducirlos:
bash
# Publica la carpeta lang/ (Laravel 11+ no la incluye por defecto)
php artisan lang:publish
# Paquete comunitario con traducciones al español
composer require laravel-lang/common --dev
php artisan lang:add esY en .env:
env
APP_LOCALE=es
APP_FALLBACK_LOCALE=enAhora "The title field is required." se convierte en "El campo título es obligatorio."
Reglas personalizadas
Cuando ninguna regla integrada te sirve, crea la tuya:
bash
php artisan make:rule RfcMexicanophp
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class RfcMexicano implements ValidationRule
{
/**
* $fail('mensaje') marca el campo como inválido.
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
// RFC de persona física (13) o moral (12), simplificado
$patron = '/^([A-ZÑ&]{3,4})(\d{6})([A-Z\d]{3})$/u';
if (! preg_match($patron, strtoupper((string) $value))) {
$fail('El :attribute no tiene un formato de RFC válido.');
}
}
}Uso:
php
use App\Rules\RfcMexicano;
'rfc' => ['required', new RfcMexicano],Para algo de un solo uso también puedes usar un closure directamente en el array de reglas:
php
'codigo' => [
'required',
function (string $attribute, mixed $value, \Closure $fail) {
if ($value === 'GRATIS') {
$fail('Ese código ya no está disponible.');
}
},
],Validación en APIs
Si la petición tiene la cabecera Accept: application/json, Laravel no redirige: responde 422 Unprocessable Content con este formato:
json
{
"message": "The title field is required. (and 1 more error)",
"errors": {
"title": ["The title field is required."],
"body": ["The body field is required."]
}
}Tu frontend (React, Vue, app móvil) sólo tiene que leer errors y mostrar cada mensaje junto a su campo.
Validación manual con Validator
Cuando necesitas controlar qué pasa al fallar (por ejemplo, al importar un CSV fila por fila):
php
use Illuminate\Support\Facades\Validator;
$validator = Validator::make($fila, [
'email' => ['required', 'email'],
'nombre' => ['required', 'max:100'],
]);
if ($validator->fails()) {
// No lanzamos excepción: guardamos los errores y seguimos con la siguiente fila
$errores[] = ['fila' => $numero, 'errores' => $validator->errors()->all()];
continue;
}
$datos = $validator->validated();Buena práctica
Valida siempre en el servidor, aunque también valides en el navegador con required o JavaScript. La validación del navegador es comodidad para el usuario; la del servidor es seguridad. Más en Validación y sanitización.
Error común
Olvidar @csrf en el formulario: Laravel responde 419 Page Expired antes de siquiera validar.
Ejercicios
- Crea un
StoreContactRequestpara un formulario de contacto con:nombre(obligatorio, máx. 80),email(obligatorio, email),telefono(opcional, sólo dígitos, 10 caracteres),mensaje(obligatorio, mínimo 20) yacepta_privacidad(debe estar marcado). Mensajes en español. - Escribe las reglas de un
UpdateUserRequestdonde el email sea único ignorando al usuario actual y la contraseña sea opcional (si viene, mínimo 8 y confirmada).
Ver solución 1
php
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreContactRequest extends FormRequest
{
public function authorize(): bool
{
return true; // formulario público
}
public function rules(): array
{
return [
'nombre' => ['required', 'string', 'max:80'],
'email' => ['required', 'email'],
'telefono' => ['nullable', 'digits:10'],
'mensaje' => ['required', 'string', 'min:20'],
'acepta_privacidad' => ['accepted'], // checkbox marcado: "on", "yes", 1, true
];
}
public function messages(): array
{
return [
'nombre.required' => 'Dinos tu nombre.',
'email.email' => 'Escribe un correo válido.',
'telefono.digits' => 'El teléfono debe tener 10 dígitos.',
'mensaje.min' => 'Cuéntanos un poco más (mínimo 20 caracteres).',
'acepta_privacidad.accepted' => 'Debes aceptar el aviso de privacidad.',
];
}
}Ver solución 2
php
public function rules(): array
{
$user = $this->route('user'); // o $this->user() si edita su propio perfil
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', Rule::unique('users')->ignore($user->id)],
// nullable: si no la escribe, no se valida ni se cambia
'password' => ['nullable', 'confirmed', Password::min(8)],
];
}En el controlador, elimina la contraseña si viene vacía:
php
$data = $request->validated();
if (empty($data['password'])) {
unset($data['password']);
}
$user->update($data); // con el cast 'password' => 'hashed' se hashea solaResumen
$request->validate([...])valida rápido y redirige con errores si falla.- En Blade usa
@error,$errorsyold()para mostrar errores y conservar valores. - Los Form Requests separan validación y autorización del controlador; usa
$request->validated(). - Al editar, usa
Rule::unique(...)->ignore($id). - Traduce mensajes con
php artisan lang:publishylaravel-lang/common. - Crea reglas propias con
php artisan make:rule. - En APIs, Laravel responde 422 con los errores en JSON.