【完全版】Laravel 通知(Notifications)× リアルタイム配信(Broadcasting)――アクセシブルなトースト/アラート設計と実装ガイド
この記事で学べること(先に要点)
- Laravel Notifications(Mail/Database/Broadcast/Slack 等)の基本と、キュー連携による安定運用
- Event/Listener/Broadcast(公開・プライベート・プレゼンス)でリアルタイム更新を実現する設計
- アクセシビリティに配慮したトースト/アラート/通知ドロワーの UX パターン(
aria-live
/role="status|alert"
/フォーカス管理) - 既読管理・国際化・時刻表示(Carbon)・セキュリティ(チャネル認可・機微情報の扱い)・負荷対策
- Feature/E2E テスト観点と、トラブル時の**フォールバック(プログレッシブエンハンスメント)**設計
想定読者(だれが得をする?)
- Laravel 初〜中級エンジニア:メールやアプリ内通知を今すぐ導入し、将来のリアルタイム化に備えたい方
- SaaS/業務システムのテックリード:通知基盤の「設計・運用・監視」をチームに標準化したい方
- CS/企画/プロダクトオーナー:ユーザーが見逃さない・驚かない・すぐ対応できる通知体験を整えたい方
- アクセシビリティ担当/QA:トーストやバナーが読み上げられるか、キーボード操作でストレスなく扱えるかを保証したい方
アクセシビリティレベル:★★★★★
aria-live
の運用、role="status|alert"
の使い分け、フォーカス移動・閉じるボタン・時間制御、色に依存しない状態表現、動きの軽減(prefers-reduced-motion
)まで実装レベルで提示します。
1. はじめに:通知は「情報」と「安心」のインターフェース
通知はただ届けるだけでは不十分です。
- 見逃さない(適切なタイミング・視認性・読み上げ)
- 驚かせない(優先度に応じた静かさ・アニメーションの節度)
- すぐ動ける(主操作へのショートカット・フォーカス移動・キーボード操作)
Laravel には Notifications と Broadcasting が標準搭載されており、段階的導入が得意です。まずはメール/アプリ内通知(Database)から始め、必要に応じてリアルタイム配信(Broadcast)へ拡張しましょう♡
2. Notifications の基本:チャネルとデータモデル
2.1 テーブル準備(Database チャネル)
php artisan notifications:table
php artisan migrate
notifications
テーブルに通知が JSON で保存され、既読管理(read_at
)が簡単に行えます。
2.2 通知クラスの作成
php artisan make:notification CommentPosted
// app/Notifications/CommentPosted.php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Messages\BroadcastMessage;
class CommentPosted extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(public readonly array $payload) {}
public function via($notifiable): array
{
// 必要なチャネルを選択(段階導入OK)
return ['database', 'broadcast', 'mail'];
}
public function toArray($notifiable): array
{
return [
'type' => 'comment.posted',
'title' => '新しいコメント',
'message' => $this->payload['excerpt'] ?? '新着コメントがあります。',
'comment_id' => $this->payload['comment_id'] ?? null,
'url' => $this->payload['url'] ?? url('/'),
];
}
public function toBroadcast($notifiable): BroadcastMessage
{
return new BroadcastMessage($this->toArray($notifiable));
}
public function toMail($notifiable): MailMessage
{
$data = $this->toArray($notifiable);
return (new MailMessage)
->subject($data['title'])
->line($data['message'])
->action('コメントを見る', $data['url'])
->line('このメールに心当たりがない場合は破棄してください。');
}
}
ShouldQueue
を実装すると自動でキュー投入され、HTTP レスポンスが軽くなります。- Broadcast を併用すると、到着と同時に UI 更新できます(後述)。
2.3 送信側(たとえば新規コメント時)
use App\Models\User;
use App\Notifications\CommentPosted;
$user->notify(new CommentPosted([
'excerpt' => $comment->excerpt(80),
'comment_id' => $comment->id,
'url' => route('comments.show', $comment),
]));
Notifiable
トレイトを持つモデル(典型はUser
)ならnotify()
で送信可能。
3. アプリ内通知 UI:アクセシブルな「通知ドロワー」を作る
3.1 一覧取得(未読・既読)
// app/Http/Controllers/NotificationController.php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class NotificationController
{
public function index(Request $request)
{
$user = $request->user();
// ページ表示用に最近の通知を取得
$notifications = $user->notifications()->latest()->paginate(20);
return view('notifications.index', compact('notifications'));
}
public function markAsRead(Request $request, string $id)
{
$notification = $request->user()->notifications()->findOrFail($id);
$notification->markAsRead();
// アクセシブルなフィードバック(JSON 例)
return response()->json(['ok' => true, 'read_at' => now()->toISOString()]);
}
}
3.2 Blade(通知ドロワーの骨組み)
{{-- resources/views/notifications/index.blade.php --}}
@extends('layouts.app')
@section('title', '通知')
@section('content')
<h1 class="text-2xl font-semibold mb-4" id="page-title" tabindex="-1">通知</h1>
{{-- ライブリージョン:一覧更新・既読化の結果を読み上げ --}}
<div id="a11y-live" class="sr-only" aria-live="polite"></div>
<ul class="divide-y border rounded" role="list">
@forelse ($notifications as $n)
@php
$data = $n->data;
$isUnread = is_null($n->read_at);
@endphp
<li class="p-4 flex items-start gap-3 {{ $isUnread ? 'bg-blue-50' : '' }}">
<div aria-hidden="true">
{{-- 色だけに頼らない:未読には点アイコン+テキストも補助 --}}
@if($isUnread)
<span class="inline-block w-2 h-2 rounded-full bg-blue-600"></span>
@else
<span class="inline-block w-2 h-2 rounded-full bg-gray-400"></span>
@endif
</div>
<div class="grow">
<h2 class="font-medium">{{ $data['title'] ?? 'お知らせ' }}</h2>
<p class="text-sm text-gray-700">{{ $data['message'] ?? '' }}</p>
<p class="text-xs text-gray-500 mt-1">
{{ \Carbon\Carbon::parse($n->created_at)->diffForHumans() }}
</p>
<div class="mt-2 flex gap-3">
@if(!empty($data['url']))
<a class="underline" href="{{ $data['url'] }}">詳細を見る</a>
@endif
@if($isUnread)
<button type="button"
class="underline"
data-id="{{ $n->id }}"
data-action="mark-read">
既読にする
</button>
@endif
</div>
</div>
{{-- スクリーンリーダー向けステータス補足 --}}
<span class="sr-only">
{{ $isUnread ? '未読' : '既読' }}
</span>
</li>
@empty
<li class="p-4">現在、表示できる通知はありません。</li>
@endforelse
</ul>
<div class="mt-4">
{{ $notifications->links() }}
</div>
<script>
document.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-action="mark-read"]');
if (!btn) return;
const id = btn.getAttribute('data-id');
const res = await fetch(`/notifications/${id}/read`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
}
});
if (res.ok) {
const live = document.getElementById('a11y-live');
live.textContent = '通知を既読にしました。';
// その場で見た目を更新(厳密には再描画や部分差し替えでもOK)
btn.remove();
}
});
</script>
@endsection
アクセシビリティの勘所
- 未読の強調は「色 + 形(点アイコン)」で二重表現。
- 動作結果は
aria-live="polite"
へ文言を挿入して読み上げ。 - キーボード操作で全機能に到達できるよう、リンク/ボタンで実装。
- 時間表示は Carbon の
diffForHumans()
を使い自然言語で。
4. トースト/アラートコンポーネント:設計とサンプル
4.1 役割の整理
- トースト:低〜中優先度の完了・情報通知(自動的に消えてもよい)。
role="status"
+aria-live="polite"
。 - アラート:高優先度のエラー・重大通知(自動で消さない)。
role="alert"
+aria-live="assertive"
。 - どちらも閉じるボタンとキーボードフォーカスを提供し、音や動きに依存しない。
4.2 Blade コンポーネント例(トースト)
{{-- resources/views/components/toast.blade.php --}}
@props([
'type' => 'info', // info|success|warning|error
'message' => '',
'autoHide' => true,
'timeout' => 6000,
])
@php
$role = $type === 'error' ? 'alert' : 'status';
$live = $type === 'error' ? 'assertive' : 'polite';
@endphp
<div x-data="{ open: true }"
x-show="open"
x-init="
$nextTick(() => {
// フォーカスをトーストに移して読み上げ(任意)
$el.focus();
if (@js($autoHide)) setTimeout(() => { open = false }, @js($timeout));
})
"
x-transition
@keydown.escape.window="open=false"
role="{{ $role }}"
aria-live="{{ $live }}"
tabindex="-1"
class="fixed bottom-4 left-1/2 -translate-x-1/2 max-w-md w-[calc(100%-2rem)] rounded shadow-lg p-4
{{ $type === 'success' ? 'bg-green-50 text-green-900' : '' }}
{{ $type === 'info' ? 'bg-blue-50 text-blue-900' : '' }}
{{ $type === 'warning' ? 'bg-yellow-50 text-yellow-900': '' }}
{{ $type === 'error' ? 'bg-red-50 text-red-900' : '' }}"
style="outline: 2px solid transparent; outline-offset: 2px;"
>
<div class="flex items-start gap-3">
<div aria-hidden="true" class="pt-1">
{{-- アイコンは形で区別 --}}
@if($type === 'success') ✅ @elseif($type === 'warning') ⚠️ @elseif($type === 'error') ❌ @else ℹ️ @endif
</div>
<div class="grow">
<p>{{ $message }}</p>
</div>
<button type="button" class="underline ml-2" @click="open=false" aria-label="通知を閉じる">
閉じる
</button>
</div>
</div>
<style>
@media (prefers-reduced-motion: reduce) {
[x-transition] { transition: none !important; }
}
</style>
role
/aria-live
を内容の優先度に合わせて切替。- 初期フォーカスで読み上げを促進(自動ではなく文脈に応じて使用)。
prefers-reduced-motion
を尊重して動きの負担を軽減。
5. リアルタイム配信:Event × Broadcast × Private Channel
5.1 イベントの作成とブロードキャスト
php artisan make:event CommentCreated
// app/Events/CommentCreated.php
namespace App\Events;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Queue\SerializesModels;
class CommentCreated implements ShouldBroadcast
{
use SerializesModels;
public function __construct(public readonly int $receiverUserId, public readonly array $payload) {}
public function broadcastOn(): array
{
// 個人向けのプライベートチャネル
return [new PrivateChannel("users.{$this->receiverUserId}")];
}
public function broadcastAs(): string
{
return 'CommentCreated';
}
public function broadcastWith(): array
{
// フロントへ渡す最小限のデータ(機微情報は含めない)
return [
'type' => 'comment.posted',
'title' => '新しいコメント',
'message' => $this->payload['excerpt'] ?? '',
'url' => $this->payload['url'] ?? url('/'),
];
}
}
5.2 チャネルの認可
// routes/channels.php
use Illuminate\Support\Facades\Broadcast;
Broadcast::channel('users.{id}', function ($user, $id) {
return (int)$user->id === (int)$id; // 本人のみ購読可
});
5.3 送出(コメント作成時)
event(new \App\Events\CommentCreated($receiverId, [
'excerpt' => $comment->excerpt(80),
'url' => route('comments.show', $comment),
]));
5.4 フロント:Echo で受信してトースト表示(概念例)
<div id="rt-live" class="sr-only" aria-live="polite"></div>
<script type="module">
// ここで Echo を任意のブロードキャスタと接続(設定は環境に合わせる)
// import Echo from 'laravel-echo'; window.Echo = new Echo({...});
const userId = '{{ auth()->id() }}';
window.Echo.private(`users.${userId}`)
.listen('.CommentCreated', (e) => {
// アクセシブルな通知:トースト + ライブリージョン
const msg = e.message || '新しい通知があります。';
const region = document.getElementById('rt-live');
region.textContent = msg;
// Blade コンポーネントを使った描画(ここでは簡略)
const toast = document.createElement('div');
toast.innerHTML = `{!! str_replace("\n", '', (string) view('components.toast', ['type'=>'info','message'=>'新しいコメントが届きました'])) !!}`;
document.body.appendChild(toast.firstElementChild);
});
</script>
設計のポイント
- Private/Presetnce チャネルでユーザーごとに分離。
- 最小限のペイロードを送る(個人情報・内部ID・生 HTML をむやみに送らない)。
- プッシュ不可環境ではフォールバック(後述:ポーリング)を用意。
6. フォールバック設計:ポーリングで確実に届くように
リアルタイム接続が失敗・遮断される場合に備え、数十秒おきの軽量ポーリングを併用します。
// routes/api.php
use Illuminate\Http\Request;
Route::middleware('auth:sanctum')->get('/me/notifications/unread-count', function (Request $request) {
return ['count' => $request->user()->unreadNotifications()->count()];
});
// 30 秒ごとに未読数を確認(失敗時は指数的バックオフなど工夫を)
const badge = document.getElementById('notif-badge');
async function poll() {
try {
const res = await fetch('/api/me/notifications/unread-count', { headers: { 'Accept': 'application/json' }});
if (res.ok) {
const { count } = await res.json();
badge.textContent = count > 0 ? String(count) : '';
document.getElementById('a11y-live')?.textContent =
count > 0 ? `未読の通知が ${count} 件あります。` : '未読の通知はありません。';
}
} finally {
setTimeout(poll, 30000);
}
}
poll();
ポイント
- プッシュがなくても使える状態を維持(プログレッシブエンハンスメント)。
- ライブリージョンで件数の変化を読み上げ、視認できないユーザーにも伝達。
7. 既読管理と「後で読む」:体験をやさしく
- 既読:クリック時に
read_at
を即時更新し、UI とライブリージョンでフィードバック。 - 一括既読:フォーカスが遠くならないよう上部にもボタン配置。
- 後で読む:フラグ(
saved_for_later
)を追加し、重要通知の見失い防止に。 - フィルタ:未読/既読/保存済みタブを**ボタン+
aria-controls
**で切替(キーボード操作可)。
8. 国際化と時刻表示:伝わるメッセージへ
- 通知タイトル・本文は翻訳辞書(配列/JSON)で管理し、
trans_choice
で件数に応じた文を表示。 - 日付は Carbon の
isoFormat('LLL')
やdiffForHumans()
を使用し、ロケールをCarbon::setLocale(app()->getLocale())
で合わせます。 - メール件名・本文も
->locale($locale)
で出し分け、代替テキスト(画像)も翻訳対象に。
9. セキュリティとプライバシー
- チャネル認可:
routes/channels.php
で厳密に判定。 - 機微情報:ブロードキャストのペイロードに個人情報を含めない。URL は署名付きや権限チェックを。
- レート制限:大量通知のスパム化を防ぐ。
- XSS 対策:通知本文はサニタイズ/エスケープ。生 HTML を安易に出さない。
- ログ:通知内容に機微がないかを監査(PII をログに残さない)。
10. パフォーマンス/運用
- キュー:通知は原則キュー化(
ShouldQueue
)。再試行回数や遅延送信をルール化。 - 集約:同系通知を一定時間でまとめて 1 件に(「○○ ほか 3 件」)。
- ページネーション:通知一覧は
paginate()
、未読バッジは別クエリで軽量化。 - 削除方針:古い通知のアーカイブ/削除を計画(テーブル肥大化対策)。
- 監視:失敗通知の再試行・DLQ(デッドレター)設計、ワーカー監視(Supervisor等)。
11. テスト:Feature/E2E/アクセシビリティ
11.1 Feature:保存と既読
// tests/Feature/NotificationsTest.php
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\User;
use App\Notifications\CommentPosted;
use Illuminate\Foundation\Testing\RefreshDatabase;
class NotificationsTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function it_stores_database_notification_and_marks_as_read()
{
$user = User::factory()->create();
$this->actingAs($user);
$user->notify(new CommentPosted(['excerpt'=>'こんにちは','url'=>'/comments/1']));
$this->assertCount(1, $user->notifications);
$n = $user->notifications()->first();
$this->assertNull($n->read_at);
$resp = $this->post("/notifications/{$n->id}/read");
$resp->assertOk();
$this->assertNotNull($n->fresh()->read_at);
}
}
11.2 Broadcast(ユニット的検証)
// tests/Unit/Events/CommentCreatedTest.php
public function test_broadcast_payload_is_minimal_and_safe()
{
$event = new \App\Events\CommentCreated(1, ['excerpt'=>'Hi','url'=>'/u/1']);
$data = $event->broadcastWith();
$this->assertArrayHasKey('message', $data);
$this->assertArrayNotHasKey('email', $data); // 機微情報が入っていない
}
11.3 E2E(Dusk):読み上げと操作性
- 通知到着時にトーストが表示され、
role="status"
またはrole="alert"
が付与されている。 - 閉じるボタンにキーボードで到達でき、
Esc
でも閉じられる。 - 既読操作後、ライブリージョンが適切な日本語で読み上げる。
12. よくある設計ミスと回避策
- アラートをすべて
assertive
で読み上げ:重要でない通知まで人の作業を遮ることに。→ 通常はpolite
、致命的なエラーのみassertive
。 - 自動消滅しかないトースト:読み終わる前に消えてしまう。→ 閉じるボタン必須/ポインタホバーやフォーカスでタイマー停止。
- 色だけのステータス表現:色覚多様性に非対応。→ アイコン・文言・枠線など多重表現。
- 大きなアニメーション:注意を奪い疲れさせる。→
prefers-reduced-motion
を尊重し、控えめに。 - 過剰通知:通知疲れの原因。→ 集約・頻度制御・ユーザー側の通知設定を提供。
13. 仕上げのチェックリスト(配布用)
アクセシビリティ
- [ ] トースト:
role="status"
(情報)/role="alert"
(危険)を使い分け - [ ]
aria-live="polite|assertive"
を適切に設定 - [ ] 閉じるボタン/
Esc
キー対応/フォーカス移動を保証 - [ ] 色に依存せず、アイコン・テキスト・枠線で状態を多重表現
- [ ]
prefers-reduced-motion
を尊重し、アニメーションは最小限
リアルタイム
- [ ] Private/Presence チャネルで認可済みユーザーのみ購読
- [ ] ペイロードは最小限・機微情報なし
- [ ] フォールバック(ポーリング)を用意し、確実に届く設計
運用
- [ ] キュー化・再試行・遅延の方針を明文化
- [ ] 集約・スロットリングで通知疲れを防止
- [ ] 既読・保存・フィルタの UX を整備
- [ ] 古い通知のアーカイブ/削除ポリシー
国際化
- [ ] 文言は辞書管理、
trans_choice
で件数に応じて表現 - [ ] Carbon のロケール設定、相対時刻の自然言語化
14. まとめ:静かに、確実に、やさしく届ける
本記事では、Laravel の Notifications と Broadcasting を軸に、段階的にスケールする通知基盤と、誰にでもわかりやすい通知体験の作り方をご紹介しました。
- まずは Database+メール通知で確実に届ける。
- そこに Broadcast を足して「届いた瞬間に見える」を実現。
- トースト/アラートの a11y(
role
/aria-live
/フォーカス/色以外の表現/動きの節度)を丁寧に設計。 - 既読・保存・集約で「見逃さない」を支える運用へ。
- セキュリティ/プライバシーを守り、キューと監視で安定運用。
通知は、ユーザーとプロダクトの信頼の呼吸です。
静かで、確実で、やさしい通知は、毎日の体験を少しずつ心地よくします。ここでご紹介したサンプルとチェックリストをベースに、チームの標準として育ててくださいね。わたしも応援しています♡
このガイドが特に役立つ読者像(詳細)
- SaaS/業務システムのテックリード:通知設計・既読運用・監視までの標準化を図り、障害時も静かに復旧できる仕組みを整えたい。
- カスタマーサクセス/プロダクトオーナー:過剰通知を避けつつ、見逃さない導線でサポート問い合わせを削減したい。
- アクセシビリティ担当/QA:トーストやアラートの読み上げ保証、キーボード完結操作、動きの軽減のテスト項目をテンプレ化したい。
- 個人開発者:まずはメール+DB通知から始め、将来的にリアルタイムへ段階的に拡張したい。