サイトアイコン IT & ライフハックブログ|学びと実践のためのアイデア集

【実務完全ガイド】Laravelのイベント駆動設計――Event・Listener・Subscriber・Broadcasting・副作用の分離・テスト・アクセシブルな通知設計

php elephant sticker

Photo by RealToughCandy.com on Pexels.com

【実務完全ガイド】Laravelのイベント駆動設計――Event・Listener・Subscriber・Broadcasting・副作用の分離・テスト・アクセシブルな通知設計

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

  • Laravel の Event / Listener / Subscriber の役割と、どこまでイベント化すべきかの判断基準
  • 「注文作成後にメール送信・在庫更新・監査ログ保存」などの副作用を、読みやすく分離する設計
  • 同期イベントとキュー実行の使い分け、失敗時の考え方、冪等性の基本
  • ドメインイベントとフレームワークイベントを混同しない整理方法
  • Broadcasting や通知とつなげるときの設計、ライブ更新とアクセシビリティの注意点
  • Event / Listener のテスト戦略と、運用で困らないログ・監視の考え方

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

  • Laravel 初〜中級エンジニア:コントローラやサービスに副作用が集まりすぎて、修正が怖くなってきた方
  • テックリード:イベント駆動を導入したいけれど、過剰設計や見通しの悪化は避けたい方
  • QA / 保守担当:メール送信や監査ログなどの副作用を、テストしやすく壊れにくい形に整理したい方
  • デザイナー / CS / アクセシビリティ担当:通知やライブ更新を、だれでも理解しやすい形で設計したい方

アクセシビリティレベル:★★★★★
イベント駆動そのものはバックエンド設計ですが、結果として画面通知やライブ更新、処理完了メッセージの品質に直結します。この記事では、role="status"role="alert"、色に依存しない状態表示、過剰な自動更新を避ける方針まで含めて整理します。


1. はじめに:イベントは「かっこいい設計」ではなく、副作用を整理するための道具です

Laravel で開発を続けていると、ある処理の完了後に別の処理をいくつも実行したくなる場面が増えてまいります。たとえば注文作成後に、確認メールを送り、在庫を減らし、監査ログを残し、管理者へ通知し、場合によっては外部システムへ同期したい、といった具合です。最初はコントローラやサービスの最後に順番に書いても動きますが、機能が増えるほど「主処理」と「副作用」が混ざり、どこを直せばよいか分かりにくくなります。

そこで役立つのがイベント駆動設計です。イベントは、処理の中心にある事実、つまり「何が起きたか」を表し、それに反応して後続処理を分離できます。大切なのは、イベント駆動を流行の設計手法として使うのではなく、副作用の整理保守性の向上のために使うことです。この記事では、その考え方を Laravel の Event / Listener を軸に、実務で迷いにくい形へ落とし込んでいきます。


2. まず理解したい:イベントとは「起きたこと」、リスナーとは「反応するもの」です

Laravel のイベントは、とても素朴に考えると理解しやすいです。

  • Event
    • 何かが起きた、という事実を表すもの
    • 例:OrderPlacedUserRegisteredArticlePublished
  • Listener
    • その事実に反応して行う処理
    • 例:確認メールを送る、監査ログを残す、在庫を更新する

この分け方の良いところは、「注文を作る」という主目的と、「注文作成後にやること」を分離できる点です。たとえば注文作成の成否と、メール送信の成否は本来別の関心事です。これを全部ひとつのメソッドに詰め込むと、どれかひとつが壊れたときに影響範囲が大きくなります。

ただし、何でもイベント化すれば良いわけではありません。イベントに向いているのは、ある事実が起きた後に、複数の副作用がぶら下がるケースです。逆に、主処理そのものや、常に必須で一体となっている処理まで全部イベントへ逃がすと、コードの流れが見えにくくなります。ここは後ほど判断基準を整理いたします。


3. 典型例で考える:注文作成処理をイベント駆動へ分ける

まず、イベントを使わない素直な実装を考えてみます。

public function store(StoreOrderRequest $request, CreateOrderAction $action)
{
    $order = $action->execute($request->user(), $request->validated());

    Mail::to($order->user->email)->queue(new OrderPlacedMail($order->id));
    app(InventoryService::class)->decreaseByOrder($order);
    AuditLog::create([
        'actor_user_id' => $request->user()->id,
        'action' => 'order.created',
        'target_type' => Order::class,
        'target_id' => $order->id,
    ]);

    return redirect()
        ->route('orders.show', $order)
        ->with('status', '注文を受け付けました。');
}

このコードは最初は分かりやすいのですが、通知先が増えたり、外部連携が追加されたりすると、すぐに長くなります。そこで、「注文が作成された」という事実をイベントにし、その後の副作用を分離します。

