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

【現場完全ガイド】Laravelのリアルタイム体験――Broadcasting/WebSockets/SSE・通知・再接続・オフライン対応とアクセシブルなライブ更新

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

  • Broadcasting(イベント→チャンネル→配信)と WebSockets/SSE の選び方
  • Laravel Echo/Laravel WebSockets/Pusher の構成と、認証付きチャンネル(Private/Presence)
  • Livewire/Alpine/Blade を使った最小 JavaScriptでのライブUI、フォールバック(ポーリング/SSE)設計
  • アクセシブルなライブ更新(aria-liverole="status"・フォーカス移動・トースト/バナー/バッジ)
  • 再接続・オフライン・レート制限・権限・監査ログ・テスト(Feature/Dusk)の実務
  • チャット/通知センター/進捗ボード/ダッシュボード集計などのサンプル

想定読者(だれが得をする?)

  • Laravel 初〜中級エンジニア:チャット、通知、ダッシュボードなど動く画面を安全に作りたい方
  • テックリード/PM:外部サービス(Pusher)とセルフホスト(Laravel WebSockets)のコスト/運用比較をしたい方
  • QA/アクセシビリティ担当:ライブ更新の読み上げキーボード操作色に依存しない通知を標準化したい方
  • CS/サポート:通知や進捗表示の言語設計を整えて問い合わせを減らしたい方

アクセシビリティレベル:★★★★★

aria-live/role="status"/role="alert" とフォーカス制御、色に依存しない状態表現、prefers-reduced-motion、キーボード到達性、オフライン時の明示と再試行など実装面を網羅します。


1. はじめに:リアルタイムは「便利さ」と「予期せぬ変化」の両立

リアルタイムUIは便利ですが、突然の変化はユーザーを迷わせます。

  • 画面が勝手に変わるときは、短い言葉で状況を案内。
  • 変化箇所へフォーカスを勝手に移動しない(操作中断のリスク)。
  • 読み上げには aria-live="polite" を基本とし、危険/重要時のみ role="alert"
  • 接続切断・再接続・遅延は静かに説明し、次の手段(再読み込み/軽量版)を提示。

Laravel はサーバイベント→Broadcasting→クライアント購読までを一貫サポートします。本記事は、安全でやさしいリアルタイム体験の設計・コード・運用をまとめます。


2. アーキテクチャ概観:イベント→チャンネル→配信→購読

  1. アプリイベントを発火(例:OrderCreated)。
  2. Broadcast可能なイベントでチャンネルへ配信(public/private/presence)。
  3. WebSockets(Pusher/Laravel WebSockets)または SSE/ポーリングでクライアントが購読。
  4. クライアント側で UI更新(カウンタ、リスト追加、トースト表示)を実施。

選択指針:

  • 双方向・大量接続・低遅延 → WebSockets
  • 単方向・配信中心・シンプル → SSE
  • 制約環境や最小構成 → ポーリング
  • モバイル/社内網などでプロキシの制約が強い場合、SSE/ポーリングの併用が安全です。

3. Broadcasting の基礎:イベントとチャンネル

3.1 ブロードキャスト可能なイベント

// app/Events/OrderCreated.php
namespace App\Events;

use App\Models\Order;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\PrivateChannel; // 認証が必要な場合
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;

class OrderCreated implements ShouldBroadcast
{
    public function __construct(public Order $order) {}

    public function broadcastOn(): Channel
    {
        // ログインユーザー専用の通知チャンネル例
        return new PrivateChannel('users.'.$this->order->user_id);
    }

    public function broadcastAs(): string
    {
        return 'order.created';
    }

    public function broadcastWith(): array
    {
        return [
            'id' => $this->order->id,
            'number' => $this->order->number,
            'total' => $this->order->total,
        ];
    }
}

3.2 チャンネル認可

// routes/channels.php
Broadcast::channel('users.{id}', function ($user, $id) {
    return (int) $user->id === (int) $id; // 所有者のみサブスク
});
  • Private/Presence チャンネルはサーバ側で認可
  • Broadcastイベントはキュー経由で配信するとピーク時に強いです。

4. クライアント:Laravel Echo で購読

