Laravelのイベントとリスナーでアプリを拡張する:疎結合にして変更に強い実装へ

Laravelのイベントとリスナーでアプリを拡張する:疎結合にして変更に強い実装へ

Laravelのイベント/リスナーは「ある出来事が起きたら、関連処理をあとから追加できる」仕組み。コントローラやサービスに処理を詰め込まず、イベントを境界にして副作用(通知、ログ、外部API連携、集計更新など)を分離すると、機能追加や仕様変更が安全に進む。この記事は、イベント設計の考え方から、実装・テスト・キュー化・運用の注意点までを、実務でそのまま使える形でまとめる。

イベントで何が変わるか:責務の分離と“後付け拡張”が成立する

イベントを使わない構成では「ユーザー登録→メール送信→Slack通知→初期データ作成→分析ログ…」が1箇所に集まりやすい。イベントを導入すると「登録が完了した」という事実だけを発行し、後続処理はリスナーへ分離できる。
結果として、追加機能は“リスナーを増やす”だけになり、既存処理の破壊が減る。

発生条件:イベントが効く場面と、逆に重くなる場面

効く場面:
・副作用が増え続ける(通知、ログ、外部連携、集計、キャッシュ更新)
・同じ“出来事”に対して処理が複数ある
・機能追加の頻度が高い
重くなる場面:
・イベント名やpayloadが場当たり的で、追跡できない
・重要処理まで全部イベントにして、失敗が見えなくなる
・同期/非同期の設計が無く、遅延や二重実行が増える
「何をイベントにするか」を決めないと分散しすぎる。

設計の基本:イベント名は“過去形の事実”にする

イベントは「Command(やれ)」ではなく「Event(起きた)」にする。
例:UserRegistered、OrderPlaced、PaymentSucceeded、InvoiceIssued。
過去形の事実にすると、リスナーが増えても意味が壊れにくい。

payload設計:必要最小限+再取得できるIDが基本

イベントに巨大な配列やモデル丸ごとを詰めると、シリアライズや互換性で詰まりやすい。
基本は「ID + 最低限の値」。詳細はリスナーでDBから再取得する。
・DB更新後に発行する
・リスナー側は“無かったら何もしない/安全に終わる”を意識する

最小実装:ユーザー登録完了イベントを作る

まずは最小の例で全体像を作る。ユーザー登録後にイベントを発行し、リスナーでメールやログを処理する。

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

use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class UserRegistered
{
    use Dispatchable, SerializesModels;

    public function __construct(
        public int $userId
    ) {}
}

// 例:登録処理(Service/UseCase/Controllerなど)
use App\Events\UserRegistered;

$user = \App\Models\User::create($data);

// “登録が完了した”事実を発行
UserRegistered::dispatch($user->id);

リスナー実装:通知・ログ・初期化処理を分ける

同じイベントに複数リスナーをぶら下げると、処理が増えても見通しが悪くなりにくい。

// app/Listeners/SendWelcomeEmail.php
namespace App\Listeners;

use App\Events\UserRegistered;
use App\Models\User;
use Illuminate\Support\Facades\Mail;

class SendWelcomeEmail
{
    public function handle(UserRegistered $event): void
    {
        $user = User::find($event->userId);
        if (!$user) return;

        // Mail::to($user->email)->send(new WelcomeMail($user));
    }
}

// app/Listeners/LogUserRegistered.php
namespace App\Listeners;

use App\Events\UserRegistered;

class LogUserRegistered
{
    public function handle(UserRegistered $event): void
    {
        logger()->info('user.registered', [
            'user_id' => $event->userId,
        ]);
    }
}

紐付け:EventServiceProviderでイベントとリスナーを登録する

Laravelはイベントとリスナーの対応をプロバイダで管理できる。イベントが増えてもここで全体が見える。

// app/Providers/EventServiceProvider.php
namespace App\Providers;

use App\Events\UserRegistered;
use App\Listeners\SendWelcomeEmail;
use App\Listeners\LogUserRegistered;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;

