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

【実務完全ガイド】Laravel Livewireで作る動的UI――フォーム、一覧、モーダル、イベント、アップロード、テスト、アクセシブルな画面設計

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

  • Laravel Livewire と Volt の役割、Blade 中心の開発に向いている理由
  • フォーム送信、バリデーション、検索、並び替え、モーダル、イベント連携の実装方針
  • ファイルアップロード、進捗表示、完了通知を安全かつ分かりやすく作る考え方
  • Livewire を大きくしすぎない責務分離と、Action / Service / Eloquent とのつなぎ方
  • 画面更新が多いUIでも、読み上げ・キーボード操作・色非依存を守るアクセシビリティ設計
  • Livewire テストで、フォームや状態遷移の回帰を防ぐ実務パターン

想定読者

  • Laravel 初〜中級エンジニア:JavaScript を増やしすぎずに、動的な管理画面やフォームを作りたい方
  • テックリード:Blade 中心のチームで、Livewire の導入範囲と設計ルールを整えたい方
  • QA / アクセシビリティ担当:エラー表示、状態更新、モーダル操作を継続的に検証したい方
  • PM / CS / 運用担当:入力体験や一覧画面を改善し、問い合わせや操作ミスを減らしたい方

アクセシビリティレベル:★★★★★
Livewire は画面の一部更新がしやすい反面、通知やフォーカスが曖昧だと、何が起きたか分かりにくくなります。本記事では、role="status"role="alert"aria-describedbyaria-invalid、見出し構造、キーボード操作、色に依存しない状態表示を前提に、実務で壊れにくい画面設計を整理します。


1. はじめに:Livewire は「JavaScriptを書かない魔法」ではなく、「Blade中心で動的UIを育てる仕組み」です

Livewire は、Laravel アプリの中で、PHP と Blade を中心に動的でリアクティブなUIを構築できる仕組みです。公式ドキュメントでも、JavaScript フレームワーク中心ではなく、PHP クラスと Blade テンプレートを使って動的UIを作れることが大きな特徴として説明されています。Laravel の Starter Kits でも Livewire ベースの選択肢が用意されており、Blade に慣れたチームが段階的にインタラクティブUIへ進む入口として非常に相性が良いです。さらに Volt は、Livewire コンポーネントを単一ファイルで記述できる機能的なAPIとして提供されており、PHP ロジックと Blade を近い距離で管理しやすくしています。

ただし、Livewire を導入すれば自動で良いUIになるわけではありません。むしろ、フォーム送信、一覧更新、モーダル表示、検索条件の保持、アップロード進捗など、画面の一部だけが動くからこそ、利用者にとって何が起きたのかを明確に伝える必要があります。特にアクセシビリティの観点では、「画面が更新された事実」「エラーがどこにあるか」「次に何をすればよいか」が、視覚だけでなく読み上げでも理解できることが重要です。


2. Livewire を選ぶとよい場面:向いているUI、向いていないUIを分けて考えます

Livewire が特に向いているのは、次のようなUIです。

  • 入力と一覧が同じ画面にあり、送信後に部分更新したい
  • 検索、絞り込み、並び替え、ページングを Blade 中心で作りたい
  • 管理画面や社内ツールのように、複雑すぎない動的UIを安定して保守したい
  • 小さなモーダル、トグル、補助的なステップUIを作りたい
  • JavaScript フレームワークを全面採用するほどではないが、静的HTMLだけでは足りない

反対に、極端に複雑なドラッグ操作、オフライン前提、クライアント側で大量データを保持するようなUIでは、別のフロントエンド技術の方が自然なこともあります。Livewire は「Laravel の中で、必要な分だけ動的にする」道具だと考えると、適切な範囲が見えやすいです。無理に全部を Livewire に寄せるより、管理画面、検索フォーム、設定画面、申請フォームのような“相性の良いところ”から導入する方が成功しやすいです。


3. 基本構成:コンポーネントを細かくしすぎず、役割を明確にします

Livewire コンポーネントは便利ですが、何でも1つに押し込むとすぐに巨大化します。最初に決めておきたいのは、「1コンポーネント1責務」に近づけることです。

たとえば管理画面なら、次のように分けると整理しやすいです。

  • UserTable:一覧表示、検索、並び替え、ページング
  • UserEditForm:ユーザー編集
  • SuspendUserModal:停止確認
  • UploadAvatarForm:ファイルアップロード

