Laravelイベントシステムとブロードキャスト
イベントシステムはLaravelの「通信ネットワーク」です。送信者がメッセージをブロードキャストし, 受信者それぞれが独自に処理します。送信者が誰が聞いているかを知る必要はありません。
1. 学ぶこと
- イベントとリスナー:EventServiceProviderの登録と自動検出
- イベントのディスパッチ:event() vs Event::dispatch()
- ブロードキャストメカニズム:Redis Pub/Sub + Laravel Echo + Pusher
- チャンネルタイプ:パブリック/プライベート/プレゼンスチャンネル
- フロントエンド受信:Laravel Echo + WebSocketリアルタイム通知
2. プロダクトマネージャーの本当の話
(1) 痛点:注文ステータスの変更を見るためにページを手動リロードしなければならない
AliceはShopMetricsバックエンドで注文を管理していますが, 顧客が注文してもすぐに表示されず, 5分ごとにページを手動リロードする必要があります。Bobはさらに大変で, 3つの店舗を同時に管理しており, リロードが追いつかず, 5件の緊急注文を見落としました。Charlieは「2024年なのにまだ手動リロード?WebSocketリアルタイム通知って聞いたことない?」と言いました。
(2) イベントブロードキャストの解決策
Laravelイベントブロードキャスト—注文が作成されるとイベントがトリガーされ, サーバーがWebSocket経由でフロントエンドにイベントをプッシュし, Aliceのページが自動的に更新されます。遅延ゼロです。
// 注文完了 → イベントディスパッチ → WebSocketブロードキャスト
event(new OrderPlaced($order));
// Aliceのブラウザがリアルタイムで通知を受信
(3) 成果
Aliceのリアルタイム通知により, 新しい注文は0.5秒以内にダッシュボードに表示され, 注文を見落とすことはなくなりました。
3. イベントとリスナー
(1) イベントとリスナーの作成
php artisan make:event OrderPlaced
php artisan make:listener SendOrderNotification --event=OrderPlaced
php artisan make:listener UpdateShopRevenue --event=OrderPlaced
php artisan make:listener SendOrderWebhook --event=OrderPlaced
(2) イベントクラス
// app/Events/OrderPlaced.php
class OrderPlaced implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public Order $order,
) {}
public function broadcastOn(): array
{
return [
new PrivateChannel('tenant.' . $this->order->tenant_id),
new PrivateChannel('shop.' . $this->order->shop_id),
];
}
public function broadcastWith(): array
{
return [
'order_id' => $this->order->id,
'order_number' => $this->order->order_number,
'total' => (float) $this->order->total,
'shop_name' => $this->order->shop->name,
'customer_name' => $this->order->user->name,
];
}
public function broadcastAs(): string
{
return 'order.placed';
}
}
(3) リスナークラス
// app/Listeners/SendOrderNotification.php
class SendOrderNotification
{
public function handle(OrderPlaced $event): void
{
$order = $event->order;
// 店舗オーナーにメール通知を送信
$order->shop->tenant->users()
->where('role', 'tenant_owner')
->each(fn ($user) => $user->notify(new OrderCreatedNotification($order)));
}
}
// app/Listeners/UpdateShopRevenue.php
class UpdateShopRevenue
{
public function handle(OrderPlaced $event): void
{
$event->order->shop->increment('revenue', $event->order->total);
}
}
(4) イベントリスナーの登録
// app/Providers/EventServiceProvider.php
protected $listen = [
OrderPlaced::class => [
SendOrderNotification::class,
UpdateShopRevenue::class,
SendOrderWebhook::class,
],
OrderStatusChanged::class => [
SendStatusChangeNotification::class,
],
];
(1) ▶ サンプル:ShopMetrics注文イベントトリガー
// app/Http/Controllers/OrderController.php
public function store(StoreOrderRequest $request): RedirectResponse
{
$order = DB::transaction(function () use ($request) {
$order = Order::create($request->validated());
foreach ($request->items as $item) {
$order->items()->create($item);
}
$order->updateTotal();
return $order;
});
// イベントをディスパッチ — すべてのリスナー + ブロードキャストをトリガー
event(new OrderPlaced($order));
return redirect()->route('orders.show', $order)
->with('success', 'Order placed!');
}
出力:
// 実行成功
4. イベントのディスパッチ
(1) ディスパッチ方法
// 方法1:event()ヘルパ (推奨)
event(new OrderPlaced($order));
// 方法2:Eventファサード
Event::dispatch(new OrderPlaced($order));
// 方法3:イベントクラスの静的ディスパッチ
OrderPlaced::dispatch($order);
(2) 同期vs非同期リスナー
// 同期リスナー — リクエストサイクル内で実行
class UpdateShopRevenue implements ShouldHandleEventsAfterCommit
{
public function handle(OrderPlaced $event): void
{
$event->order->shop->increment('revenue', $event->order->total);
}
}
// 非同期リスナー — キューにプッシュ
class SendOrderWebhook implements ShouldQueue
{
use InteractsWithQueue;
public int $tries = 3;
public int $backoff = 30;
public function handle(OrderPlaced $event): void
{
Http::post($event->order->shop->webhook_url, [
'event' => 'order.placed',
'data' => new OrderResource($event->order),
]);
}
}
| 型 | インターフェース | 実装方法 | 適用場面 |
|---|---|---|---|
| 同期 | なし | リクエスト内で順次実行 | データベース更新 |
| 非同期 | ShouldQueue | キューに登録して非同期実行 | メール送信/Webhook |
| トランザクション後 | AfterCommit | トランザクションコミット後に実行 | 永続化データに依存 |
(1) ▶ サンプル:ShopMetricsイベントとリスナー登録
// app/Providers/EventServiceProvider.php
class EventServiceProvider extends ServiceProvider
{
protected $listen = [
// 注文イベント
OrderPlaced::class => [
UpdateShopRevenue::class, // 同期 — 統計を更新
SendOrderNotification::class, // 非同期 — メール送信
SendOrderWebhook::class, // 非同期 — webhook呼び出し
],
OrderStatusChanged::class => [
SendStatusNotification::class, // 非同期
UpdateAnalyticsCache::class, // 同期 — キャッシュクリア
],
SubscriptionCreated::class => [
SendWelcomeEmail::class, // 非同期
ProvisionTenantResources::class, // 非同期
],
];
}
出力:
// 実行成功
5. ブロードキャストメカニズム
(1) ブロードキャストアーキテクチャ
sequenceDiagram
participant S as サーバー
participant E as イベント
participant B as ブロードキャスタ
participant WS as WebSocketサーバー
participant C as クライアント (Echo)
S->>E: event(new OrderPlaced($order))
E->>B: broadcastOn() → チャンネル
B->>WS: Redisチャンネルにパブリッシュ
WS->>C: WebSocket経由でプッシュ
C->>C: Echoが受信 & UIを更新
(2) ブロードキャスト設定
# .env
BROADCAST_CONNECTION=redis
QUEUE_CONNECTION=redis
# 依存関係のインストール
composer require pusher/pusher-php-server
# またはRedisの場合:
# predis/predisはインストール済み
// config/broadcasting.php
'default' => env('BROADCAST_CONNECTION', 'redis'),
'connections' => [
'pusher' => [
'driver' => 'pusher',
'key' => env('PUSHER_APP_KEY'),
'secret' => env('PUSHER_APP_SECRET'),
'app_id' => env('PUSHER_APP_ID'),
],
'redis' => [
'driver' => 'redis',
'connection' => 'default',
],
],
(3) ブロードキャストドライバの比較
| ドライバ | サービス | セルフホスト | パフォーマンス | コスト |
|---|---|---|---|---|
| Pusher | Pusherクラウド | ❌ | 高 | 有料 |
| Redis | Redis + Laravel Echo Server | ✅ | 高 | 無料 |
| Ably | Ablyクラウド | ❌ | 高 | 有料 |
| Log | ログ (開発用) | ✅ | — | 無料 |
(1) ▶ サンプル:ShopMetricsブロードキャストイベント定義
// app/Events/OrderStatusChanged.php
class OrderStatusChanged implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public Order $order,
public string $oldStatus,
public string $newStatus,
) {}
public function broadcastOn(): array
{
return [
new PrivateChannel('tenant.' . $this->order->tenant_id),
];
}
public function broadcastWith(): array
{
return [
'order_id' => $this->order->id,
'order_number' => $this->order->order_number,
'old_status' => $this->oldStatus,
'new_status' => $this->newStatus,
'updated_at' => $this->order->updated_at->toISOString(),
];
}
public function broadcastAs(): string
{
return 'order.status_changed';
}
}
出力:
// 実行成功
6. チャンネルタイプと認可
(1) 3つのチャンネルタイプ
| チャンネル | プレフィックス | 可視性 | 目的 |
|---|---|---|---|
| パブリック | channel- |
全員 | アナウンス, サイト全体の通知 |
| プライベート | private- |
認可済みユーザー | テナント/ユーザー専用 |
| プレゼンス | presence- |
認可 + オンラインリスト | コラボレーション, チャット |
(2) チャンネル認可
// routes/channels.php
use Illuminate\Support\Facades\Broadcast;
// プライベートチャンネル — テナントメンバーのみリッスン可能
Broadcast::channel('tenant.{tenantId}', function ($user, $tenantId) {
return $user->tenant_id === (int) $tenantId;
});
// プライベートチャンネル — 店舗オーナー/アナリストのみ
Broadcast::channel('shop.{shopId}', function ($user, $shopId) {
$shop = Shop::find($shopId);
return $shop && $user->tenant_id === $shop->tenant_id;
});
// プレゼンスチャンネル — オンラインのユーザー
Broadcast::channel('shop.dashboard.{shopId}', function ($user, $shopId) {
if ($user->tenant_id === Shop::find($shopId)?->tenant_id) {
return ['id' => $user->id, 'name' => $user->name, 'role' => $user->role];
}
});
(1) ▶ サンプル:ShopMetricsチャンネル認可
// routes/channels.php
Broadcast::channel('tenant.{tenantId}', function ($user, $tenantId) {
return $user->tenant_id === (int) $tenantId
&& $user->tenant->status === 'active';
});
Broadcast::channel('shop.{shopId}', function ($user, $shopId) {
$shop = Shop::find($shopId);
if (!$shop || $user->tenant_id !== $shop->tenant_id) {
return false;
}
return ['id' => $user->id, 'name' => $user->name];
});
Broadcast::channel('notifications.{userId}', function ($user, $userId) {
return (int) $user->id === (int) $userId;
});
出力:
// 実行成功
7. フロントエンド受信
(1) Laravel Echoのインストール
npm install laravel-echo pusher-js
# またはRedisの場合:
npm install laravel-echo-connector socket.io-client
(2) Echoの設定
// resources/js/app.js
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
window.Pusher = Pusher;
window.Echo = new Echo({
broadcaster: 'pusher',
key: import.meta.env.VITE_PUSHER_APP_KEY,
cluster: import.meta.env.VITE_PUSHER_APP_CLUSTER ?? 'mt1',
wsHost: import.meta.env.VITE_PUSHER_HOST,
wsPort: import.meta.env.VITE_PUSHER_PORT ?? 6001,
forceTLS: false,
enabledTransports: ['ws'],
});
(3) イベントのリッスン
// プライベートチャンネルのリッスン — テナント固有のイベント
window.Echo.private(`tenant.${tenantId}`)
.listen('.order.placed', (e) => {
showToast(`新規注文: ${e.order_number} — $${e.total}`);
updateOrdersList(e);
})
.listen('.order.status_changed', (e) => {
updateOrderStatus(e.order_id, e.new_status);
});
// プレゼンスチャンネルのリッスン — オンラインユーザーを確認
window.Echo.join(`shop.dashboard.${shopId}`)
.here((users) => {
updateOnlineUsers(users);
})
.joining((user) => {
addOnlineUser(user);
})
.leaving((user) => {
removeOnlineUser(user);
});
(1) ▶ サンプル:ShopMetricsリアルタイムダッシュボード
<!-- resources/views/dashboard/index.blade.php -->
<script>
const tenantId = {{ auth()->user()->tenant_id }};
// Echoの初期化
window.Echo.private(`tenant.${tenantId}`)
.listen('.order.placed', (event) => {
// 統計を更新
const stats = document.getElementById('stats');
const orderCount = stats.querySelector('.order-count');
orderCount.textContent = parseInt(orderCount.textContent) + 1;
// 最近の注文に追加
const list = document.getElementById('recent-orders');
list.insertAdjacentHTML('afterbegin', `
<tr class="bg-green-50">
<td>${event.order_number}</td>
<td>${event.shop_name}</td>
<td>$${event.total.toFixed(2)}</td>
<td><span class="badge-blue">New</span></td>
</tr>
`);
// 通知を表示
showNotification(`新規注文 from ${event.customer_name}: $${event.total}`);
})
.listen('.order.status_changed', (event) => {
const row = document.querySelector(`[data-order="${event.order_id}"]`);
if (row) {
row.querySelector('.status-badge').textContent = event.new_status;
row.querySelector('.status-badge').className = `status-badge badge-${event.new_status}`;
}
});
</script>
出力:
// 実行成功
8. 総合例:ShopMetricsリアルタイム通知システム
// ============================================
// 総合例: ShopMetricsリアルタイム通知
// 対象: イベント, リスナー, ブロードキャスト, チャンネル, Echo
// ============================================
// app/Events/OrderPlaced.php
class OrderPlaced implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(public Order $order) {}
public function broadcastOn(): array
{
return [
new PrivateChannel('tenant.' . $this->order->tenant_id),
new PrivateChannel('shop.' . $this->order->shop_id),
];
}
public function broadcastWith(): array
{
$this->order->load('shop', 'user');
return [
'order_id' => $this->order->id,
'order_number' => $this->order->order_number,
'total' => (float) $this->order->total,
'status' => $this->order->status,
'shop' => ['id' => $this->order->shop->id, 'name' => $this->order->shop->name],
'customer' => ['id' => $this->order->user->id, 'name' => $this->order->user->name],
'created_at' => $this->order->created_at->toISOString(),
];
}
public function broadcastAs(): string { return 'order.placed'; }
}
// app/Events/OrderStatusChanged.php
class OrderStatusChanged implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public Order $order,
public string $oldStatus,
public string $newStatus,
) {}
public function broadcastOn(): array
{
return [new PrivateChannel('tenant.' . $this->order->tenant_id)];
}
public function broadcastWith(): array
{
return [
'order_id' => $this->order->id,
'order_number' => $this->order->order_number,
'old_status' => $this->oldStatus,
'new_status' => $this->newStatus,
];
}
public function broadcastAs(): string { return 'order.status_changed'; }
}
// サービスクラスでのトリガー
class OrderService
{
public function place(array $data): Order
{
$order = DB::transaction(function () use ($data) {
$order = Order::create($data);
// ... アイテム作成, 合計計算
return $order;
});
event(new OrderPlaced($order));
return $order;
}
public function changeStatus(Order $order, string $newStatus): Order
{
$oldStatus = $order->status;
$order->update(['status' => $newStatus]);
event(new OrderStatusChanged($order, $oldStatus, $newStatus));
return $order;
}
}
❓ よくある質問
broadcastWith()で必要なフィールドのみを送信します (ID + キー情報)。フロントエンドが受信後, AJAXで完全なデータを取得できます。ブロードキャストデータ内に大量の関連リソースを埋め込むのは避けてください。Event::fake()でイベントディスパッチをシミュレートし, イベントがディスパッチされたことをアサートします:Event::assertDispatched(OrderPlaced::class)。リスナーごとに個別のユニットテストを書きます。📖 まとめ
- イベントの送信者と受信者を疎結合にし, リスナーを追加するだけで新機能を追加可能
- ShouldQueueリスナーは非同期で実行され, リクエストをブロックしない
- ShouldBroadcastインターフェースでイベントをWebSocket経由でブロードキャスト可能
- プライベートチャンネルは認可が必要。プレゼンスチャンネルは現在オンラインのユーザーリストも表示
- Laravel EchoフロントエンドライブラリがWebSocket接続とイベントリッスンを一元管理
- broadcastWith()でブロードキャストデータ量を制御し, 必要なフィールドのみ送信
📝 練習問題
-
基本問題 (⭐):
OrderPlacedイベントとSendOrderNotificationリスナーを作成し, 注文作成時にトリガーしてください。Event::fake()を使ってイベントがディスパッチされたことを確認するテストを書いてください。 -
応用問題 (⭐⭐):
OrderPlacedイベントにShouldBroadcastブロードキャストを実装し, Redisブロードキャストドライバを設定し, フロントエンドでLaravel Echoを使ってprivate-tenant.{id}チャンネルをリッスンして新規注文のリアルタイム通知を表示してください。 -
チャレンジ (⭐⭐⭐):プレゼンスチャンネルを使ったマルチユーザーコラボレーションダッシュボードを実装してください。複数ユーザーが同じ店舗データを同時に表示する際, オンラインユーザーリストを表示し, 1人のユーザーがデータを変更すると他のユーザーにリアルタイムで変更が反映されるようにします (注文ステータス変更のブロードキャスト)。