4.1 基本配線

npm i laravel-echo pusher-js
// resources/js/bootstrap.js(例)
import Echo from 'laravel-echo';
window.Pusher = require('pusher-js');

window.echo = new Echo({
  broadcaster: 'pusher',
  key: import.meta.env.VITE_PUSHER_KEY,
  wsHost: import.meta.env.VITE_PUSHER_HOST,
  wsPort: 6001,
  wssPort: 6001,
  forceTLS: false,
  disableStats: true,
  enabledTransports: ['ws', 'wss'], // フォールバックを使うなら調整
  authEndpoint: '/broadcasting/auth', // Private/Presence
});

// 購読例
window.echo.private(`users.${userId}`)
  .listen('.order.created', (e) => {
     // UI更新
  });

4.2 Laravel WebSockets(セルフホスト)

  • Pusher互換のサーバを自前運用。コスト最適化とデータ所在の観点で有力。
  • 小〜中規模では1台で十分。大規模は水平分割+Redisなどのスケール方針を。

5. SSE(Server-Sent Events):シンプルな一方向配信

// routes/web.php
Route::get('/stream/orders', function () {
    return response()->stream(function () {
        while (true) {
            if ($event = App\Support\SseBuffer::next()) {
                echo "data: ".json_encode($event)."\n\n";
                ob_flush(); flush();
            }
            usleep(300000);
        }
    }, 200, ['Content-Type' => 'text/event-stream', 'Cache-Control' => 'no-cache']);
})->middleware('auth');
const es = new EventSource('/stream/orders');
es.onmessage = (ev) => {
  const data = JSON.parse(ev.data);
  // UI更新
};
es.onerror = () => { /* 接続断UIなど */ };
  • HTTP/1.1 で動作しやすく、プロキシ越えが安定。
  • 双方向やPresenceが不要なら十分な選択肢です。

6. アクセシブルなライブ更新:設計原則

  • 見出しやフォーカスを勝手に移動しない。ユーザーの操作を中断しない。
  • 重要ではない更新は aria-live="polite"、緊急は role="alert"
  • バッジ増分や新着件数はテキストでも提示(色だけに頼らない)。
  • トーストは読み上げ優先度を調整(通常は role="status")。
  • 動きは短く控えめprefers-reduced-motion を尊重。

6.1 最小トースト(読み上げ対応)

<div id="toast" role="status" aria-live="polite" class="sr-only"></div>

<script>
function announce(msg){ 
  const t = document.getElementById('toast');
  t.textContent = ''; // 同文再読上げ対策
  setTimeout(()=> t.textContent = msg, 50);
}
</script>

6.2 新着バッジ

<button class="relative" aria-describedby="notify-help" id="bell">
  <span aria-hidden="true">🔔</span>
  <span id="badge" class="absolute -top-1 -right-1 rounded-full bg-red-600 text-white text-xs px-1">0</span>
</button>
<p id="notify-help" class="sr-only">新着通知の件数を表示します。</p>

<script>
let count = 0;
function onOrderCreated(){ 
  count++;
  document.getElementById('badge').textContent = count;
  announce(`新しい注文が${count}件あります`);
}
</script>
  • 視覚の色(赤バッジ)に加え数値と読み上げで周知。
  • クリックで一覧へ移動する場合は、次のフォーカス先を設計。

7. 典型UI:チャットと通知センター

7.1 チャット(Presence チャンネル)

// routes/channels.php
Broadcast::channel('rooms.{roomId}', function ($user, $roomId) {
    return $user->can('join', Room::findOrFail($roomId)) ? ['id'=>$user->id,'name'=>$user->name] : false;
});
window.echo.join(`rooms.${roomId}`) // presence
  .here(users => renderUsers(users))
  .joining(user => addUser(user))
  .leaving(user => removeUser(user))
  .listen('.message.posted', (e) => appendMessage(e));

// メッセージ送信は通常のPOST→サーバでイベント発火

アクセシビリティ

  • 新着メッセージはrole="status"の領域に短文で告げる。「山田さんからメッセージ」。
  • メッセージリストは role="log" にすると追記に強い読み上げ挙動になります。
  • ユーザー入退室は音でなく文言で。過度な通知は抑制。

