[Guía práctica] Crear formularios accesibles en Laravel — Validación, diseño de errores, asistencia de entrada, guardado progresivo, multi-paso
Lo que aprenderás (puntos destacados)
- Cómo construir una base mantenible de formularios con FormRequest de Laravel y componentes Blade
- UI con mentalidad de accesibilidad: etiquetas, descripciones, mensajes de error, gestión del foco, regiones en vivo
- Patrones de producción para tipos de entrada,
autocomplete
,inputmode
, validación en tiempo real, máscaras, direcciones/tarjetas - UX probada en producción: multipaso, visualización del progreso, guardado de borradores, recuperación de borradores
- Seguridad/Compliance: protección contra spam, CSRF, subida de archivos, captura de consentimientos
- Ángulos de prueba con Dusk/Feature/PA11y, además de una checklist distribuible
Lectores previstos (¿quién se beneficia?)
- Desarrolladores Laravel principiantes–intermedios: implementar formularios básicos de registro/compra/alta con seguridad y legibilidad
- Tech leads para proyectos de cliente/SaaS interno: convertir piezas reutilizables de formularios en estándares de equipo
- Diseñadores/redactores técnicos: estandarizar reglas para copys de etiquetas y de errores
- QA/especialistas de accesibilidad: sistematizar la verificación para lectores de pantalla, uso con teclado y señales no cromáticas
1. Empieza por el diseño: decide primero la “arquitectura de información” del formulario
- Define el objetivo y el resultado en una frase (p. ej., registro de miembro → “Recoger una dirección contactable e info de verificación de identidad”).
- Minimiza entradas: solo obligatorias. Reevalúa si los campos opcionales son realmente necesarios.
- Divide en secciones: agrupa datos personales, contacto, pago, etc., con encabezados y descripciones.
- Política de errores: resumen + bajo cada campo, e indica claramente cómo corregir con texto breve.
- Texto de consentimiento: propósito, periodo de retención y método de retirada en lenguaje claro. Divide contenido largo a una página de detalle.
2. Directorios y código base
app/
├─ Http/
│ ├─ Requests/
│ │ └─ RegisterRequest.php
│ └─ Controllers/
│ └─ RegisterController.php
resources/
└─ views/
├─ components/form/ // Partes de UI del formulario
│ ├─ field.blade.php
│ ├─ input.blade.php
│ ├─ select.blade.php
│ ├─ checkbox.blade.php
│ ├─ file.blade.php
│ └─ errors-summary.blade.php
└─ auth/register.blade.php
- Centraliza validación, nombres de atributos y mensajes con FormRequest.
- Estandariza etiquetas/descripciones/visualización de errores/
aria-*
con componentes Blade. - Mantén los controladores centrados en la persistencia.
3. FormRequest: validación, nombres de atributos y mensajes
// app/Http/Requests/RegisterRequest.php
class RegisterRequest extends FormRequest
{
public function authorize(): bool { return true; }
public function rules(): array
{
return [
'name' => ['required','string','max:80'],
'email' => ['required','email','max:255','unique:users,email'],
'password' => ['required','string','min:12','confirmed'],
'phone' => ['nullable','string','max:20'],
'agree' => ['accepted'],
'avatar' => ['nullable','file','mimes:jpg,jpeg,png,webp','max:2048'],
];
}
public function attributes(): array
{
return [
'name' => 'Name',
'email' => 'Email address',
'password' => 'Password',
'password_confirmation' => 'Password (confirmation)',
'phone' => 'Phone number',
'agree' => 'Agreement to Terms of Service',
'avatar' => 'Profile image',
];
}
public function messages(): array
{
return [
'password.min' => 'Please enter at least :min characters for your password.',
'agree.accepted' => 'Please agree to the Terms of Service.',
];
}
}
- Usa
accepted
para requerir explícitamente un checkbox marcado. - Usa
confirmed
para validar la coincidencia con el campo de confirmación. - Restringe siempre archivos por MIME + tamaño.
4. Componentizar campos
4.1 Envoltorio (components/form/field.blade.php
)
@props(['id','label','help'=>null,'required'=>false,'error'=>null])
<div class="mb-5">
<label for="{{ $id }}" class="block font-medium">
{{ $label }} @if($required)<span aria-hidden="true">(required)</span>@endif
</label>
<div>
{{ $slot }}
</div>
@if($help)
<p id="{{ $id }}-help" class="text-sm text-gray-600 mt-1">{{ $help }}</p>
@endif
@if($error)
<p id="{{ $id }}-error" class="text-sm text-red-700 mt-1" role="alert">{{ $error }}</p>
@endif
</div>
4.2 Input de texto (components/form/input.blade.php
)
@props([
'id','type'=>'text','value'=>null,'required'=>false,
'autocomplete'=>null,'inputmode'=>null,'describedby'=>null,'invalid'=>false,
])
<input
id="{{ $id }}"
name="{{ $attributes->get('name') }}"
type="{{ $type }}"
value="{{ old($attributes->get('name'), $value) }}"
@if($autocomplete) autocomplete="{{ $autocomplete }}" @endif
@if($inputmode) inputmode="{{ $inputmode }}" @endif
@if($required) aria-required="true" @endif
@if($describedby) aria-describedby="{{ $describedby }}" @endif
@if($invalid) aria-invalid="true" @endif
class="w-full border rounded px-3 py-2"
/>
- Combina IDs de ayuda y error en
aria-describedby
separados por un espacio. aria-invalid
solo cuando hay error.- Ajusta
autocomplete
einputmode
adecuadamente.
5. Pantalla: ensamblar el formulario de registro
{{-- resources/views/auth/register.blade.php --}}
@extends('layouts.app')
@section('title','Sign Up')
@section('content')
<h1 class="text-2xl font-semibold mb-4" id="page-title" tabindex="-1">Sign Up</h1>
{{-- Resumen de errores (arriba, con enlaces a cada campo) --}}
@if ($errors->any())
<x-form.errors-summary :errors="$errors" />
@endif
<form action="{{ route('register.store') }}" method="POST" enctype="multipart/form-data" novalidate>
@csrf
@php
$nameError = $errors->first('name');
$nameDescIds = trim('name-help '.($nameError ? 'name-error' : ''));
@endphp
<x-form.field id="name" label="Name" :required="true" help="Enter your legal name." :error="$nameError">
<x-form.input id="name" name="name" :required="true"
autocomplete="name" inputmode="text"
:describedby="$nameDescIds" :invalid="(bool)$nameError" />
</x-form.field>
@php
$emailError = $errors->first('email');
$emailDescIds = trim('email-help '.($emailError ? 'email-error' : ''));
@endphp
<x-form.field id="email" label="Email address" :required="true" help="We’ll send a confirmation email. Use an address you can receive."
:error="$emailError">
<x-form.input id="email" name="email" type="email" :required="true"
autocomplete="email" inputmode="email"
:describedby="$emailDescIds" :invalid="(bool)$emailError" />
</x-form.field>
@php
$pwError = $errors->first('password');
$pwDescIds = trim('password-help '.($pwError ? 'password-error' : ''));
@endphp
<x-form.field id="password" label="Password" :required="true" help="12+ characters. Recommended: mix upper/lowercase, numbers, symbols."
:error="$pwError">
<x-form.input id="password" name="password" type="password" :required="true"
autocomplete="new-password"
:describedby="$pwDescIds" :invalid="(bool)$pwError" />
</x-form.field>
<x-form.field id="password_confirmation" label="Password (confirmation)" :required="true">
<x-form.input id="password_confirmation" name="password_confirmation" type="password"
autocomplete="new-password" />
</x-form.field>
@php
$phoneError = $errors->first('phone');
@endphp
<x-form.field id="phone" label="Phone number" help="Hyphens are auto-formatted."
:error="$phoneError">
<x-form.input id="phone" name="phone" inputmode="tel" autocomplete="tel"
:invalid="(bool)$phoneError" />
</x-form.field>
@php
$avatarError = $errors->first('avatar');
@endphp
<x-form.field id="avatar" label="Profile image" help="JPG/PNG/WebP, 2MB or less."
:error="$avatarError">
<x-form.file id="avatar" name="avatar" accept=".jpg,.jpeg,.png,.webp" />
</x-form.field>
<div class="mb-5">
<label class="inline-flex items-center">
<input type="checkbox" name="agree" value="1" @checked(old('agree'))>
<span class="ml-2">
I agree to the <a href="{{ route('terms') }}" class="underline">Terms of Service</a>
</span>
</label>
@error('agree')<p role="alert" class="text-sm text-red-700 mt-1">{{ $message }}</p>@enderror
</div>
<button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded">Create account</button>
</form>
@endsection
Puntos clave
- Coloca el resumen de errores arriba del formulario con enlaces ancla a cada campo.
- Cada campo asocia ayuda y error vía
aria-describedby
. - Usa
novalidate
para suprimir popups nativos y mantener mensajería consistente.
6. Implementar el resumen de errores
{{-- components/form/errors-summary.blade.php --}}
@props(['errors'])
<nav class="mb-4 p-3 bg-red-50 border border-red-200 rounded" aria-labelledby="error-title">
<h2 id="error-title" class="font-semibold text-red-800">Please review your input.</h2>
<ul class="list-disc pl-5 mt-2">
@foreach ($errors->keys() as $key)
<li>
<a class="underline text-red-800" href="#{{ $key }}">
{{ $errors->first($key) }}
</a>
</li>
@endforeach
</ul>
</nav>
- La lista de errores usa enlaces para saltar al campo.
- Inglés conciso y fácil para lectores de pantalla.
7. Asistencia de entrada: autocomplete
, inputmode
, enmascarado
7.1 Ajustes comunes
- Nombre:
autocomplete="name"
- Email:
autocomplete="email"
- Código postal:
autocomplete="postal-code"
,inputmode="numeric"
- Dirección:
autocomplete="address-line1"
,address-level1
(provincia/estado),address-level2
(ciudad) - Teléfono:
autocomplete="tel"
,inputmode="tel"
- Nº de tarjeta:
autocomplete="cc-number"
,inputmode="numeric"
- Fecha caducidad:
autocomplete="cc-exp"
- Código seguridad:
autocomplete="cc-csc"
7.2 Guías de formateo inline
- El formato visual como insertar guiones está bien; normaliza al enviar antes de guardar.
- “Debouncea” la validación en tiempo real ~300 ms para evitar parpadeo excesivo.
- Usa una región con
aria-live="polite"
para anunciar resultados de validación.
<div id="pw-hint" class="sr-only" aria-live="polite"></div>
8. Fechas, horas, selects: elegir widgets de entrada
- Prefiere
<input type="date|time|datetime-local">
nativos. - Si agregas calendario, soporta teclado y lectores de pantalla.
- Para selects largos, cambia a combobox con búsqueda (
role="combobox"
/aria-expanded
). - Agrupa radio/checkbox con
fieldset
/legend
.
9. Subida de archivos: previsualización y texto alternativo
- Al previsualizar imágenes, proporciona un campo de texto alternativo al lado.
- Indica estado durante la subida con
aria-busy="true"
. - Maneja errores (tamaño excesivo/extensión inválida) breve y concretamente.
10. Multi-paso: progreso, guardar, restaurar
10.1 Progreso y navegación
- Usa
aria-current="step"
para el paso actual; añaderole="list"
para lectura de pantalla. - Muestra también texto numérico del progreso (p. ej., 3/5).
<ol class="flex gap-2" aria-label="Registration steps">
<li aria-current="step">1. Basic info</li>
<li>2. Contact</li>
<li>3. Review</li>
</ol>
10.2 Guardado de borrador y recuperación
- Guarda borradores como JSON en BD y auto-restaura al volver.
- Auto-guarda cada decenas de segundos; borra el borrador al enviar definitivo.
- No guardes elementos sensibles (contraseñas, etc.).
11. Anti-spam y seguridad
- El token CSRF es obligatorio (
@csrf
). - Campo invisible (honeypot) + umbral de tiempo de envío.
- Usa Rate Limiting para prevenir envíos masivos.
- Evalúa consentimientos con
accepted
del lado servidor. - Nunca reveles la existencia de cuenta en mensajes de error (login/reset).
12. Persistencia en servidor y caminos de fallo
- Ante fallo, retén valores e indica claramente siguientes acciones en el resumen superior.
- Mantén siempre una ruta visible a “Guardar y continuar después.”
- Desactiva el botón de envío tras clic (evita doble envío).
13. Detalles de accesibilidad
- Cada campo debe tener etiqueta. Los placeholders son complementarios, no sustitutos.
- No dependas solo del color. Usa también iconos/texto.
- En error, mueve el foco a la parte superior del formulario y lee el resumen.
- Divide textos largos de consentimiento en resumen + detalles, con viñetas para claves.
- Respeta
prefers-reduced-motion
reduciendo animaciones.
14. Ejemplo de controlador: registro y borradores
// app/Http/Controllers/RegisterController.php
class RegisterController extends Controller
{
public function create()
{
$draft = auth()->check() ? auth()->user()->draft('register') : null;
return view('auth.register', ['draft' => $draft]);
}
public function store(RegisterRequest $request)
{
$data = $request->validated();
if ($request->hasFile('avatar')) {
$data['avatar_path'] = $request->file('avatar')->store('avatars','public');
}
$user = \App\Models\User::create([
'name'=>$data['name'],
'email'=>$data['email'],
'password'=>bcrypt($data['password']),
'phone'=>$data['phone'] ?? null,
'avatar_path'=>$data['avatar_path'] ?? null,
]);
auth()->login($user);
// Delete draft
// Draft::clear('register', $user->id);
return redirect()->route('dashboard')->with('status','Registration completed.');
}
}
15. Tests: Feature/Dusk/Accesibilidad
15.1 Feature
public function test_register_validation_and_persist()
{
$res = $this->post('/register', [
'name'=>'', 'email'=>'invalid', 'password'=>'short', 'password_confirmation'=>'mismatch'
]);
$res->assertSessionHasErrors(['name','email','password']);
$res = $this->post('/register', [
'name'=>'Hanako Yamada',
'email'=>'hanako@example.com',
'password'=>'strong-password-123!',
'password_confirmation'=>'strong-password-123!',
'agree'=>'1',
]);
$res->assertRedirect('/dashboard');
$this->assertDatabaseHas('users',['email'=>'hanako@example.com']);
}
15.2 Dusk (extracto)
- En error, el foco se mueve al resumen superior.
- Cada campo tiene
label for
, yaria-describedby
vincula errores y descripciones. - El flujo se puede completar solo con teclado.
- Se reducen animaciones cuando se establece
prefers-reduced-motion
.
16. Errores comunes y remedios
- Sin etiquetas, solo placeholder → Proporciona siempre una etiqueta.
- Copys de error vagos → Indica instrucciones concretas y breves de corrección.
- Error indicado solo por color → Añade texto/icono/borde.
- Validación en tiempo real llamativa → Usa debounce y avisos sutiles.
- UI de fecha custom sin teclado → Prefiere inputs nativos; si agregas, sigue APG.
- Foco perdido en flujos con modal → Un modal por pantalla, gestiona foco de retorno.
- Doble envío → Desactiva el botón durante el envío y muestra estado visual.
17. Checklist (para distribución)
Estructura
- [ ] Expón el propósito en una frase; agrupa con encabezados de sección
- [ ]
label
para cada campo; vincula descripciones víaaria-describedby
- [ ] Resumen de errores + errores bajo campo con enlaces de salto
Entrada
- [ ]
type
/autocomplete
/inputmode
apropiados - [ ] Validación en tiempo real con debounce; notifica vía
aria-live
- [ ] Preferir inputs nativos de fecha/hora
Accesibilidad
- [ ] Señales no cromáticas;
aria-invalid
solo cuando existen errores - [ ] Enfocar el resumen al fallar el envío
- [ ] Respetar
prefers-reduced-motion
Seguridad
- [ ]
@csrf
, honeypot, Rate Limiting - [ ] Validar MIME/tamaño de archivo
- [ ] Validar consentimientos con
accepted
UX/Operaciones
- [ ] Guardado y reanudación de borradores
- [ ] Prevenir doble envío
- [ ] Mostrar claramente la siguiente acción al éxito
18. Cierre
- Construye una base legible y reutilizable con FormRequest y componentes Blade.
- Clarifica las relaciones entre etiquetas/descripciones/errores, y usa un resumen de errores más movimiento de foco para que las personas no se pierdan.
- Reduce la carga de tecleo con
autocomplete
/inputmode
/elementos nativos. - Haz que los flujos multipaso sean resilientes a interrupciones con progreso y guardado de borradores.
- Seguridad y accesibilidad pueden convivir—usa lenguaje breve y concreto para guiar.
Los formularios son la puerta de entrada de tu producto. Un cuidado atento aquí reduce el abandono y fomenta una experiencia que cualquiera pueda usar con comodidad. Usa el ejemplo de hoy como base y evolucionalo hacia el estándar de tu equipo.
Referencias
- Laravel Oficial
- Especificaciones HTML/Form
- Accesibilidad
- Diseño/Redacción