このように分けると、どこで何を担当しているかが分かりやすくなります。逆に、「ユーザー管理」全体をひとつの Livewire コンポーネントにしてしまうと、状態が増え、イベントが増え、保守が急に難しくなります。
UI はつながって見えても、コードは責務ごとに分ける方が安全です。


4. まずは基本のフォーム:入力、バリデーション、保存の流れを整えます

Livewire はフォームと相性がとても良いです。ここでは、名前とメールアドレスを更新する簡単なフォームを例にします。

namespace App\Livewire;

use Livewire\Component;
use App\Models\User;

class ProfileForm extends Component
{
    public User $user;

    public string $name = '';
    public string $email = '';

    public function mount(User $user): void
    {
        $this->user = $user;
        $this->name = $user->name;
        $this->email = $user->email;
    }

    protected function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:50'],
            'email' => ['required', 'email', 'max:255'],
        ];
    }

    public function save(): void
    {
        $this->validate();

        $this->user->update([
            'name' => $this->name,
            'email' => $this->email,
        ]);

        session()->flash('status', 'プロフィールを更新しました。');
    }

    public function render()
    {
        return view('livewire.profile-form');
    }
}
<div>
    @if (session()->has('status'))
        <div role="status" aria-live="polite" class="border p-3 mb-4">
            {{ session('status') }}
        </div>
    @endif

    <form wire:submit="save">
        <div class="mb-4">
            <label for="name" class="block font-medium">
                名前 <span aria-hidden="true">(必須)</span><span class="sr-only">必須</span>
            </label>
            <input
                id="name"
                type="text"
                wire:model.blur="name"
                aria-invalid="@error('name') true @else false @enderror"
                aria-describedby="@error('name') name-error @else name-help @enderror"
                class="border rounded px-3 py-2 w-full"
            >
            <p id="name-help" class="text-sm text-gray-600">50文字以内で入力してください。</p>
            @error('name')
                <p id="name-error" class="text-sm text-red-700">{{ $message }}</p>
            @enderror
        </div>

        <div class="mb-4">
            <label for="email" class="block font-medium">
                メールアドレス <span aria-hidden="true">(必須)</span><span class="sr-only">必須</span>
            </label>
            <input
                id="email"
                type="email"
                wire:model.blur="email"
                aria-invalid="@error('email') true @else false @enderror"
                aria-describedby="@error('email') email-error @else email-help @enderror"
                class="border rounded px-3 py-2 w-full"
            >
            <p id="email-help" class="text-sm text-gray-600">例:hanako@example.com</p>
            @error('email')
                <p id="email-error" class="text-sm text-red-700">{{ $message }}</p>
            @enderror
        </div>

        <button type="submit" class="border rounded px-4 py-2">保存する</button>
    </form>
</div>

この例で大切なのは、入力・検証・保存の流れが非常に素直であることです。さらに、エラー表示を aria-invalidaria-describedby で紐付けることで、読み上げでも理解しやすくしています。


5. wire:model の使い分け:全部リアルタイムにしない方が分かりやすいです

Livewire では wire:model で状態を簡単に同期できますが、むやみにリアルタイム更新すると、逆に使いづらくなることがあります。主な選択肢は次のように整理できます。

  • wire:model
    • 入力のたびに同期
    • 即時反応が必要な検索向き
  • wire:model.live
    • より積極的なリアルタイム同期
  • wire:model.blur
    • フォーカスが外れたときに同期
    • フォームの入力に向いている
  • wire:model.defer
    • 送信時までまとめて反映
    • 入力項目が多いフォームに向いている

たとえばプロフィール編集のような通常フォームなら、blurdefer の方が自然です。検索ボックスのように即時反映したいものだけ、リアルタイム同期に寄せると、画面の動きが落ち着きます。アクセシビリティの観点でも、入力中に画面が激しく変わると認知負荷が高くなるので、更新タイミングは慎重に選ぶと良いです。


6. エラーサマリ:Livewire でも「何が起きたか」を先頭で伝えます

バリデーションエラーがあるとき、各フィールドの下にエラーを出すだけでは不十分なことがあります。特に項目が多いフォームでは、先頭にエラーサマリを置き、フォーカスを移した方が分かりやすいです。

@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