3.1 Event を定義する

namespace App\Events;

use App\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class OrderPlaced
{
    use Dispatchable, SerializesModels;

    public function __construct(public Order $order)
    {
    }
}

3.2 発火側をシンプルにする

public function store(StoreOrderRequest $request, CreateOrderAction $action)
{
    $order = $action->execute($request->user(), $request->validated());

    event(new \App\Events\OrderPlaced($order));

    return redirect()
        ->route('orders.show', $order)
        ->with('status', '注文を受け付けました。');
}

3.3 Listener を分ける

namespace App\Listeners;

use App\Events\OrderPlaced;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Mail;
use App\Mail\OrderPlacedMail;

class SendOrderPlacedMail implements ShouldQueue
{
    public function handle(OrderPlaced $event): void
    {
        Mail::to($event->order->user->email)
            ->queue(new OrderPlacedMail($event->order->id));
    }
}
namespace App\Listeners;

use App\Events\OrderPlaced;
use App\Services\InventoryService;

class DecreaseInventory
{
    public function __construct(
        private InventoryService $inventoryService
    ) {}

    public function handle(OrderPlaced $event): void
    {
        $this->inventoryService->decreaseByOrder($event->order);
    }
}
namespace App\Listeners;

use App\Events\OrderPlaced;
use App\Models\AuditLog;
use App\Models\Order;

class WriteOrderAuditLog
{
    public function handle(OrderPlaced $event): void
    {
        AuditLog::create([
            'actor_user_id' => $event->order->user_id,
            'action' => 'order.created',
            'target_type' => Order::class,
            'target_id' => $event->order->id,
        ]);
    }
}

この形にすると、注文作成そのものと、その後の副作用がきれいに分かれます。コントローラも Action も短くなり、影響範囲が読みやすくなります。


4. イベントに向いている処理、向いていない処理

イベント駆動が便利だからといって、全部をイベントへ逃がすと逆に分かりにくくなります。そこで、まず判断基準を持っておくと安心です。

イベントに向いている処理

  • ある事実の後に、複数の副作用がぶら下がる
  • 副作用同士が疎結合でよい
  • 一部の処理を後から追加・削除したくなる
  • 通知、監査ログ、外部同期、検索インデックス更新のような補助的処理
  • 多少遅れても問題ない処理をキュー化したい

イベントに向いていない処理

  • 主処理そのもの
  • 成功と失敗が絶対に一体であるべき処理
  • トランザクションの中で厳密に同時完了させたい処理
  • 順序や整合性が非常に厳格で、見えない分離が危険な処理

たとえば「注文を作る」こと自体は主処理です。一方で「注文作成後に管理者へ通知する」は副作用です。ここを分けると整理されます。逆に「注文レコードを作る」「注文明細を作る」「合計金額を保存する」あたりは、ひとつの業務処理として Action や Service の中に留めた方が自然です。


5. ドメインイベントと Laravel のイベントを混同しない考え方

実務で少し混乱しやすいのが、「ドメインイベント」と「Laravel のイベント」は必ずしも同じではない、という点です。

  • ドメインイベント
    • 業務上の意味を持つ事実
    • 例:OrderPlacedSubscriptionRenewedUserSuspended
  • フレームワークイベント
    • Laravel が内部で提供するイベントや、技術的な都合のイベント
    • 例:ログイン成功、ジョブ失敗、モデル保存後のフックなど

まずは難しく考えすぎず、業務上意味のある事実をイベント名にするのが分かりやすいです。OrderSaved より OrderPlaced の方が、何が起きたのか伝わりやすいですし、後から読む人にも優しいです。
技術都合のイベントは、名前も処理も業務イベントとは分けて考えると混乱が減ります。


6. Listener をキュー化する:重い副作用は同期で抱え込まない

イベント駆動にすると、つい Listener の中に重い処理を書きたくなります。ですが、メール送信、外部API連携、画像生成、集計のような重い副作用は、同期で実行すると主処理を待たせます。そこで ShouldQueue を使って、Listener 自体をキューへ回す設計が有効です。

class SendOrderPlacedMail implements ShouldQueue
{
    public int $tries = 5;
    public int $timeout = 60;

    public function backoff(): array
    {
        return [10, 30, 60, 120];
    }

    public function handle(OrderPlaced $event): void
    {
        Mail::to($event->order->user->email)
            ->queue(new OrderPlacedMail($event->order->id));
    }
}

この形にすると、注文作成の本体は素早く完了し、後続処理は非同期で進みます。運用上も、失敗ジョブや Horizon で監視しやすくなるため、主処理と副作用の切り分けが楽になります。