class EventServiceProvider extends ServiceProvider
{
    protected $listen = [
        UserRegistered::class => [
            SendWelcomeEmail::class,
            LogUserRegistered::class,
        ],
    ];
}

イベントの発行場所:モデルイベントとドメインイベントを混ぜない

LaravelにはEloquentのモデルイベント(created/updated/deleted)もある。
・モデルイベント:DB行の変化に紐づく(保存時に自動で飛ぶ)
・ドメインイベント:業務上の出来事に紐づく(「登録完了」「支払い成功」など)
実務で増やすなら、業務用はドメインイベントとして明示的にdispatchする方が読みやすい。

同期か非同期か:重い処理はリスナーをキュー化する

メール送信、外部API、PDF生成、集計更新などは同期でやるとレスポンスが遅くなる。リスナーをキューに回すとWeb処理が軽くなる。

// app/Listeners/SendWelcomeEmail.php(キュー化)
namespace App\Listeners;

use App\Events\UserRegistered;
use Illuminate\Contracts\Queue\ShouldQueue;

class SendWelcomeEmail implements ShouldQueue
{
    public $tries = 3;        // リトライ回数
    public $backoff = 30;     // 秒(例)

    public function handle(UserRegistered $event): void
    {
        // ...
    }
}

冪等性:同じイベントが複数回処理されても壊れないようにする

キューは再試行や二重実行が起きうる。
対策の方向性:
・「すでに送った/作った」をDBで判定する(フラグ、ログテーブル)
・外部APIは冪等キーを付ける
・集計は“加算”ではなく“再計算”に寄せる
冪等性が無いと、歓迎メールが2回飛ぶ、ポイントが二重付与される、などの事故になる。

例外時の扱い:失敗を見える化して“黙って落ちる”を防ぐ

イベント処理は分散しやすく、失敗が気づかれにくい。
・キュー失敗時の通知(Slack/メール)
・failed_jobs の監視
・リスナーごとに重要度を分ける(必須処理と任意処理)
必須処理なら同期で落として即時検知、任意処理なら非同期で再試行、などの切り分けが必要。

テスト方針:イベント発行のテストとリスナーのテストを分ける

テストは2層に分けると楽。
1) 「特定操作でイベントが発行される」を検証
2) 「リスナーが期待通り動く」を検証(単体 or 結合)

// イベントが発行されたことを確認(例)
use Illuminate\Support\Facades\Event;
use App\Events\UserRegistered;

Event::fake();

// 何らかの登録処理を実行...

Event::assertDispatched(UserRegistered::class, function ($e) use ($user) {
    return $e->userId === $user->id;
});

イベントの整理術:増えたら命名規約とディレクトリで統制する

増えると破綻しやすいポイントは命名と置き場所。
・Events/ と Listeners/ を機能単位サブディレクトリで分ける(例:Events/User、Events/Order)
・イベント名は過去形、リスナー名は動詞から始める(Send…, Sync…, Record…)
・payloadはID中心、破壊的変更はバージョンを切る(例:OrderPlacedV2)

運用の現実:イベント駆動は“観測”がセット

イベントを使うほど、原因調査は「どのイベントが発火し、どのリスナーが成功/失敗したか」を追う形になる。
・リクエストIDをログに残す(入口で生成し全ログへ)
・イベント名/リスナー名/実行時間をログ化
・失敗時はpayload(最低限)を残す
これが無いと、動いていないのか遅いのかが分からない。

まとめ:イベント/リスナーで拡張しやすいLaravelにする要点

・イベントは“過去形の事実”、リスナーは“副作用”を担当
・payloadはID中心で最小化し、詳細はリスナーで再取得
・重い処理はShouldQueueで非同期化し、冪等性を必ず入れる
・必須処理と任意処理を分け、失敗を見える化する
・テストは「発行される」層と「処理する」層で分ける
・命名規約とディレクトリ統制で、増えても壊れない形にする