Livewire では更新時に DOM が差し替わるため、エラーサマリが出たときにフォーカスをどこへ戻すかが重要になります。実務では、更新後にエラーサマリか最初のエラー入力へフォーカスを戻す方針を決めておくと、操作の迷子が減ります。


7. 一覧と検索:Livewire は管理画面のテーブルと相性が良いです

検索・絞り込み・並び替え・ページングは、Livewire と特に相性が良いパターンです。以下は簡単な一覧コンポーネントの例です。

namespace App\Livewire\Admin;

use Livewire\Component;
use Livewire\WithPagination;
use App\Models\User;

class UserTable extends Component
{
    use WithPagination;

    public string $search = '';
    public string $status = '';
    public string $sort = 'created_at';
    public string $direction = 'desc';

    public function updatingSearch(): void
    {
        $this->resetPage();
    }

    public function sortBy(string $field): void
    {
        if ($this->sort === $field) {
            $this->direction = $this->direction === 'asc' ? 'desc' : 'asc';
        } else {
            $this->sort = $field;
            $this->direction = 'asc';
        }
    }

    public function render()
    {
        $users = User::query()
            ->select(['id', 'name', 'email', 'status', 'created_at'])
            ->when($this->search !== '', function ($q) {
                $q->where(function ($w) {
                    $w->where('name', 'like', "%{$this->search}%")
                      ->orWhere('email', 'like', "%{$this->search}%");
                });
            })
            ->when($this->status !== '', fn ($q) => $q->where('status', $this->status))
            ->orderBy($this->sort, $this->direction)
            ->paginate(20);

        return view('livewire.admin.user-table', compact('users'));
    }
}

検索条件を変更したときに resetPage() するのがポイントです。これが無いと、5ページ目にいる状態で検索して「結果が無い」ように見えることがあります。小さなことですが、運用画面ではとても効きます。


8. 一覧画面のアクセシビリティ:件数、状態、並び替えを文字で伝えます

Livewire で一覧を動的に更新する場合、利用者に「何件見つかったか」「何が変わったか」を伝える必要があります。たとえば以下のように件数を role="status" 付きで出すと、読み上げでも変化が分かりやすくなります。

<div>
    <div role="status" aria-live="polite" class="mb-3 text-sm">
        {{ number_format($users->total()) }}件のユーザーが見つかりました。
    </div>

    <label for="search" class="block font-medium">検索</label>
    <input id="search" type="text" wire:model.live.debounce.300ms="search" class="border rounded px-3 py-2 w-full">

    <table class="w-full mt-4 border-collapse">
        <caption class="sr-only">ユーザー一覧</caption>
        <thead>
            <tr>
                <th scope="col">
                    <button type="button" wire:click="sortBy('name')">名前</button>
                </th>
                <th scope="col">メールアドレス</th>
                <th scope="col">状態</th>
                <th scope="col">
                    <button type="button" wire:click="sortBy('created_at')">登録日</button>
                </th>
            </tr>
        </thead>
        <tbody>
            @foreach($users as $user)
                <tr>
                    <td>{{ $user->name }}</td>
                    <td>{{ $user->email }}</td>
                    <td>{{ $user->status === 'active' ? '有効' : '停止中' }}</td>
                    <td>{{ $user->created_at->format('Y-m-d') }}</td>
                </tr>
            @endforeach
        </tbody>
    </table>

    <div class="mt-4">
        {{ $users->links() }}
    </div>
</div>

ここで大切なのは、状態を色だけで示さず、必ず「有効」「停止中」と文字で出すことです。並び替えボタンも、アイコンだけに頼らず、押す対象が文字で分かるようにします。


9. モーダル:便利ですが、責務を小さくして慎重に使います

Livewire でモーダルを作ると便利ですが、モーダルはアクセシビリティと状態管理の両方で難しい部品です。特に注意したいのは次の点です。

  • 開いた瞬間にモーダル内へフォーカスが移る
  • 閉じたら元のトリガーにフォーカスが戻る
  • Esc で閉じられる
  • モーダル内で何を確認しているかが見出しで分かる
  • 背景コンテンツへ誤って移動しない

Livewire 単体で完結させるより、必要に応じて Alpine.js など軽量な補助を組み合わせる方が現実的です。ただし、何でもモーダルにすると操作が複雑になります。削除確認や一括操作確認のように、意味がはっきりしている場面に絞ると安全です。


