php elephant sticker
Photo by RealToughCandy.com on Pexels.com

[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 e inputmode 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ñade role="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, y aria-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ía aria-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

por greeden

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)