ただし、「絶対に注文作成と同時に終わっていないと困る」処理までキューへ逃がすのは危険です。同期に残すものと非同期にするものは、ユーザー影響と整合性で判断すると安心です。


7. 失敗時の考え方:リスナーの失敗が主処理を巻き込むかどうかを決める

イベント駆動で重要なのは、「後続処理が失敗したら、主処理も失敗にするのか」を事前に決めることです。
たとえば注文作成後に管理者通知が失敗したとしても、注文そのものは成立していてほしいことが多いです。一方で、在庫更新が失敗したらその注文は危険かもしれません。ここは一律ではなく、処理ごとに考える必要があります。

整理の例

  • 注文作成
    • 必須:注文本体、明細、決済確定
    • 重要だが後続:在庫更新
    • 補助的:確認メール、監査ログ、管理者通知
  • ユーザー登録
    • 必須:ユーザー作成
    • 後続:ウェルカムメール、分析イベント送信、CRM 同期

このように分けておくと、どこまでをトランザクションの中で扱い、どこからをイベントで分離するかが見えやすくなります。


8. 冪等性:イベントやリスナーは再実行されても壊れないようにする

イベントやキューは、再試行や重複発火が起こり得ます。ですので、Listener は二重実行されても壊れないように設計する方が安全です。

たとえば確認メールなら、重要通知は「送信済みフラグ」を持つと安心です。

class SendOrderPlacedMail implements ShouldQueue
{
    public function handle(OrderPlaced $event): void
    {
        $order = $event->order->fresh();

        if ($order->confirmation_mail_sent_at) {
            return;
        }

        Mail::to($order->user->email)
            ->send(new OrderPlacedMail($order->id));

        $order->forceFill([
            'confirmation_mail_sent_at' => now(),
        ])->save();
    }
}

このようにしておくと、再実行や重複ジョブでも二重送信を防げます。イベント駆動は便利ですが、「同じイベントが2回処理されても大丈夫か」を考える習慣があると、運用時の安心感がかなり違います。


9. Subscriber:イベントが増えてきたときの整理先

Listener が増えてくると、関連するイベント群をひとまとまりで管理したくなることがあります。そこで使えるのが Subscriber です。
Subscriber は、複数のイベントに対して、ひとつのクラスで購読関係をまとめる仕組みです。

たとえば、監査ログ系のイベントをひとつに集めたい場合、次のように整理できます。

namespace App\Listeners;

use App\Events\OrderPlaced;
use App\Events\UserSuspended;
use App\Models\AuditLog;

class AuditSubscriber
{
    public function handleOrderPlaced(OrderPlaced $event): void
    {
        AuditLog::create([
            'action' => 'order.created',
            'target_id' => $event->order->id,
        ]);
    }

    public function handleUserSuspended(UserSuspended $event): void
    {
        AuditLog::create([
            'action' => 'user.suspended',
            'target_id' => $event->user->id,
        ]);
    }

    public function subscribe($events): void
    {
        $events->listen(
            OrderPlaced::class,
            [self::class, 'handleOrderPlaced']
        );

        $events->listen(
            UserSuspended::class,
            [self::class, 'handleUserSuspended']
        );
    }
}

すべてを Subscriber にする必要はありませんが、「この種類のイベントはまとまっている方が見やすい」というものに限って使うと、構造がきれいになります。


10. Broadcasting やリアルタイム通知へつなぐときの注意点

イベントは、画面へのライブ通知や Broadcasting と組み合わせることもできます。たとえば「エクスポート完了」「新しい注文が入った」「コメントが追加された」といった通知を、リアルタイムに画面へ出したい場面があります。

ただし、リアルタイム更新は便利な一方で、アクセシビリティや認知負荷の面で注意が必要です。

  • 勝手に画面が大きく変わらないようにする
  • 重要でない更新を alert にしない
  • 数値や件数をテキストで明示する
  • 色だけで通知状態を区別しない
  • 自動更新があっても、ユーザーのフォーカスを奪わない

UI 側の最小例

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

リアルタイム通知が来たときは、この領域に短く意味のある文言を入れます。
悪い例は「更新されました」だけの通知です。
良い例は「新しい注文が1件追加されました」「エクスポートが完了しました。ダウンロードできます」のように、次の行動が分かる通知です。


11. イベント設計とアクセシブルな通知設計は相性が良いです

イベント駆動設計が整うと、UI 側で何を通知すべきかが明確になります。たとえば ExportFinished というイベントがあるなら、画面では「完了通知」を出せばよいですし、UserSuspended なら管理画面では「停止しました」を role="status" で出す設計にできます。

