[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
andinputmode
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; addrole="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
, andaria-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 viaaria-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
- Laravel Official
- HTML/Form Specs
- Accessibility
- Design/Writing