サイトアイコン IT & ライフハックブログ|学びと実践のためのアイデア集

Laravelフォームバリデーション実務ガイド:FormRequestとアクセシブルなエラー表示

php elephant sticker

Photo by RealToughCandy.com on Pexels.com

Laravelのフォームバリデーションは、入力値を拒否するだけの仕組みではありません。

サーバー側で受け入れる条件と、利用者が入力を直すための案内を一つの設計として扱うことで、安全性と使いやすさを両立できます。

この記事では、登録フォームを例に、FormRequestでのルール管理、Bladeコンポーネント、エラー概要、フォーカス移動、非同期送信、多言語化、テストまでを順に整理します。

この記事で扱う実装

フォームバリデーションを三つの層で考える

フォームバリデーションとは、送信された値がアプリケーションの受け入れ条件を満たすか確認する処理です。

ブラウザ側の検証は早いフィードバックに役立ちますが、利用者や通信経路から届く値を最終的に判断するのはサーバー側です。

層 役割 実装の中心
入力前の案内 必須項目や形式を先に伝える ラベル、補足文、選択肢
検証 受け入れ条件を判定する FormRequest、HTMLの属性
修正支援 誤りの場所と直し方を伝える エラー概要、項目別メッセージ、フォーカス

三つの層を分けて考えると、「ルールは正しいが直し方が分からない」「画面には赤字が出るが読み上げでは気付けない」といった欠落を見つけやすくなります。

入力支援とエラー設計の全体像は、関連記事「フォームはなぜ使いにくくなるのか」でも確認できます。

FormRequestに検証ルールを集約する

FormRequestは、検証ルールと認可処理を専用クラスにまとめるLaravelの仕組みです。

コントローラーから入力条件を切り離せるため、ルール、文言、前処理の所在が明確になります。

リクエストクラスを作成する

php artisan make:request RegisterUserRequest

次の例では、登録フォームに必要な項目だけを定義します。

<?php

namespace App\Http\Requests;

use App\Models\User;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class RegisterUserRequest extends FormRequest
{
    public function authorize(): bool
    {
        // 認可を別の層で処理している登録画面の例
        return true;
    }

    public function rules(): array
    {
        return [
            'name' => ['bail', 'required', 'string', 'max:50'],
            'email' => [
                'bail',
                'required',
                'email',
                'max:255',
                Rule::unique(User::class, 'email'),
            ],
            'password' => ['bail', 'required', 'string', 'min:8', 'confirmed'],
            'agree' => ['accepted'],
            'phone' => ['nullable', 'string', 'max:20'],
        ];
    }

    public function attributes(): array
    {
        return [
            'name' => 'お名前',
            'email' => 'メールアドレス',
            'password' => 'パスワード',
            'agree' => '利用規約',
            'phone' => '電話番号',
        ];
    }

    public function messages(): array
    {
        return [
            'email.unique' => ':attributeは使用できません。',
            'password.confirmed' => ':attributeと確認欄が一致しません。',
            'agree.accepted' => ':attributeへの同意が必要です。',
        ];
    }

    protected function prepareForValidation(): void
    {
        $name = $this->input('name');
        $email = $this->input('email');

        $this->merge([
            'name' => is_string($name) ? trim($name) : $name,
            'email' => is_string($email) ? trim($email) : $email,
        ]);
    }
}

パスワードの長さや文字種はサービスの方針に合わせて決め、画面の案内とルールを一致させます。

たとえば画面に「英数字を含む」と表示するなら、長さだけでなく、その条件もサーバー側のルールで検証する必要があります。

保存には検証済みデータを使う

FormRequestはコントローラーが動く前に検証されますが、リクエストオブジェクトから未検証の入力が消えるわけではありません。

保存処理にはvalidated()またはsafe()から取り出した値を使います。

use App\Http\Requests\RegisterUserRequest;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Hash;

public function store(RegisterUserRequest $request): RedirectResponse
{
    $data = $request->safe()->only([
        'name',
        'email',
        'password',
        'phone',
    ]);

    $data['password'] = Hash::make($data['password']);

    $user = User::create($data);
    auth()->login($user);

    return redirect()
        ->route('dashboard')
        ->with('status', '登録が完了しました。');
}

この例は、モデル側の代入許可設定が済んでいることを前提にしています。

