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

[Practical Guide] Building Accessible Form UX in Laravel — Validation, Error Design, Input Assistance, Progressive Save, Multi-Step

What you’ll learn (highlights)

  • How to build a maintainable form foundation with Laravel FormRequest and Blade components
  • Accessibility-minded UI: labels, descriptions, error messages, focus management, live regions
  • Production patterns for input types, autocomplete, inputmode, real-time validation, masks, address/card inputs
  • Work-proven UX: multi-step, progress display, draft save, draft recovery
  • Security/Compliance: spam protection, CSRF, file upload, consent capture
  • Testing angles with Dusk/Feature/PA11y, plus a distributable checklist

Intended readers (who benefits?)

  • Beginner–intermediate Laravel engineers: implement core forms for signup/purchase/registration safely and readably
  • Tech leads for client work/in-house SaaS: turn reusable form parts into team standards
  • Designers/technical writers: standardize rules for label copy & error copy
  • QA/accessibility specialists: systematize verification for screen readers, keyboard use, and non-color cues

1. Start with design: decide the form’s “information architecture” first

  • Define the goal and outcome in one sentence (e.g., member registration → “Collect a reachable address and identity verification info”).
  • Minimize inputs: required only. Re-evaluate whether optional fields are truly needed.
  • Split into sections: group personal info, contact, payment, etc., with headings and descriptions.
  • Error policy: summary + under each field, and clearly state how to fix in short text.
  • Consent copy: purpose, retention period, and withdrawal method in plain language. Split long content to a detail page.

2. Directories and foundation code

app/
├─ Http/
│  ├─ Requests/
│  │   └─ RegisterRequest.php
│  └─ Controllers/
│      └─ RegisterController.php
resources/
└─ views/
   ├─ components/form/                // Form UI parts
   │   ├─ field.blade.php
   │   ├─ input.blade.php
   │   ├─ select.blade.php
   │   ├─ checkbox.blade.php
   │   ├─ file.blade.php
   │   └─ errors-summary.blade.php
   └─ auth/register.blade.php
  • Centralize validation, attribute names, and messages with FormRequest.
  • Standardize labels/descriptions/error display/aria-* with Blade components.
  • Keep controllers focused on persistence logic.

3. FormRequest: validation, attribute names, and messages

// 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.',
        ];
    }
}
  • Use accepted to explicitly require a checked box.
  • Use confirmed to validate match with confirmation input.
  • Always restrict files by MIME + size.

4. Componentizing fields