つまり、バックエンドの事実が整理されるほど、UI の状態設計もぶれにくくなります。結果として、次のような一貫性が生まれます。

  • 成功:role="status"
  • 重要な失敗:role="alert"
  • 色だけに頼らない文言
  • 再試行や次の行動が分かるメッセージ

この一貫性は、利用者にとっても、QA や CS にとっても大きな助けになります。


12. ログと監視:イベントは見えにくいので、可観測性が大切です

イベント駆動設計の弱点は、処理の流れがコード上では追いにくくなりやすいことです。ですので、最低限のログや監視を整えておくと安心です。

たとえば重要イベントでは、次のような情報を構造化ログに残しておくと調査が速くなります。

  • event
  • 対象ID(order_id, user_id など)
  • trace_id
  • 実行した listener 名
  • 結果(success / failed)

たとえばジョブ失敗や通知未送信を追うときに、「どのイベントから派生したものか」が見えると、原因の切り分けがかなり楽になります。


13. テスト:イベントをテストするときの考え方

イベント駆動設計は、設計がきれいでもテストが無いと安心できません。Laravel では Event::fake() や Listener の個別テストが使えます。

13.1 イベントが発火されたかをテストする

use Illuminate\Support\Facades\Event;

public function test_order_placed_event_is_dispatched()
{
    Event::fake();

    $user = User::factory()->create();

    $response = $this->actingAs($user)->post('/orders', [
        'total_amount' => 1000,
        'items' => [
            ['product_id' => 1, 'quantity' => 1, 'price' => 1000],
        ],
    ]);

    $response->assertRedirect();

    Event::assertDispatched(\App\Events\OrderPlaced::class);
}

13.2 Listener を個別にテストする

public function test_send_order_mail_listener_marks_sent_at()
{
    $order = Order::factory()->create([
        'confirmation_mail_sent_at' => null,
    ]);

    Mail::fake();

    $listener = app(\App\Listeners\SendOrderPlacedMail::class);
    $listener->handle(new \App\Events\OrderPlaced($order));

    $this->assertNotNull($order->fresh()->confirmation_mail_sent_at);
}

イベント発火のテストと、Listener のテストを分けると、壊れた場所が特定しやすくなります。


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

14.1 何でもイベントにして流れが見えなくなる

回避策として、主処理は Action や Service に残し、副作用だけをイベントへ分けます。

14.2 イベント名が技術寄りで意味が伝わらない

OrderSaved より OrderPlaced のように、業務上の意味が伝わる名前を付けると読みやすくなります。

14.3 Listener が重すぎて主処理を遅くする

重い処理は ShouldQueue を使ってキュー化し、同期で抱え込まない方が安全です。

14.4 再試行で二重通知や二重連携が起きる

送信済みフラグ、一意制約、ロックなどで冪等性を確保しておくと安心です。

14.5 失敗時の影響範囲が分からない

イベント名、対象ID、trace_id をログに残し、Horizon や例外監視で追える状態を作っておくと調査が速くなります。


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

設計

  • [ ] 主処理と副作用を分けて考えている
  • [ ] イベント名が業務上の事実を表している
  • [ ] Listener は1目的で短く保たれている
  • [ ] 何でもイベント化せず、主処理は Service / Action に残している

信頼性

  • [ ] 重い Listener はキュー化している
  • [ ] 冪等性を意識した設計になっている
  • [ ] 失敗時に主処理を巻き込むかどうかを決めている
  • [ ] ログに event 名・対象ID・trace_id が残る

UI / アクセシビリティ

  • [ ] 成功通知は role="status" で統一できる
  • [ ] 重要な失敗は role="alert" で伝える
  • [ ] リアルタイム更新でフォーカスを奪わない
  • [ ] 色だけで状態を伝えない

テスト

  • [ ] Event 発火のテストがある
  • [ ] 重要 Listener の個別テストがある
  • [ ] 冪等性や二重実行防止のテストがある

16. まとめ

Laravel のイベント駆動設計は、コードを難しくするためのものではなく、主処理と副作用を分けて、保守しやすくするための道具です。OrderPlacedUserRegistered のような業務上の事実を軸にすると、メール送信、監査ログ、外部同期、通知といった後続処理を自然に整理できます。重い処理はキューへ逃がし、失敗時の影響範囲と冪等性を意識しておくと、運用でも落ち着いた設計になります。さらに、イベントが整理されると UI の通知も一貫しやすくなり、アクセシブルな状態表示へつなげやすくなります。まずは、今コントローラやサービスの末尾に並んでいる副作用をひとつ見直して、イベントへ切り出してみるのがおすすめです。


参考リンク

モバイルバージョンを終了