【実務ガイド】LaravelでつくるアクセシブルなフォームUX――バリデーション、エラーデザイン、入力支援、段階的保存、マルチステップ
この記事で学べること(要点)
- LaravelのFormRequestとBladeコンポーネントで、保守しやすいフォーム基盤を構築する方法
- ラベル、説明、エラーメッセージ、フォーカス移動、ライブリージョンなどアクセシビリティに配慮したUI
- 入力種別・
autocomplete
・inputmode
・リアルタイム検証・マスク・住所/カード入力の実務パターン - マルチステップ、進捗表示、下書き保存、ドラフト復旧など業務で効くUX
- スパム対策、CSRF、ファイルアップロード、同意取得のセキュリティ/コンプライアンス
- Dusk/Feature/PA11yのテスト観点と配布できるチェックリスト
想定読者(だれが得をする?)
- 初〜中級のLaravelエンジニア:申込/購入/登録など主要フォームを安全に、読みやすく実装したい方
- 受託/自社SaaSのテックリード:再利用可能なフォーム部品をチーム標準にしたい方
- デザイナー/テクニカルライター:ラベル文言・エラー文のルールを整えたい方
- QA/アクセシビリティ担当:読み上げ・キーボード・色に依存しない表示の検証を体系化したい方
1. まず設計:フォームの「情報設計」を先に決める
- 目的と成果を1文で定義(例:会員登録→「連絡可能なアドレスと本人確認情報を取得」)。
- 入力の最小化:必須のみ。任意は本当に必要か再検討。
- セクション分割:個人情報、連絡先、支払い等を見出しと説明でグルーピング。
- エラーポリシー:サマリ+各項目下の二段構え、修正方法を短く明示。
- 同意文:目的・保管期間・撤回方法を平易な言葉で。長文は詳細ページに分離。
2. ディレクトリと基盤コード
app/
├─ Http/
│ ├─ Requests/
│ │ └─ RegisterRequest.php
│ └─ Controllers/
│ └─ RegisterController.php
resources/
└─ views/
├─ components/form/ // フォームUIの部品
│ ├─ field.blade.php
│ ├─ input.blade.php
│ ├─ select.blade.php
│ ├─ checkbox.blade.php
│ ├─ file.blade.php
│ └─ errors-summary.blade.php
└─ auth/register.blade.php
- FormRequestで検証・属性名・メッセージを集中管理。
- Bladeコンポーネントでラベル/説明/エラー表示/
aria-*
を共通化。 - コントローラは保存ロジックに専念。
3. FormRequest:検証と属性名・メッセージ
// 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' => '氏名',
'email' => 'メールアドレス',
'password' => 'パスワード',
'password_confirmation' => 'パスワード(確認)',
'phone' => '電話番号',
'agree' => '利用規約への同意',
'avatar' => 'プロフィール画像',
];
}
public function messages(): array
{
return [
'password.min' => 'パスワードは:min文字以上で入力してください。',
'agree.accepted' => '利用規約に同意してください。',
];
}
}
accepted
でチェック必須を明確化。confirmed
で確認入力と一致を検証。- ファイルはMIME + サイズを必ず制限。
4. フィールドの共通コンポーネント化
4.1 ラッパー(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">(必須)</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 テキスト入力(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"
/>
aria-describedby
にヘルプとエラーのIDを空白区切りで連結。aria-invalid
はエラー時のみ。autocomplete
やinputmode
を適切に設定。
5. 画面:登録フォームの組み立て
{{-- resources/views/auth/register.blade.php --}}
@extends('layouts.app')
@section('title','新規登録')
@section('content')
<h1 class="text-2xl font-semibold mb-4" id="page-title" tabindex="-1">新規登録</h1>
{{-- エラーサマリ(最初に表示、ページ内リンクで各項目へ) --}}
@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="氏名" :required="true" help="本名を入力してください。" :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="メールアドレス" :required="true" help="確認メールをお送りします。受信可能なアドレスを入力。"
: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="パスワード" :required="true" help="12文字以上。推奨:英大小・数字・記号を混在。"
: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="パスワード(確認)" :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="電話番号" help="ハイフンは自動整形されます。"
: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="プロフィール画像" help="JPG/PNG/WebP、2MB以下。"
: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">
<a href="{{ route('terms') }}" class="underline">利用規約</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">登録する</button>
</form>
@endsection
ポイント
- エラーサマリをフォームの先頭に置き、各項目へのアンカーリンクを用意。
- 各フィールドは
aria-describedby
でヘルプとエラーを関連付け。 novalidate
でブラウザ標準のポップアップを抑え、一貫したメッセージに。
6. エラーサマリの実装
{{-- 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">入力内容を確認してください。</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>
- エラー一覧はリンクで該当項目へ移動。
- 音声読み上げでも理解しやすい簡潔な日本語。
7. 入力支援:autocomplete
・inputmode
・マスク
7.1 代表的な設定表
- 名前:
autocomplete="name"
- メール:
autocomplete="email"
- 郵便番号:
autocomplete="postal-code"
,inputmode="numeric"
- 住所:
autocomplete="address-line1"
,address-level1
(都道府県),address-level2
(市区町村) - 電話:
autocomplete="tel"
,inputmode="tel"
- カード番号:
autocomplete="cc-number"
,inputmode="numeric"
- 有効期限:
autocomplete="cc-exp"
- セキュリティコード:
autocomplete="cc-csc"
7.2 インライン整形の指針
- ハイフン自動挿入などの見た目整形は可、送信時に正規化して保存。
- リアルタイム検証は**遅延(300ms程度)**して過剰な点滅を避ける。
aria-live="polite"
の領域で検証結果を説明する。
<div id="pw-hint" class="sr-only" aria-live="polite"></div>
8. 日付・時刻・セレクト:入力部品の選び方
- ブラウザネイティブの
<input type="date|time|datetime-local">
を優先。 - カレンダーピッカーを導入する場合はキーボード操作と読み上げ対応が必要。
- セレクトは項目数が多い場合、検索可能なコンボボックスへ(
role="combobox"
/aria-expanded
)。 - ラジオ/チェックボックス群は
fieldset
/legend
でグループ化。
9. ファイルアップロード:プレビューと代替テキスト
- 画像プレビューには代替テキスト入力も同時に。
- アップロード中は
aria-busy="true"
で状態を示す。 - エラーハンドリング(サイズ超過・拡張子不正)は短文で具体的に。
10. マルチステップ:進捗・保存・復旧
10.1 進捗とナビゲーション
- 現在位置に
aria-current="step"
、リストはrole="list"
で読み上げを補助。 - 進捗バーは数値テキストも併記(例:3/5)。
<ol class="flex gap-2" aria-label="登録ステップ">
<li aria-current="step">1. 基本情報</li>
<li>2. 連絡先</li>
<li>3. 確認</li>
</ol>
10.2 下書き保存と復旧
- ドラフトをDBにJSONで保存し、再訪時に自動復元。
- 自動保存は数十秒間隔、送信時はドラフトを消去。
- 機密項目(パスワード等)は保存しない。
11. スパム対策・セキュリティ
- CSRFトークンは必須(
@csrf
)。 - 目に見えないフィールド(ハニーポット)+提出時間の閾値。
- Rate Limitingで多量送信を防止。
- 同意チェックは
accepted
でサーバ側でも評価。 - エラーメッセージで存在可否を明かさない(ログイン/再発行系)。
12. サーバ保存と失敗時の導線
- 失敗時は入力値を保持し、上部のサマリに次アクションを明記。
- 「保存して後で続ける」導線を常に見える位置に配置。
- 送信ボタンは一度押したら無効化(二重送信防止)。
13. アクセシビリティの詳細ポイント
- ラベルは全項目に必ず。プレースホルダは補足であり代替ではない。
- 色だけで状態を示さない。アイコン/テキストも併用。
- エラー発生時はフォーム先頭へフォーカスを移し、サマリを読み上げ。
- 長文同意文は要約+詳細に分割、要点を箇条書きで。
prefers-reduced-motion
に従ってアニメーションを短縮。
14. コントローラ例:登録処理とドラフト
// 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);
// ドラフト削除
// Draft::clear('register', $user->id);
return redirect()->route('dashboard')->with('status','登録が完了しました。');
}
}
15. テスト:Feature/Dusk/アクセシビリティ
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'=>'山田花子',
'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(抜粋)
- エラー時、先頭のサマリにフォーカスが移動する。
- 各項目に**
label for
**があり、aria-describedby
でエラーと説明が紐付く。 - キーボードだけで送信まで完了できる。
prefers-reduced-motion
でアニメーションが抑制される。
16. よくある落とし穴と回避策
- ラベルがなくプレースホルダのみ → ラベルを必ず配置。
- エラー文が曖昧 → 修正方法を短く具体的に。
- 色だけでエラー表示 → テキスト/アイコン/枠線も使用。
- リアルタイム検証の点滅 → 遅延と控えめな通知。
- カスタム日付UIがキーボード非対応 → ネイティブ入力優先、導入時はAPG準拠。
- 多段ポップアップでフォーカス迷子 → 1画面1モーダル、戻り先を管理。
- 二重送信 → 送信中はボタンを無効化し、視覚的に示す。
17. チェックリスト(配布用)
構造
- [ ] 目的を1文で提示、セクション見出しでグルーピング
- [ ] 全項目に
label
、説明はaria-describedby
で紐付け - [ ] エラーサマリ+項目下エラー、リンクで移動
入力
- [ ] 適切な
type
/autocomplete
/inputmode
- [ ] リアルタイム検証は遅延、
aria-live
で通知 - [ ] ネイティブの日付/時刻を優先
アクセシビリティ
- [ ] 色以外の手掛かり、
aria-invalid
はエラー時のみ - [ ] 送信失敗時はサマリにフォーカス
- [ ]
prefers-reduced-motion
を尊重
セキュリティ
- [ ]
@csrf
、ハニーポット、Rate Limiting - [ ] ファイルはMIME/サイズ検証
- [ ] 同意は
accepted
で検証
UX/運用
- [ ] 下書き保存・再開
- [ ] 二重送信防止
- [ ] 成功時の次アクションを明示
18. まとめ
- FormRequestとBladeコンポーネントで読みやすく再利用可能な基盤を作る。
- ラベル/説明/エラーの関係性を明確にし、エラーサマリとフォーカス移動で迷わない。
autocomplete
/inputmode
/ネイティブ要素でタイピング負担を減らす。- マルチステップは進捗と下書き保存で中断に強い設計に。
- セキュリティとアクセシビリティは両立可能。短い具体的な言葉で導く。
フォームはプロダクトの入り口です。ここに配慮を注ぐことで、離脱が減り、誰にとっても気持ちよく使える体験が育ちます。今日のサンプルを土台に、あなたのチームの標準へ育ててくださいね。
参考リンク
- Laravel 公式
- HTML/フォーム仕様
- アクセシビリティ
- デザイン/ライティング