7.2 通知センター

  • 重要度低:数字バッジ+一覧で提示、既読でバッジ減。
  • 重要度高:role="alert" トースト+詳細へキーボード先導
  • 通知の有効期限・重複抑制(同一キーは上書き)を運用に組み込みます。

8. 再接続・オフライン・フォールバック

8.1 状態バナー

<div id="conn" role="status" aria-live="polite" class="text-sm text-gray-600">
  接続中...
</div>

<script>
function setConn(text, cls=''){ const el = document.getElementById('conn'); el.textContent=text; el.className=cls; }

window.addEventListener('offline', ()=> setConn('オフラインです。変更は一時保存されます。','text-red-700'));
window.addEventListener('online', ()=> setConn('オンラインに復帰しました。同期します。','text-green-700'));
</script>

8.2 Echo の再接続ハンドリング

window.echo.connector.pusher.connection.bind('state_change', (states) => {
  if (states.current === 'connecting') setConn('接続中...');
  if (states.current === 'unavailable' || states.current === 'disconnected') setConn('接続が不安定です。自動再接続します。','text-red-700');
  if (states.current === 'connected') setConn('接続中','text-green-700');
});

8.3 フォールバック方針

  • 1st: WebSockets → 2nd: SSE → 3rd: ポーリング(15–60秒)。
  • 重要画面のみライブ、他は明示ボタンで更新(「最新の情報を取得」)。
  • ライブ不可でも機能が失われない設計優先

9. セキュリティ・権限・レート制限・監査

  • Private/Presence チャンネルはサーバで厳格認可
  • イベントのペイロード最小化(機微情報は含めない)。
  • メッセージ投稿や通知作成はRate Limitingでスパム/濫用対策。
  • 監査ログに「誰が/いつ/どのチャンネルへ/どの種類のイベント」を記録。
  • 接続IDやユーザーIDでトレースできるよう構造化ログ化。

10. サーバ運用:Horizon/Queues・WebSockets・スケール

  • Broadcasting はキュー化してピークを平準化。
  • Laravel WebSockets を使うなら監視パネルで接続数/メッセージ量を把握。
  • 多接続ではヘルスチェックとオートスケール、CDN/エッジで静的配信を分離。
  • 逆プロキシ(Nginx)のタイムアウト/ヘッダを最適化(SSEはX-Accel-Buffering: no等)。

11. 進捗とジョブのライブ表示(ダッシュボード)

11.1 進捗イベント

class ExportProgress implements ShouldBroadcast {
  public function __construct(public int $userId, public int $percent) {}
  public function broadcastOn(){ return new PrivateChannel("users.{$this->userId}"); }
  public function broadcastAs(){ return 'export.progress'; }
}
window.echo.private(`users.${userId}`)
  .listen('.export.progress', e => updateProgress(e.percent));

11.2 進捗UI(アクセシブル)

<div aria-labelledby="exp-title">
  <h2 id="exp-title">エクスポートの進行状況</h2>
  <div role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" id="bar"
       aria-describedby="exp-help">0%</div>
  <p id="exp-help" class="sr-only">完了するとダウンロードリンクが表示されます。</p>
</div>

<script>
function updateProgress(p){
  const bar = document.getElementById('bar');
  bar.setAttribute('aria-valuenow', p);
  bar.textContent = `${p}%`;
  if (p===100) announce('エクスポートが完了しました。');
}
</script>
  • 進捗は数値で表示し、色の変化に依存しない。
  • 完了時はrole="status"短く通知

12. テスト:Feature・Dusk・スモークa11y

12.1 Feature(イベント発火)

use Illuminate\Support\Facades\Broadcast;
use Illuminate\Support\Facades\Event;

test('OrderCreated broadcast payload', function () {
    Event::fake();
    $order = Order::factory()->create();
    event(new \App\Events\OrderCreated($order));
    Event::assertDispatched(\App\Events\OrderCreated::class);
});

12.2 Dusk(UI反映と読み上げ)

