【実務完全ガイド】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-describedby、aria-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-invalid と aria-describedby で紐付けることで、読み上げでも理解しやすくしています。
5. wire:model の使い分け:全部リアルタイムにしない方が分かりやすいです
Livewire では wire:model で状態を簡単に同期できますが、むやみにリアルタイム更新すると、逆に使いづらくなることがあります。主な選択肢は次のように整理できます。
wire:model- 入力のたびに同期
- 即時反応が必要な検索向き
wire:model.live- より積極的なリアルタイム同期
wire:model.blur- フォーカスが外れたときに同期
- フォームの入力に向いている
wire:model.defer- 送信時までまとめて反映
- 入力項目が多いフォームに向いている
たとえばプロフィール編集のような通常フォームなら、blur や defer の方が自然です。検索ボックスのように即時反映したいものだけ、リアルタイム同期に寄せると、画面の動きが落ち着きます。アクセシビリティの観点でも、入力中に画面が激しく変わると認知負荷が高くなるので、更新タイミングは慎重に選ぶと良いです。
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-invalid と aria-describedby は Livewire でも基本です。
エラーが増えたときに、どの入力のことかがすぐ分かるようにします。
14.3 フォーカスを奪いすぎない
自動更新のたびにフォーカスを動かすと、かえって混乱します。
フォーカス移動は、エラーサマリ表示やモーダル開閉のような「必要なときだけ」に絞ります。
14.4 色だけで状態を示さない
成功、失敗、停止中、処理中などは、色に加えて必ず文字ラベルを付けます。
これらは、管理画面でも一般向け画面でも同じです。Livewire の便利さに引っ張られて、見た目だけの更新にならないようにしたいところです。
15. Volt を使う場合の考え方:小さな画面に向いています
Volt は Livewire コンポーネントを単一ファイルで書けるので、小さなフォームや設定画面にとても相性が良いです。ロジックと Blade が近くにあるため、画面単位で見通しが良くなります。
一方で、責務が大きくなると単一ファイルの良さが減るので、次のように考えると扱いやすいです。
- 小さな設定フォーム、検索ボックス、単純な一覧 → Volt 向き
- 大きな管理画面、複数責務のフォーム、ファイルアップロード+モーダル+一覧更新 → 通常の Livewire クラス分離の方が安全なことが多い
つまり Volt は便利ですが、規模に応じて選ぶのが実務的です。
16. よくある落とし穴と回避策
- 1コンポーネントに何でも詰め込む
- 回避:一覧、フォーム、モーダルなど責務で分割する
wire:modelを全部リアルタイムにする- 回避:フォームは
blurやdeferを基本にし、検索だけ即時更新にする
- 回避:フォームは
- エラー表示がフィールドと結びついていない
- 回避:
aria-invalidとaria-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 コンポーネントを整えてみるのがおすすめです。
