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

【実務ガイド】LaravelでつくるアクセシブルなフォームUX――バリデーション、エラーデザイン、入力支援、段階的保存、マルチステップ

この記事で学べること(要点)

  • LaravelのFormRequestとBladeコンポーネントで、保守しやすいフォーム基盤を構築する方法
  • ラベル、説明、エラーメッセージ、フォーカス移動、ライブリージョンなどアクセシビリティに配慮したUI
  • 入力種別・autocompleteinputmode・リアルタイム検証・マスク・住所/カード入力の実務パターン
  • マルチステップ、進捗表示、下書き保存、ドラフト復旧など業務で効く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 はエラー時のみ。
  • autocompleteinputmode を適切に設定。

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. 入力支援:autocompleteinputmode・マスク

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/ネイティブ要素でタイピング負担を減らす。
  • マルチステップは進捗と下書き保存で中断に強い設計に。
  • セキュリティとアクセシビリティは両立可能。短い具体的な言葉で導く。

フォームはプロダクトの入り口です。ここに配慮を注ぐことで、離脱が減り、誰にとっても気持ちよく使える体験が育ちます。今日のサンプルを土台に、あなたのチームの標準へ育ててくださいね。


参考リンク

投稿者 greeden

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

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