Skip to content

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í:

  1. El usuario envía un formulario.
  2. Laravel revisa los datos contra unas reglas (required, email, max:255...).
  3. Si todo está bien, tu código continúa con los datos ya validados.
  4. 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 422 y 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 ​

ReglaQué hace
requiredEl campo debe venir y no estar vacío
nullablePuede venir vacío (se guarda null)
sometimesSólo valida si el campo viene en la petición
string, integer, numeric, booleanTipo de dato
emailFormato de correo
min:3, max:255Longitud (texto), valor (números) o tamaño en KB (archivos)
between:1,10Rango
in:borrador,publicadoUno de los valores permitidos
unique:users,emailNo debe existir en la tabla
exists:categories,idDebe existir en la tabla
confirmedDebe existir campo_confirmation igual (contraseñas)
date, after:today, before:2030-01-01Fechas
image, mimes:jpg,png, fileArchivos
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 UpdatePostRequest

Se 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 es

Y en .env:

env
APP_LOCALE=es
APP_FALLBACK_LOCALE=en

Ahora "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 RfcMexicano
php
<?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 ​

  1. Crea un StoreContactRequest para 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) y acepta_privacidad (debe estar marcado). Mensajes en español.
  2. Escribe las reglas de un UpdateUserRequest donde 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 sola

Resumen ​

  • $request->validate([...]) valida rápido y redirige con errores si falla.
  • En Blade usa @error, $errors y old() 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:publish y laravel-lang/common.
  • Crea reglas propias con php artisan make:rule.
  • En APIs, Laravel responde 422 con los errores en JSON.

Academia Web — aprende, copia y construye.