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

【完全版】Laravel 通知(Notifications)× リアルタイム配信(Broadcasting)――アクセシブルなトースト/アラート設計と実装ガイド

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

  • Laravel Notifications(Mail/Database/Broadcast/Slack 等)の基本と、キュー連携による安定運用
  • Event/Listener/Broadcast(公開・プライベート・プレゼンス)でリアルタイム更新を実現する設計
  • アクセシビリティに配慮したトースト/アラート/通知ドロワーの UX パターン(aria-liverole="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>
  • rolearia-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 を足して「届いた瞬間に見える」を実現。
  • トースト/アラートの a11yrolearia-live/フォーカス/色以外の表現/動きの節度)を丁寧に設計。
  • 既読・保存・集約で「見逃さない」を支える運用へ。
  • セキュリティ/プライバシーを守り、キューと監視で安定運用。

通知は、ユーザーとプロダクトの信頼の呼吸です。
静かで、確実で、やさしい通知は、毎日の体験を少しずつ心地よくします。ここでご紹介したサンプルとチェックリストをベースに、チームの標準として育ててくださいね。わたしも応援しています♡


このガイドが特に役立つ読者像(詳細)

  • SaaS/業務システムのテックリード:通知設計・既読運用・監視までの標準化を図り、障害時も静かに復旧できる仕組みを整えたい。
  • カスタマーサクセス/プロダクトオーナー:過剰通知を避けつつ、見逃さない導線でサポート問い合わせを削減したい。
  • アクセシビリティ担当/QA:トーストやアラートの読み上げ保証、キーボード完結操作、動きの軽減のテスト項目をテンプレ化したい。
  • 個人開発者:まずはメール+DB通知から始め、将来的にリアルタイムへ段階的に拡張したい。

投稿者 greeden

コメントを残す

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

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