4.1 Wrapper (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 Text input (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"
/>
  • Combine help and error IDs in aria-describedby separated by a space.
  • aria-invalid only when there’s an error.
  • Set autocomplete and inputmode appropriately.

5. Screen: assembling the registration form

{{-- 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>

  {{-- Error summary (shown first, with in-page links to each field) --}}
  @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

Key points

  • Place the error summary at the top of the form with anchor links to each field.
  • Each field associates help and error via aria-describedby.
  • Use novalidate to suppress native popups and keep consistent messaging.

6. Implementing the error summary

{{-- 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>
  • The error list uses links to jump to the field.
  • Concise, easy-to-understand English for screen readers.

7. Input assistance: autocomplete, inputmode, masking

7.1 Common settings

  • Name: autocomplete="name"
  • Email: autocomplete="email"
  • Postal code: autocomplete="postal-code", inputmode="numeric"
  • Address: autocomplete="address-line1", address-level1 (state/prefecture), address-level2 (city)
  • Phone: autocomplete="tel", inputmode="tel"
  • Card number: autocomplete="cc-number", inputmode="numeric"
  • Expiry date: autocomplete="cc-exp"
  • Security code: autocomplete="cc-csc"

7.2 Inline formatting guidelines

  • Visual formatting like auto-inserting hyphens is OK; normalize on submit before saving.
  • Debounce real-time validation by ~300ms to avoid excessive flicker.
  • Use a region with aria-live="polite" to announce validation results.
<div id="pw-hint" class="sr-only" aria-live="polite"></div>

8. Dates, times, selects: choosing input widgets

  • Prefer native <input type="date|time|datetime-local">.
  • If adding a calendar picker, support keyboard interaction and screen readers.
  • For long select lists, switch to a searchable combobox (role="combobox" / aria-expanded).
  • Group radio/checkbox sets with fieldset/legend.

9. File upload: preview and alternative text

  • When previewing images, provide an alternative text input alongside.
  • Indicate state during upload with aria-busy="true".
  • Handle errors (oversize/invalid extension) briefly and concretely.

10. Multi-step: progress, save, restore

10.1 Progress and navigation

  • Use aria-current="step" for the current step; add role="list" to aid screen reading.
  • Show numeric text for progress as well (e.g., 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 Draft save and recovery

  • Save drafts as JSON in the DB and auto-restore on return.
  • Auto-save every tens of seconds; clear the draft on final submit.
  • Do not store sensitive items (passwords, etc.).

11. Anti-spam & security

  • CSRF token is mandatory (@csrf).
  • Invisible field (honeypot) + threshold for submission time.
  • Use Rate Limiting to prevent bulk submissions.
  • Evaluate consent with accepted on the server side.
  • Never disclose account existence in error messages (login/reset flows).

12. Server persistence and failure paths

  • On failure, retain input values and clearly state next actions in the top summary.
  • Always keep a visible path to “Save and continue later.”
  • Disable the submit button after click (prevent double submit).

13. Accessibility details

  • Every field must have a label. Placeholders are supplementary, not substitutes.
  • Don’t rely on color alone. Also use icons/text.
  • On error, move focus to the form top and read the summary.
  • Split long consent text into summary + details, with bullet points for key items.
  • Honor prefers-reduced-motion by reducing animations.

14. Controller example: registration & drafts

// 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/Accessibility

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 (excerpt)

  • On error, focus moves to the top summary.
  • Each field has a label for, and aria-describedby ties errors and descriptions.
  • The flow can be completed using keyboard only.
  • Animations are reduced when prefers-reduced-motion is set.

16. Common pitfalls and remedies

  • No labels, placeholder only → Always provide a label.
  • Vague error copy → State concrete, short fix instructions.
  • Error indicated by color only → Also use text/icon/border.
  • Flashy real-time validation → Use debounce and subtle notices.
  • Custom date UI not keyboard-friendly → Prefer native inputs; if added, follow APG.
  • Focus lost in multi-modal flows → One modal per screen, manage return focus.
  • Double submit → Disable the button during submission and show visual state.

17. Checklist (for distribution)

Structure

  • [ ] Present the purpose in one sentence; group with section headings
  • [ ] label for every field; link descriptions via aria-describedby
  • [ ] Error summary + under-field errors with links to jump

Input

  • [ ] Appropriate type/autocomplete/inputmode
  • [ ] Debounced real-time validation; notify via aria-live
  • [ ] Prefer native date/time inputs

Accessibility

  • [ ] Non-color cues; aria-invalid only when errors exist
  • [ ] Focus the summary on submission failure
  • [ ] Respect prefers-reduced-motion

Security

  • [ ] @csrf, honeypot, Rate Limiting
  • [ ] Validate file MIME/size
  • [ ] Validate consent with accepted

UX/Operations

  • [ ] Draft save & resume
  • [ ] Prevent double submission
  • [ ] Clearly show the next action on success

18. Wrap-up

  • Build a readable, reusable foundation with FormRequest and Blade components.
  • Clarify the relationships among labels/descriptions/errors, and use an error summary plus focus movement so users don’t get lost.
  • Reduce typing burden with autocomplete/inputmode/native elements.
  • Make multi-step flows resilient to interruption with progress and draft saving.
  • Security and accessibility can coexist—use short, concrete language to guide users.

Forms are the front door of your product. Thoughtful care here reduces drop-off and nurtures an experience anyone can use comfortably. Use today’s sample as a base and evolve it into your team standard.


References

By greeden

Leave a Reply

Your email address will not be published. Required fields are marked *

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