10. Livewire イベント:コンポーネント間通信は増やしすぎない方が読みやすいです

Livewire では、コンポーネント同士でイベントを送受信できます。便利ですが、使いすぎると「どこから何が飛んできているか」が分かりにくくなります。
おすすめは、次のような用途に絞ることです。

  • モーダルを閉じたあとに一覧を再読み込みしたい
  • 子コンポーネントの更新結果を親へ伝えたい
  • 完了通知だけを画面上部に出したい

たとえばユーザー編集後に一覧更新を通知するなら、次のような形です。

$this->dispatch('user-updated');

親コンポーネント側でそれを受けて再描画します。
ただし、複雑な業務ロジックまでコンポーネントイベントに頼ると、責務がUI層へ寄りすぎます。業務処理は Action / Service に置き、Livewire イベントは「画面の反応」に限定すると分かりやすいです。


11. ファイルアップロード:進捗と完了通知を丁寧に見せます

Livewire はファイルアップロードとも相性が良いですが、利用者にとっては「今どうなっているか」が分からないと不安になりやすい部分です。ですので、次の3段階を明確にします。

  • 選択した
  • 送信中
  • 完了 / 失敗

Livewire ではアップロード進捗イベントや temporary file を扱えます。画面側では、進捗バーだけでなく、テキストでも状態を伝えると安心です。

<div>
    <label for="avatar" class="block font-medium">プロフィール画像</label>
    <input id="avatar" type="file" wire:model="avatar">

    <div wire:loading wire:target="avatar" role="status" aria-live="polite" class="mt-2 text-sm">
        アップロード中です。
    </div>

    @error('avatar')
        <p role="alert" class="text-sm text-red-700">{{ $message }}</p>
    @enderror
</div>

このように、進行中は role="status"、失敗は role="alert" を使い分けると、読み上げでも自然に伝わります。画像プレビューを出す場合も、代替テキストや説明文を忘れない方がよいです。


12. Livewire を大きくしすぎない:業務処理は Service / Action に逃がします

Livewire コンポーネントは便利なので、つい保存処理、通知、ログ、外部API連携まで全部中へ書きたくなります。ですが、それをやるとコンポーネントがすぐ巨大になります。おすすめは、Livewire は次の責務に寄せることです。

  • 状態を持つ
  • 入力を受ける
  • バリデーションする
  • Action / Service を呼ぶ
  • 結果を画面へ返す

たとえば注文作成なら、実際の注文生成処理は CreateOrderAction に任せた方が自然です。

public function save(CreateOrderAction $action): void
{
    $this->validate();

    $order = $action->execute(auth()->user(), [
        'items' => $this->items,
        'note' => $this->note,
    ]);

    session()->flash('status', '注文を受け付けました。');

    $this->redirectRoute('orders.show', $order);
}

こうしておくと、Livewire は画面ロジックに集中し、業務処理は再利用しやすくなります。テストでも、UIのテストと業務処理のテストを分けやすくなります。


13. テスト:Livewire は状態遷移を直接検証しやすいです

Livewire の大きな利点のひとつが、コンポーネント単位で状態をテストしやすいことです。Laravel 公式でも Livewire コンポーネントは PHPUnit / Pest と組み合わせてテストできるようになっており、入力・バリデーション・イベント・リダイレクトなどを細かく確認できます。

13.1 フォーム保存のテスト

use Livewire\Livewire;
use App\Livewire\ProfileForm;
use App\Models\User;

public function test_profile_can_be_updated()
{
    $user = User::factory()->create([
        'name' => '旧名前',
        'email' => 'old@example.com',
    ]);

    Livewire::test(ProfileForm::class, ['user' => $user])
        ->set('name', '新しい名前')
        ->set('email', 'new@example.com')
        ->call('save')
        ->assertHasNoErrors();

    $this->assertDatabaseHas('users', [
        'id' => $user->id,
        'name' => '新しい名前',
        'email' => 'new@example.com',
    ]);
}

13.2 バリデーションのテスト

public function test_profile_validation_error_is_returned()
{
    $user = User::factory()->create();

    Livewire::test(ProfileForm::class, ['user' => $user])
        ->set('name', '')
        ->set('email', 'not-email')
        ->call('save')
        ->assertHasErrors(['name', 'email']);
}

このように、画面遷移を伴わずにフォーム挙動を直接テストできるのは Livewire の大きな強みです。