safe()->only()で保存対象を明示すると、フォームに項目が追加されたときも、保存する値をコードレビューで確認できます。

Bladeコンポーネントでラベルとエラーを関連付ける

同じ入力部品を画面ごとに手書きすると、id、ラベル、補足、エラーの関連付けが崩れやすくなります。

次のコンポーネントは、補足文とエラー文のIDをaria-describedbyへ渡し、エラー時だけaria-invalid="true"を設定します。

{{-- resources/views/components/form/input.blade.php --}}
@props([
    'id',
    'label',
    'type' => 'text',
    'name' => null,
    'help' => null,
    'required' => false,
    'autocomplete' => null,
])

@php
    $name = $name ?? $id;
    $error = $errors->first($name);
    $describedBy = collect([
        $help ? $id . '-help' : null,
        $error ? $id . '-error' : null,
    ])->filter()->implode(' ');
@endphp

<div class="form-field">
    <label for="{{ $id }}">
        {{ $label }}
        @if($required)
            <span>(必須)</span>
        @endif
    </label>

    <input
        id="{{ $id }}"
        name="{{ $name }}"
        type="{{ $type }}"
        value="{{ $type === 'password' ? '' : old($name) }}"
        @if($required) required @endif
        @if($autocomplete) autocomplete="{{ $autocomplete }}" @endif
        @if($describedBy !== '') aria-describedby="{{ $describedBy }}" @endif
        @if($error) aria-invalid="true" @endif
        class="form-input {{ $error ? 'is-invalid' : '' }}"
    >

    @if($help)
        <p id="{{ $id }}-help">{{ $help }}</p>
    @endif

    @if($error)
        <p id="{{ $id }}-error">{{ $error }}</p>
    @endif
</div>

必須であることは、色や記号だけに頼らず「必須」という文字でも示します。

ネイティブHTMLのrequiredを使う場合、同じ意味のaria-requiredを重ねる必要はありません。

赤い枠は視覚的な補助として使えますが、エラーの内容は必ず文字で提示します。

入力部品を呼び出す

<x-form.input
    id="name"
    label="お名前"
    required
    autocomplete="name"
/>

<x-form.input
    id="email"
    type="email"
    label="メールアドレス"
    help="登録後の連絡を受け取れるアドレスを入力してください。"
    required
    autocomplete="email"
/>

<x-form.input
    id="password"
    type="password"
    label="パスワード"
    help="8文字以上で入力してください。"
    required
    autocomplete="new-password"
/>

autocompleteは入力の目的に合う値を選びます。

入力条件はプレースホルダーだけに置かず、入力中も読める補足文として残します。

エラー概要とフォーカスで修正箇所へ案内する

項目の直下にエラーがあっても、長いフォームでは送信に失敗したこと自体へ気付けない場合があります。

フォームの先頭にエラー概要を置き、各メッセージから該当項目へ移動できるようにします。

@if($errors->any())
    <section
        id="form-errors"
        data-error-summary
        tabindex="-1"
        role="alert"
        aria-labelledby="form-errors-title"
    >
        <h2 id="form-errors-title">
            入力内容を確認してください
        </h2>

        <ul>
            @foreach($errors->keys() as $field)
                <li>
                    <a href="#{{ $field }}">
                        {{ $errors->first($field) }}
                    </a>
                </li>
            @endforeach
        </ul>
    </section>
@endif

この単純な例では入力名とHTMLのIDを同じにしています。

配列形式やドット記法の項目名を使う場合は、エラーキーとHTMLのIDを対応付ける変換を用意します。

画面遷移後は、概要を表示するだけでなく、キーボードの現在位置も移します。

document.addEventListener('DOMContentLoaded', () => {
    document
        .querySelector('[data-error-summary]')
        ?.focus();
});

tabindex="-1"は、概要を通常のTab移動には加えず、スクリプトからフォーカスできるようにする指定です。

項目ごとのエラーすべてを強いライブ通知にすると読み上げが重なるため、概要で全体を通知し、各項目のエラーはaria-describedbyで確認できる構成にします。

直し方が分かるエラーメッセージを書く

エラー文は「何が違うか」と「どう直すか」を、入力条件の範囲で具体的に書きます。

