[Complete Field Guide] Laravel Form Design & Input Experience — Validation, FormRequest, Error Display, Files, Wizards, Resubmission Prevention, and Building an Accessible UI
What you’ll learn (key takeaways)
- How to build maintainable Laravel validation (FormRequest / rules / custom messages)
- Real-world patterns like Input → Confirm → Complete (wizard), drafts, and resubmission prevention (PRG / double-submit)
- Secure file upload design (MIME / size / virus scanning / preview)
- Standardized error display (error summary, field association,
aria-invalid/aria-describedby) - Input assistance (autocomplete, inputmode, cautions with masks, dates/phone, address completion)
- Accessible forms (labels, required indicators, grouping, keyboard completion, non-color-dependent UI)
- Testing (Feature / Dusk) to prevent regressions in the input experience
Target audience (who benefits?)
- Laravel beginner–intermediate engineers: want a “pattern” that won’t collapse as forms increase
- Designers / writers / QA: want to unify error copy and input aids in a way anyone can understand
- PM / CS: want flows that reduce drop-off and support inquiries
Accessibility level: ★★★★★
Concrete examples are provided for focusing the error summary,
role="alert",aria-describedby, required indicators that don’t rely on color, fieldset/legend, input-assist attributes, keyboard-completable flows, and error wording that makes sense when read aloud.
1. Introduction: Forms are where “quality” shows up the most
A form is the screen users interact with most actively. If input goes poorly, the most important actions—purchase or registration—stop. And unfriendly forms lead to inquiries like “I don’t get it,” “I can’t submit,” “What’s wrong?”
Laravel already has a solid foundation for validation, CSRF, and file handling, so once you decide on an implementation pattern, your team can build reusable, robust forms. This article summarizes that pattern with an accessibility-first approach.
2. Start with a policy: define your form “standards”
These are strong candidates to standardize across a team:
- How to indicate required fields (use text like “Required”; don’t rely on color alone)
- Error display (summary + field association + copy rules)
- Resubmission prevention (PRG, double-submit measures)
- Input assistance (autocomplete, inputmode, examples, placeholder usage)
- File handling (allowed types, size, preview, scanning, deletion)
- Post-completion state (success message, next action)
Once you define these standards, adding new forms becomes faster and the experience becomes consistent.
3. Centralize validation with FormRequest
Validation becomes hard to maintain if it’s scattered across controllers. In Laravel, the standard approach is to use FormRequest to centralize rules and messages.
php artisan make:request RegisterRequest
// app/Http/Requests/RegisterRequest.php
class RegisterRequest extends FormRequest
{
public function rules(): array
{
return [
'name' => ['required','string','max:50'],
'email' => ['required','email','max:255'],
'password' => ['required','string','min:12','confirmed'],
'agree' => ['accepted'],
];
}
public function attributes(): array
{
return [
'name' => 'Name',
'email' => 'Email address',
'password' => 'Password',
'agree' => 'Agreement to the Terms of Service',
];
}
public function messages(): array
{
return [
'agree.accepted' => 'You must agree to the Terms of Service.',
];
}
}
Notes
- Using
attributes()makes the subject names in error messages easier to read. - With
confirmed, Laravel automatically checkspassword_confirmation.
4. Controller: prevent resubmission with PRG (most important)
With PRG (Post/Redirect/Get), you always redirect after submission.
This prevents duplicate submissions caused by refresh (F5).
public function store(RegisterRequest $request)
{
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
return redirect()->route('register.done')
->with('status', 'Registration completed successfully.');
}
5. Error display standard: summary + field association
5.1 Error summary (top of page)
- After submit, focus the summary so the user learns “what happened” as quickly as possible.
role="alert"is useful, but avoid overuse—set a rule like “show only on validation errors.”
@if ($errors->any())
<div id="error-summary" role="alert" tabindex="-1" class="border p-3 mb-4">
<h2 class="font-semibold">Please review your input.</h2>
<ul class="list-disc pl-5">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
Focus movement (example: small JS)
<script>
(function(){
const el = document.getElementById('error-summary');
if (el) el.focus();
})();
</script>
5.2 Field side (association)
- Inputs with errors should have
aria-invalid="true" - Give the error message an
id, and associate it viaaria-describedby.
<label for="email" class="block font-medium">
Email address <span aria-hidden="true">(Required)</span>
<span class="sr-only">Required</span>
</label>
<input id="email" name="email" type="email" value="{{ old('email') }}"
aria-invalid="{{ $errors->has('email') ? 'true' : 'false' }}"
aria-describedby="{{ $errors->has('email') ? 'email-error' : 'email-help' }}"
autocomplete="email"
class="border rounded px-3 py-2 w-full">
<p id="email-help" class="text-sm text-gray-600">Example: hanako@example.com</p>
@if($errors->has('email'))
<p id="email-error" class="text-sm text-red-700">{{ $errors->first('email') }}</p>
@endif
Notes
- Don’t rely on “red text only”—make it explicit in text.
- Keep error messages short and specific (e.g., “Enter a valid email address format.”).
6. Input assistance: autocomplete / inputmode / how to use examples
6.1 Common autocomplete values
- Name:
name - Email:
email - Phone:
tel - Address:
street-address/postal-code/address-level1, etc. - One-time code:
one-time-code - Password:
new-password/current-password
6.2 inputmode
- Numbers:
inputmode="numeric" - Phone:
inputmode="tel" - Email:
type="email"often makesinputmodeunnecessary
6.3 Caution with masked inputs
Masks that auto-insert hyphens for postal codes or phone numbers can be convenient, but they can negatively affect:
- Copy/paste behavior
- Screen reader reading
- Cursor movement while typing
It’s safer to start with input examples and validation, and keep masks to the bare minimum.
7. Grouping the form: reduce confusion with fieldset/legend
For grouped inputs like address or payment, fieldset and legend make screen reader output clearer.
<fieldset class="border p-3">
<legend class="font-semibold">Shipping Address</legend>
<label for="zip" class="block mt-2">Postal code</label>
<input id="zip" name="zip" autocomplete="postal-code" class="border rounded px-2 py-1">
<label for="addr" class="block mt-2">Address</label>
<input id="addr" name="addr" autocomplete="street-address" class="border rounded px-2 py-1 w-full">
</fieldset>
8. File uploads: balance safety and usability
8.1 Validation (example: images)
$request->validate([
'avatar' => ['nullable','file','mimetypes:image/jpeg,image/png','max:2048'],
]);
8.2 UX points
- Explain allowed formats and max size up front (e.g., JPEG/PNG, up to 2MB)
- After upload, show not only the filename but also a preview (for images)
- Provide a delete button so users can recover from wrong selections
- In production, consider async virus scanning and notify the result
Accessibility
- Add
altto preview images (e.g., “Preview of the selected profile image”) - Announce progress/completion with
role="status" - Treat drag-and-drop as optional—users must be able to complete using standard file selection
9. Wizard flow (Input → Confirm → Complete): use session/drafts for confidence
Splitting complex applications or purchases into steps reduces mistakes.
However, preserving state when users go back is critical.
9.1 Typical structure
- Step 1 Input → save to session
- Step 2 Confirm → submit to finalize (PRG)
- Step 3 Complete → show result
// Step 1: save
session(['wizard' => $request->validated()]);
return redirect()->route('apply.confirm');
// Step 2: finalize
$data = session('wizard');
abort_if(!$data, 419);
$application = Application::create($data);
session()->forget('wizard');
return redirect()->route('apply.done');
Accessibility
- Clearly show current step in text (e.g., “Step 2 of 3: Confirm”)
- For progress bars,
role="progressbar"plus numeric values is helpful
10. Double-submit prevention: both server-side and UI-side
10.1 Server-side (idempotency key)
For important “create” operations, assume double submissions can happen and make the operation idempotent.
Example: issue a hidden UUID per form and record it in the DB to prevent duplicates.
10.2 UI-side (disable the submit button)
- Disable the button immediately on submit
- Add
aria-disabled="true"to make state explicit - But ensure the server-side still protects even if JS is disabled
<button id="submit" class="px-4 py-2 border rounded">Submit</button>
<div id="submit-status" role="status" aria-live="polite" class="sr-only"></div>
<script>
document.querySelector('form')?.addEventListener('submit', () => {
const b = document.getElementById('submit');
b.disabled = true;
b.setAttribute('aria-disabled','true');
document.getElementById('submit-status').textContent = 'Submitting. Please wait.';
});
</script>
11. Success messages: design so they aren’t missed
For success, use flash messages like with('status', ...) and show them near the top.
For screen readers, role="status" works well.
@if(session('status'))
<div role="status" aria-live="polite" class="border p-3 mb-4">
{{ session('status') }}
</div>
@endif
12. Testing: forms break easily, so protect them
12.1 Feature test (doesn’t return 422)
public function test_register_success()
{
$res = $this->post('/register', [
'name'=>'Hanako Yamada',
'email'=>'hanako@example.com',
'password'=>'StrongPassw0rd!',
'password_confirmation'=>'StrongPassw0rd!',
'agree'=>1,
]);
$res->assertRedirect(route('register.done'));
}
12.2 Dusk (focus on error summary)
Check end-to-end:
- Submit → error → focus moves to summary
aria-invalidis addedaria-describedbyswitches to the error message
This prevents accessibility regressions.
13. Common pitfalls and how to avoid them
- No label, only placeholder
- Fix: always provide a
label
- Fix: always provide a
- Errors don’t appear near inputs
- Fix: show error under the field +
aria-describedby
- Fix: show error under the field +
- Vague error messages (“Invalid”)
- Fix: say what to change, briefly and concretely
- Required/errors indicated by red color only
- Fix: add text (“Required”, error details)
- Double submissions occur
- Fix: PRG + idempotency key + button disable
- File limits are revealed too late
- Fix: explain upfront + strict server-side validation
- Wizard can’t go back
- Fix: session/draft saving so state can be restored
14. Checklist (for sharing)
Validation
- [ ] Centralize in FormRequest; use attributes/messages for readability
- [ ] Rules are minimal and explicit (limits / format / required)
Error display
- [ ] Error summary + field association
- [ ]
aria-invalid,aria-describedby - [ ] Explain with text, not color dependence
- [ ] Focus the summary
Input assistance
- [ ] autocomplete / inputmode / examples
- [ ] Masks are minimal
- [ ] Group using fieldset/legend
Submission
- [ ] Prevent resubmission with PRG
- [ ] Idempotency key + UI disabling
- [ ] Success message uses
role="status"
Files
- [ ] MIME/size limits; scan if needed
- [ ] Upfront guidance, preview, deletion flow
Tests
- [ ] Feature tests for success/failure
- [ ] Dusk tests for focus/ARIA (important forms)
15. Summary
Laravel forms improve dramatically when you centralize validation in FormRequest, prevent resubmission with PRG, and reduce confusion with an error summary plus field association. When you add input-assist attributes and fieldset/legend grouping, and avoid color-dependent required indicators while keeping error messages short, accessibility naturally improves. Design for real-world pain points—files, wizards, and double submits—from the start, and standardize a form experience that doesn’t break easily.
Reference links
- Laravel (official)
- Accessibility
