404 Not Found

404 Not Found


nginx

Laravelイベントシステムとブロードキャスト

イベントシステムはLaravelの「通信ネットワーク」です。送信者がメッセージをブロードキャストし, 受信者それぞれが独自に処理します。送信者が誰が聞いているかを知る必要はありません。

1. 学ぶこと


2. プロダクトマネージャーの本当の話

(1) 痛点:注文ステータスの変更を見るためにページを手動リロードしなければならない

AliceはShopMetricsバックエンドで注文を管理していますが, 顧客が注文してもすぐに表示されず, 5分ごとにページを手動リロードする必要があります。Bobはさらに大変で, 3つの店舗を同時に管理しており, リロードが追いつかず, 5件の緊急注文を見落としました。Charlieは「2024年なのにまだ手動リロード?WebSocketリアルタイム通知って聞いたことない?」と言いました。

(2) イベントブロードキャストの解決策

Laravelイベントブロードキャスト—注文が作成されるとイベントがトリガーされ, サーバーがWebSocket経由でフロントエンドにイベントをプッシュし, Aliceのページが自動的に更新されます。遅延ゼロです。

PHP
// 注文完了 → イベントディスパッチ → WebSocketブロードキャスト
event(new OrderPlaced($order));
// Aliceのブラウザがリアルタイムで通知を受信

(3) 成果

Aliceのリアルタイム通知により, 新しい注文は0.5秒以内にダッシュボードに表示され, 注文を見落とすことはなくなりました。


3. イベントとリスナー

(1) イベントとリスナーの作成

BASH
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) イベントクラス

PHP
// 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) リスナークラス

PHP
// 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) イベントリスナーの登録

PHP
// app/Providers/EventServiceProvider.php
protected $listen = [
    OrderPlaced::class => [
        SendOrderNotification::class,
        UpdateShopRevenue::class,
        SendOrderWebhook::class,
    ],
    OrderStatusChanged::class => [
        SendStatusChangeNotification::class,
    ],
];

(1) ▶ サンプル:ShopMetrics注文イベントトリガー

PHP
// 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!');
}

出力:

TEXT
// 実行成功

4. イベントのディスパッチ

(1) ディスパッチ方法

PHP
// 方法1:event()ヘルパ (推奨)
event(new OrderPlaced($order));

// 方法2:Eventファサード
Event::dispatch(new OrderPlaced($order));

// 方法3:イベントクラスの静的ディスパッチ
OrderPlaced::dispatch($order);

(2) 同期vs非同期リスナー

PHP
// 同期リスナー — リクエストサイクル内で実行
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イベントとリスナー登録

PHP
// 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,   // 非同期
        ],
    ];
}

出力:

TEXT
// 実行成功

5. ブロードキャストメカニズム

(1) ブロードキャストアーキテクチャ

100%
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) ブロードキャスト設定

BASH
# .env
BROADCAST_CONNECTION=redis
QUEUE_CONNECTION=redis

# 依存関係のインストール
composer require pusher/pusher-php-server
# またはRedisの場合:
# predis/predisはインストール済み
PHP
// 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ブロードキャストイベント定義

PHP
// 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';
    }
}

出力:

TEXT
// 実行成功

6. チャンネルタイプと認可

(1) 3つのチャンネルタイプ

チャンネル プレフィックス 可視性 目的
パブリック channel- 全員 アナウンス, サイト全体の通知
プライベート private- 認可済みユーザー テナント/ユーザー専用
プレゼンス presence- 認可 + オンラインリスト コラボレーション, チャット

(2) チャンネル認可

PHP
// 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チャンネル認可

PHP
// 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;
});

出力:

TEXT
// 実行成功

7. フロントエンド受信

(1) Laravel Echoのインストール

BASH
npm install laravel-echo pusher-js
# またはRedisの場合:
npm install laravel-echo-connector socket.io-client

(2) Echoの設定

JAVASCRIPT
// 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) イベントのリッスン

JAVASCRIPT
// プライベートチャンネルのリッスン — テナント固有のイベント
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リアルタイムダッシュボード

HTML
<!-- 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>

出力:

TEXT
// 実行成功

8. 総合例:ShopMetricsリアルタイム通知システム

PHP
// ============================================
// 総合例: 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;
    }
}

❓ よくある質問

Q イベントと直接呼び出しの違いは何ですか?
A 直接呼び出し (A→B)は密結合で, AはBの存在を知っている必要があります。イベント (A→Event→B)は疎結合で, Aは誰が聞いているかを知りません。新しい機能はリスナーを追加するだけで済み, 送信者のコードを修正する必要はありません。
Q ShouldQueueリスナーはいつ使うべきですか?
A 時間のかかる操作 (メール送信, Webhookトリガー, レポート生成)には非同期リスナーを使用します。即時操作 (データベース更新, キャッシュクリア)には同期リスナーを使用します。非同期リスナーはリクエストをブロックしません。
Q PusherとRedisのどちらを選ぶべきですか?
A 小規模デプロイや迅速なローンチにはPusher (ホスティングサービス)を使用します。大規模デプロイやコストが懸念される場合はRedis + Laravel Echo Server (セルフホスト)を使用します。Pusherは無料枠に制限があり, Redisサーバーは固定コストです。
Q フロントエンドがブロードキャストイベントを受信しない場合はどうしますか?
A チェックリスト:1) QUEUE_CONNECTIONが"sync"に設定されていない 2) queue:workが実行中 3) Echoのkey/host設定が正しい 4) チャンネル認可がtrueを返す 5) ブロードキャストイベントがShouldBroadcastを実装している。
Q ブロードキャストイベントのデータが大きすぎる場合はどうしますか?
A broadcastWith()で必要なフィールドのみを送信します (ID + キー情報)。フロントエンドが受信後, AJAXで完全なデータを取得できます。ブロードキャストデータ内に大量の関連リソースを埋め込むのは避けてください。
Q イベントリスナーのテストはどうしますか?
A Event::fake()でイベントディスパッチをシミュレートし, イベントがディスパッチされたことをアサートします:Event::assertDispatched(OrderPlaced::class)。リスナーごとに個別のユニットテストを書きます。

📖 まとめ


📝 練習問題

  1. 基本問題 (⭐):OrderPlacedイベントとSendOrderNotificationリスナーを作成し, 注文作成時にトリガーしてください。Event::fake()を使ってイベントがディスパッチされたことを確認するテストを書いてください。

  2. 応用問題 (⭐⭐):OrderPlacedイベントにShouldBroadcastブロードキャストを実装し, Redisブロードキャストドライバを設定し, フロントエンドでLaravel Echoを使ってprivate-tenant.{id}チャンネルをリッスンして新規注文のリアルタイム通知を表示してください。

  3. チャレンジ (⭐⭐⭐):プレゼンスチャンネルを使ったマルチユーザーコラボレーションダッシュボードを実装してください。複数ユーザーが同じ店舗データを同時に表示する際, オンラインユーザーリストを表示し, 1人のユーザーがデータを変更すると他のユーザーにリアルタイムで変更が反映されるようにします (注文ステータス変更のブロードキャスト)。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%