避けたい文 改善例 改善する理由
入力が不正です メールアドレスを「name@example.com」の形式で入力してください。 対象と形式が分かる
必須です お名前を入力してください。 項目名が分かる
短すぎます パスワードを8文字以上で入力してください。 修正条件が分かる

文字数や形式を案内するときは、実際のFormRequestのルールと一致させます。

システム内部のルール名やデータベース名を、そのまま利用者向けの文言へ出さないようにします。

多言語化では共通文と画面固有文を分ける

検証メッセージの共通文は言語ファイルへ置き、画面固有の文だけをFormRequestのmessages()で上書きします。

現在のLaravelで言語ファイルがない場合は、Artisanのlang:publishで公開してから、lang/ja/validation.phpなどを編集します。

// lang/ja/validation.php
return [
    'required' => ':attributeを入力してください。',
    'email' => ':attributeを正しい形式で入力してください。',
    'attributes' => [
        'name' => 'お名前',
        'email' => 'メールアドレス',
        'password' => 'パスワード',
    ],
];

言語切替、URL、日付や通貨まで含む設計は「Laravelの多言語化とローカライゼーション」で整理しています。

非同期検証でも通知の順序を保つ

JavaScriptで送信するフォームでも、サーバー側のFormRequestを最終判定に使います。

入力内容が受け入れられなかった場合は、LaravelのJSONエラーレスポンスを受け取り、次の順で画面を更新します。

  1. 前回のエラー表示とaria-invalidを取り除く
  2. 項目別メッセージを表示し、入力とaria-describedbyで関連付ける
  3. エラー概要へ同じ内容をまとめる
  4. エラー概要を通知してフォーカスを移す
  5. 修正後の再送信でもサーバー側で再検証する

入力中に一文字ごと検証すると、完成前の値へ繰り返しエラーが出て負担になります。

形式確認はフォーカスが外れたとき、重複確認など通信が必要な判定は入力が落ち着いたときに行い、送信時にはすべてを再確認します。

role="alert"とaria-liveの使い分けは「ステータスメッセージの実装方法」も参考になります。

実務で迷いやすいフォームの扱い

場面 実装方針 確認点
マルチステップ 各段階の項目をサーバー側で検証し、完了済みの値を安全に保持する 現在位置、戻る操作、段階をまたぐ整合性
ファイルアップロード ファイル種別と容量の条件を画面に示し、サーバー側でも検証する 選択済みファイル名、失敗理由、再選択の方法
重複確認 入力中の確認は補助とし、送信時にRule::uniqueで再検証する 利用できない場合の代替案、通信失敗時の扱い
チェックボックス群 グループ名をfieldsetとlegendで示す グループ全体のエラーと個別選択肢の関係
日付や電話番号 受け入れる形式を入力前に示す 表記例とサーバー側ルールの一致

どの場面でも、クライアント側の確認だけを通過条件にしません。

画面の補助機能が動かない場合でも、サーバー側が同じ条件で判定し、理解できるエラーを返す構成を保ちます。

公開前のアクセシビリティ確認

自動テストと手動確認を組み合わせる

FormRequestのルールはFeatureテストで、必須、形式、境界値、確認欄、任意項目を確認します。

HTMLについては、ラベルとID、aria-describedby、aria-invalid、エラー概要へのリンクを検査します。

自動テストだけでは、読み上げる順序やフォーカス移動の自然さまでは判断できません。

公開前にキーボードだけで入力と修正を行い、主要なブラウザーとスクリーンリーダーの組み合わせでも代表的なエラーを確認します。

入力条件と修正導線を一つの仕様にする

Laravel側のルール、画面上の案内、エラーメッセージが同じ条件を示しているかを最初に確認します。

そのうえで、エラーの存在、該当項目、修正方法を文字で伝え、キーボードと読み上げの利用者が迷わず修正箇所へ移動できるようにします。

FormRequestとBladeコンポーネントを共通化しておけば、新しいフォームでも同じ確認基準を適用しやすくなります。

この記事に関連する株式会社greedenの取り組み

フォームの使いやすさは、入力規則だけでなく、エラーの伝え方やフォーカス移動まで含めて検証してこそ高まります。株式会社greedenは、Webアクセシビリティチェックを通じ、利用者が迷いにくいフォーム改善の確認を支援しています。

モバイルバージョンを終了