public function test_toast_announces_on_message()
{
    $this->browse(function (Browser $b) {
        $b->visit('/dashboard')
          ->script("announce('新しい注文があります')"); // 疑似注入
        $b->assertSeeIn('#toast','新しい注文があります');
    });
}

12.3 a11yスモーク(Pa11y/axe)

  • トースト領域に role="status" が存在。
  • 重要通知画面に role="alert" が重複・乱用されていない。
  • バッジのみで意味が伝わらない UI がない。

13. 実装サンプル(最小・通知カウンタ)

13.1 ルート/イベント

// routes/channels.php
Broadcast::channel('users.{id}', fn($user,$id) => (int)$user->id === (int)$id);

// app/Events/NotifyUser.php
class NotifyUser implements ShouldBroadcast {
  public function __construct(public int $userId, public string $message){}
  public function broadcastOn(){ return new PrivateChannel("users.{$this->userId}"); }
  public function broadcastAs(){ return 'notify.user'; }
  public function broadcastWith(){ return ['message'=>$this->message]; }
}

13.2 Blade

<button id="bell" aria-describedby="notify-help" class="relative">
  <span aria-hidden="true">🔔</span>
  <span id="badge" class="absolute -top-1 -right-1 bg-red-600 text-white text-xs rounded px-1">0</span>
</button>
<p id="notify-help" class="sr-only">新着通知の件数を表示します。</p>
<div id="toast" role="status" aria-live="polite" class="sr-only"></div>

<script type="module">
import Echo from 'laravel-echo';
window.Pusher = (await import('pusher-js')).default;

const echo = new Echo({ broadcaster:'pusher', key:import.meta.env.VITE_PUSHER_KEY, wsHost:location.hostname, wsPort:6001, forceTLS:false, disableStats:true });
let count = 0;

echo.private(`users.${@js(auth()->id())}`).listen('.notify.user', (e) => {
  count++;
  document.getElementById('badge').textContent = count;
  announce(e.message);
});
</script>

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

  • 重要でない更新まで alert 乱用 → 通常は status/polite
  • ライブ更新でフォーカスを強制移動 → しない。ユーザー操作を最優先。
  • 失敗や切断が無言 → 接続状態を短文で明示、再試行を提示。
  • Private 認可が甘い → routes/channels.php で厳格に
  • イベントペイロードが過大 → 必要最小+IDで再取得。
  • フォールバック不在 → SSE/ポーリングを併用、明示更新ボタンも併置。
  • 色だけのバッジ → 数値/文言を併記。
  • 動き過多 → prefers-reduced-motion を尊重し、短く控えめに。

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

アーキテクチャ

  • [ ] イベントは ShouldBroadcast、ペイロード最小
  • [ ] Private/Presence の認可を実装
  • [ ] キュー経由で配信、Horizon監視

クライアント

  • [ ] Echo 配線、再接続メッセージ
  • [ ] SSE/ポーリングのフォールバック
  • [ ] 接続状態の表示(role="status"

アクセシビリティ

  • [ ] 通知は role="status" aria-live="polite"、緊急のみ alert
  • [ ] バッジは数値/テキスト併記、色依存なし
  • [ ] フォーカス移動の抑制、操作中断を避ける
  • [ ] prefers-reduced-motion の尊重

セキュリティ/運用

  • [ ] routes/channels で厳格認可
  • [ ] Rate Limiting/監査ログ
  • [ ] 断/復帰時の案内、再試行導線
  • [ ] 監視(接続数/遅延/エラー率)

テスト

  • [ ] Feature:イベント発火/認可
  • [ ] Dusk:トースト/バッジ/読み上げ
  • [ ] a11y:status/alert の適切性

16. まとめ

  • Laravel Broadcasting と Echo で堅実なリアルタイム基盤を構築。
  • Private/Presence 認可と最小ペイロードで安全に。
  • UI は読み上げに優しい通知と色に依存しない状態表示を基本に。
  • 接続断/復帰/再接続は短い言葉で案内し、フォールバックを用意。
  • テストと監視を回し、静かに・確実に・やさしく動く体験を保守しましょう。

参考リンク

投稿者 greeden

コメントを残す

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

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