14. アクセシビリティで特に注意したい点:動的UIは「変化の伝え方」が核心です

Livewire で作る画面は、部分更新が多いため、通常の Blade より次の点に注意した方が安全です。

14.1 何が更新されたかを伝える

検索結果件数や保存完了は、短い文言で role="status" に流すと分かりやすいです。

14.2 エラーを必ず入力と結びつける

aria-invalidaria-describedby は Livewire でも基本です。
エラーが増えたときに、どの入力のことかがすぐ分かるようにします。

14.3 フォーカスを奪いすぎない

自動更新のたびにフォーカスを動かすと、かえって混乱します。
フォーカス移動は、エラーサマリ表示やモーダル開閉のような「必要なときだけ」に絞ります。

14.4 色だけで状態を示さない

成功、失敗、停止中、処理中などは、色に加えて必ず文字ラベルを付けます。

これらは、管理画面でも一般向け画面でも同じです。Livewire の便利さに引っ張られて、見た目だけの更新にならないようにしたいところです。


15. Volt を使う場合の考え方:小さな画面に向いています

Volt は Livewire コンポーネントを単一ファイルで書けるので、小さなフォームや設定画面にとても相性が良いです。ロジックと Blade が近くにあるため、画面単位で見通しが良くなります。
一方で、責務が大きくなると単一ファイルの良さが減るので、次のように考えると扱いやすいです。

  • 小さな設定フォーム、検索ボックス、単純な一覧 → Volt 向き
  • 大きな管理画面、複数責務のフォーム、ファイルアップロード+モーダル+一覧更新 → 通常の Livewire クラス分離の方が安全なことが多い

つまり Volt は便利ですが、規模に応じて選ぶのが実務的です。


16. よくある落とし穴と回避策

  • 1コンポーネントに何でも詰め込む
    • 回避:一覧、フォーム、モーダルなど責務で分割する
  • wire:model を全部リアルタイムにする
    • 回避:フォームは blurdefer を基本にし、検索だけ即時更新にする
  • エラー表示がフィールドと結びついていない
    • 回避:aria-invalidaria-describedby をセットにする
  • 完了通知が見た目だけで、読み上げでは分からない
    • 回避:role="status"role="alert" を明示する
  • モーダルを乱用する
    • 回避:意味が明確な確認画面だけに絞り、専用画面の方が安全な場面を見極める
  • 業務ロジックを Livewire に書きすぎる
    • 回避:Action / Service へ分離し、コンポーネントは画面責務に集中させる

17. チェックリスト(配布用)

設計

  • [ ] Livewire コンポーネントの責務が明確
  • [ ] 業務処理を Action / Service に分離している
  • [ ] 一覧、フォーム、モーダルを必要に応じて分けている

入力・エラー

  • [ ] バリデーションルールが整理されている
  • [ ] aria-invalid / aria-describedby がある
  • [ ] エラーサマリが必要なフォームで用意されている

状態更新

  • [ ] 完了通知が role="status" で伝わる
  • [ ] 重要な失敗が role="alert" で伝わる
  • [ ] 件数や進捗が文字でも分かる
  • [ ] 色だけに依存しない状態表示

UI操作

  • [ ] キーボードだけで主要操作が完了できる
  • [ ] モーダル開閉時のフォーカスが設計されている
  • [ ] 一覧の並び替えや検索条件が分かりやすい

テスト

  • [ ] 保存処理の Livewire テストがある
  • [ ] バリデーションエラーのテストがある
  • [ ] 重要な状態遷移のテストがある

18. まとめ

Laravel Livewire は、Blade 中心のまま動的UIを作れる、とても実務的な選択肢です。特にフォーム、一覧、検索、モーダル、管理画面のような“中規模でよく使うUI”に強く、Laravel の世界観の中で自然に育てていけます。ただし、便利だからこそ、状態更新の伝え方、フォーカス、エラー表示、責務分離を丁寧に設計しないと、分かりにくい画面になりやすいです。
大切なのは、Livewire を魔法として使うのではなく、Action / Service / Blade / Eloquent と役割を分けながら、利用者にとって意味のある変化を丁寧に見せることです。まずは1つのフォームか1つの一覧から、アクセシビリティを意識した Livewire コンポーネントを整えてみるのがおすすめです。


参考リンク

投稿者 greeden

コメントを残す

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

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