【現場完全ガイド】Laravelのフォーム設計と入力体験――バリデーション、FormRequest、エラー表示、ファイル、ウィザード、再送信防止、アクセシブルなUIの作り方
この記事で学べること(要点)
- Laravelのバリデーション(FormRequest/ルール/カスタムメッセージ)を“保守しやすく”組む方法
- 入力→確認→完了(ウィザード)や下書き、再送信防止(PRG/二重送信)など実務パターン
- ファイルアップロードの安全設計(MIME/サイズ/ウイルス対策/プレビュー)
- エラー表示の標準化(エラーサマリ、フィールド紐付け、
aria-invalid/aria-describedby) - 入力補助(autocomplete、inputmode、マスクの注意、日付/電話、住所補完)
- アクセシブルなフォーム(ラベル、必須表示、グルーピング、キーボード操作、色非依存)
- テスト(Feature/Dusk)で入力体験を壊さない仕組み
想定読者(だれが得をする?)
- Laravel 初〜中級エンジニア:フォーム実装が増えても破綻しない“型”が欲しい方
- デザイナー/ライター/QA:エラー文言や入力補助を、誰にでも分かる形へ統一したい方
- PM/CS:入力離脱を減らし、問い合わせを減らす導線にしたい方
アクセシビリティレベル:★★★★★
エラーサマリへのフォーカス、
role="alert"、aria-describedby、色に依存しない必須表示、fieldset/legend、入力補助属性、キーボード完遂、読み上げで理解できるエラー文面まで具体例で示します。
1. はじめに:フォームは“品質”が一番表に出る場所です
フォームは、ユーザーが最も能動的に操作する画面です。入力がうまくいかなければ、購入や登録といった最重要行動が止まります。さらに、フォームの不親切さは「わからない」「送れない」「何が悪いの?」という問い合わせにつながります。
LaravelはバリデーションやCSRF、ファイル処理の基盤が整っているので、実装の型さえ決めれば、再利用できる“強いフォーム”をチームで作れます。この記事では、その型をアクセシビリティ重視でまとめます。
2. まず方針:フォームの“標準”を決める
チームで共通化しておくと強い項目は次のとおりです。
- 必須の示し方(テキストで「必須」、色だけにしない)
- エラー表示(サマリ+フィールド紐付け+文言ルール)
- 再送信防止(PRG、二重送信対策)
- 入力補助(autocomplete、inputmode、例示、プレースホルダの使い方)
- ファイル(許可タイプ、サイズ、プレビュー、スキャン、削除)
- 完了後の状態(成功メッセージ、次アクション)
この“標準”を一度決めると、フォーム追加が早くなり、体験が揃います。
3. FormRequestでバリデーションを集約する
フォームのバリデーションは、コントローラに散らすと保守が難しくなります。Laravelでは FormRequest を使って、ルールとメッセージを集約するのが基本です。
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' => 'お名前',
'email' => 'メールアドレス',
'password' => 'パスワード',
'agree' => '利用規約への同意',
];
}
public function messages(): array
{
return [
'agree.accepted' => '利用規約への同意が必要です。',
];
}
}
ポイント
attributes()を使うと、エラー文の対象名が読みやすくなります。confirmedを使うとpassword_confirmationが自動で見られます。
4. コントローラ:PRGで再送信を防ぐ(最重要)
PRG(Post/Redirect/Get)で、送信後は必ずリダイレクトします。
これで更新(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', '登録が完了しました。');
}
5. エラー表示の標準:サマリ+フィールド紐付け
5.1 エラーサマリ(ページ先頭)
- 送信後、サマリにフォーカスして「何が起きたか」を最短で伝えます。
- サマリは
role="alert"が便利ですが、乱用は避け、バリデーション時のみ表示するなど運用ルールを決めます。
@if ($errors->any())
<div id="error-summary" role="alert" tabindex="-1" class="border p-3 mb-4">
<h2 class="font-semibold">入力内容を確認してください。</h2>
<ul class="list-disc pl-5">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
フォーカス移動(例:小さなJS)
<script>
(function(){
const el = document.getElementById('error-summary');
if (el) el.focus();
})();
</script>
5.2 フィールド側(紐付け)
- エラーがある入力には
aria-invalid="true" - エラー文は
idを付け、aria-describedbyで紐付けます。
<label for="email" class="block font-medium">
メールアドレス <span aria-hidden="true">(必須)</span>
<span class="sr-only">必須</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">例:hanako@example.com</p>
@if($errors->has('email'))
<p id="email-error" class="text-sm text-red-700">{{ $errors->first('email') }}</p>
@endif
ポイント
- 「赤文字だけ」ではなく、テキストとして明確に示します。
- 可能ならエラー文は短く、具体的に(例:「メールアドレスの形式で入力してください」)。
6. 入力補助:autocomplete / inputmode / 例示の使い方
6.1 よく使うautocomplete
- 名前:
name - メール:
email - 電話:
tel - 住所:
street-address/postal-code/address-level1など - ワンタイムコード:
one-time-code - パスワード:
new-password/current-password
6.2 inputmode
- 数字:
inputmode="numeric" - 電話:
inputmode="tel" - メール:
type="email"+inputmodeは不要なことが多い
6.3 マスク入力の注意
郵便番号や電話番号を自動でハイフン付けするマスクは便利ですが、
- コピペ時の挙動
- スクリーンリーダーの読み上げ
- 入力中のカーソル移動
に悪影響が出ることがあります。まずは入力例とバリデーションで対応し、マスクは必要最小限に留めるのが安全です。
7. フォームのグルーピング:fieldset/legendで迷子を減らす
住所や支払いなど、まとまりがある入力は fieldset と legend を使うと読み上げが分かりやすくなります。
<fieldset class="border p-3">
<legend class="font-semibold">お届け先</legend>
<label for="zip" class="block mt-2">郵便番号</label>
<input id="zip" name="zip" autocomplete="postal-code" class="border rounded px-2 py-1">
<label for="addr" class="block mt-2">住所</label>
<input id="addr" name="addr" autocomplete="street-address" class="border rounded px-2 py-1 w-full">
</fieldset>
8. ファイルアップロード:安全と体験を両立する
8.1 バリデーション(例:画像)
$request->validate([
'avatar' => ['nullable','file','mimetypes:image/jpeg,image/png','max:2048'],
]);
8.2 体験のポイント
- 許可形式と最大サイズを事前に説明(例:JPEG/PNG、2MBまで)
- アップロード後はファイル名だけでなく、プレビュー(画像の場合)を表示
- 削除ボタンを用意し、誤選択をリカバリできるように
- 本番ではウイルススキャン(非同期)を検討し、結果は通知
アクセシビリティ
- プレビュー画像には
alt(例:「選択したプロフィール画像のプレビュー」) - 進捗や完了は
role="status"で案内 - ドラッグ&ドロップは補助にして、通常のファイル選択で完遂できるようにします
9. ウィザード(入力→確認→完了):セッション/下書きで安心に
複雑な申請や購入は、ステップを分けるとミスが減ります。
ただし、途中で戻ったときの状態保持が重要です。
9.1 典型構成
- Step1 入力 → セッションに保存
- Step2 確認 → 送信で確定(PRG)
- Step3 完了 → 結果表示
// Step1: 保存
session(['wizard' => $request->validated()]);
return redirect()->route('apply.confirm');
// Step2: 確定
$data = session('wizard');
abort_if(!$data, 419);
$application = Application::create($data);
session()->forget('wizard');
return redirect()->route('apply.done');
アクセシビリティ
- ステップの現在地をテキストで明示(例:「ステップ2/3:確認」)
- 進行状況バーは
role="progressbar"で数値も提示すると親切です
10. 二重送信対策:トークンとUIの両面で
10.1 サーバ側(冪等性キー)
重要な作成処理では、二重送信を想定して冪等にします。
例:フォームごとに hidden の UUID を発行し、DBに記録して重複を防ぐ。
10.2 UI側(送信ボタンの無効化)
- 送信直後にボタンを無効化
aria-disabled="true"を付与して状態を明示- ただし、JSが無効でもサーバ側が守れるように
<button id="submit" class="px-4 py-2 border rounded">送信</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 = '送信中です。しばらくお待ちください。';
});
</script>
11. 成功メッセージ:見落とされない設計
成功時は with('status', ...) のフラッシュを使い、ページ上部に表示します。
読み上げには role="status" が相性良いです。
@if(session('status'))
<div role="status" aria-live="polite" class="border p-3 mb-4">
{{ session('status') }}
</div>
@endif
12. テスト:フォームは壊れやすいので守る
12.1 Featureテスト(422にならない)
public function test_register_success()
{
$res = $this->post('/register', [
'name'=>'山田花子',
'email'=>'hanako@example.com',
'password'=>'StrongPassw0rd!',
'password_confirmation'=>'StrongPassw0rd!',
'agree'=>1,
]);
$res->assertRedirect(route('register.done'));
}
12.2 Dusk(エラーサマリにフォーカス)
- 送信→エラー→サマリにフォーカス
aria-invalidが付くaria-describedbyがエラーに切り替わる
これらをE2Eで確認すると、アクセシビリティの回帰を防げます。
13. よくある落とし穴と回避策
- プレースホルダだけでラベルが無い
- 回避:必ず
labelを用意
- 回避:必ず
- エラーが入力の近くに出ない
- 回避:フィールド直下にエラー+
aria-describedby
- 回避:フィールド直下にエラー+
- エラー文が抽象的(「不正です」)
- 回避:何を直すかを短く具体的に
- 赤色だけで必須/エラーを示す
- 回避:テキスト(必須、エラー内容)を併記
- 二重送信が起きる
- 回避:PRG+冪等性キー+ボタン無効化
- ファイル制限が後出し
- 回避:事前に説明+サーバ側で厳格検証
- ウィザードが戻れない
- 回避:セッション/下書き保存で復元可能に
14. チェックリスト(配布用)
バリデーション
- [ ] FormRequestに集約、attributes/messagesで読みやすく
- [ ] ルールは最小かつ明確(上限/形式/必須)
エラー表示
- [ ] エラーサマリ+フィールド紐付け
- [ ]
aria-invalid、aria-describedby - [ ] 色に依存せずテキストで説明
- [ ] サマリにフォーカス
入力補助
- [ ] autocomplete/inputmode/例示
- [ ] マスクは必要最小限
- [ ] fieldset/legendでグルーピング
送信
- [ ] PRGで再送信防止
- [ ] 冪等性キー+UI無効化
- [ ] 成功メッセージは
role="status"
ファイル
- [ ] MIME/サイズ制限、必要ならスキャン
- [ ] 事前説明、プレビュー、削除導線
テスト
- [ ] 成功/失敗のFeatureテスト
- [ ] フォーカス/ARIAのDuskテスト(重要フォーム)
15. まとめ
Laravelのフォームは、FormRequestでバリデーションを集約し、PRGで再送信を防ぎ、エラーサマリとフィールド紐付けで迷子を減らすと、品質が一気に上がります。入力補助属性やfieldset/legendを整え、色に依存しない必須表示と短いエラー文にすると、アクセシビリティも自然に高まります。ファイルやウィザード、二重送信のような“現場で起きる困りごと”も最初から設計に入れて、壊れにくいフォーム体験を標準化していきましょう。
