イベントと通知 — 注文確定から先を疎結合にする
第1章で挙げた 4 つの問題のうち、残っているのは「メールの送信時間がそのままレスポンス時間になる」です。
この問題は 2 段階で解きます。この章で「注文を作る処理」から確認メールを切り離し、次章でそれをリクエストの外へ出します。 順番に意味があります。切り離さないまま非同期にすると、何を非同期にしたのか分からないコードになるためです。
注文のあとに足されていくもの
第6章で、コントローラに 7 つの関心事が同居している状態を見ました。そのうち 6 番目が「確認メールの送信」でした。いまの OrderPlacementService には、まだメール送信が入っていません。第14章まで先送りしてきたからです。
ここで素直に足すと、こうなります。
public function place(User $user, array $items, ?string $idempotencyKey = null): Order
{
// ... 在庫の確認と減算、注文の作成 ...
Mail::to($user)->send(new OrderConfirmed($order));
return $order;
}
動きはします。問題は、このあとに何が足されるかです。
- 倉庫システムへ出荷指示を送る
- 販売実績を集計サービスへ記録する
- 初回購入ならクーポンを発行する
- 高額注文なら管理者へ通知する
どれも「注文が確定したあとにやること」です。素直に足していくと place() が伸び続けます。そして、どれか 1 つが失敗すると注文そのものが失敗します。 集計サービスが落ちているせいで注文を受け付けられない、という状態になります。
もう 1 つあります。第6章で「変更の理由が複数あるか」を切り出しの基準にしました。この 4 つは変更の理由がそれぞれ違います。クーポンの条件が変わるのはマーケティングの都合で、倉庫の連携方式が変わるのは物流の都合です。同じメソッドに置く理由がありません。
イベントで切り離す
「注文が確定した」という事実を 1 回発表し、それを聞きたい側が勝手に反応する形にします。発表する側は、誰が聞いているかを知りません。
イベントクラス
イベントは、起きたことを表すただのクラスです。振る舞いを持ちません。
<?php
namespace App\Events;
use App\Models\Order;
class OrderPlaced
{
public function __construct(
public readonly Order $order,
) {}
}
php artisan make:event OrderPlaced で作れます。第2章で見たとおり、app/Events は最初は存在せず、このコマンドで作られます。
生成されたクラスから、いくつかのトレイトを外しています。 make:event は WebSocket 配信 (ブロードキャスト) 用のトレイトと SerializesModels を付けた形で作ります。ブロードキャストはこの連載では扱いません。SerializesModels はイベントをキューに載せるときに効いてくるもので、第16章で戻します。いまは同期で動くので、無くても差がありません。
過去形の名前にしています。 OrderPlaced は「注文が確定した」という済んだ事実です。PlaceOrder (注文しろ) という命令形にすると、聞いた側が何をすべきかを発表側が決めている形になります。事実を発表するだけなら、聞く側が増えても発表側は変わりません。
発表する
サービスクラスからイベントを発行します。
第14章の place() は返り値が 3 経路に分かれています。新しく注文を作った経路だけで発行します。
use App\Events\OrderPlaced;
public function place(User $user, array $items, ?string $idempotencyKey = null): Order
{
if ($idempotencyKey !== null) {
$existing = $this->findByIdempotencyKey($user, $idempotencyKey);
if ($existing !== null) {
return $existing; // 再送。発行しない
}
}
usort($items, fn ($a, $b) => $a['product_id'] <=> $b['product_id']);
try {
$order = DB::transaction(function () use ($user, $items, $idempotencyKey) {
// ... 在庫の確認と減算、注文と明細の作成 ...
return $order;
}, attempts: 3);
} catch (UniqueConstraintViolationException $e) {
$existing = $idempotencyKey === null
? null
: $this->findByIdempotencyKey($user, $idempotencyKey);
if ($existing === null) {
throw $e;
}
return $existing; // 同時再送。発行しない
}
event(new OrderPlaced($order)); // ここだけ
return $order;
}
catch の中で return しています。 変数に代入して下へ抜ける形にすると、再送の経路でも event() に届きます。第14章で「同じ注文を 2 回作らない」ために書いた分岐が、そのまま「同じ事実を 2 回発表しない」ためにも働くよう、経路を分けたまま保ちます。
place() に足されるのはこの 1 行だけです。倉庫連携が増えてもクーポン発行が増えても、この行は変わりません。
再送の 2 経路では発行しません。 「注文が確定した」という事実は 1 回しか起きていないので、発表も 1 回です。ここで発行すると、再送のたびに確認メールが届きます。第14章で一意制約とキーの検索を入れたことが、そのまま「イベントを 2 回出さない」ことにもなっています。
トランザクションの外に置いています。 第14章で「トランザクションの中でメール送信や外部 API 呼び出しをしない」と決めました。イベントの発行そのものは軽い処理ですが、それを聞いたリスナーがメールを送ります。中に置けば、結局トランザクションの中でメールを送ることになります。再試行が起これば、そのたびに送られます。
リスナークラス
聞く側を作ります。php artisan make:listener SendOrderConfirmation --event=OrderPlaced で生成できます。
<?php
namespace App\Listeners;
use App\Events\OrderPlaced;
use App\Notifications\OrderConfirmed;
class SendOrderConfirmation
{
public function handle(OrderPlaced $event): void
{
$event->order->user->notify(new OrderConfirmed($event->order));
}
}
$event->order->user を使うので、Order 側にリレーションを足します。第12章で User に orders() を定義したのと対になる向きです。
use Illuminate\Database\Eloquent\Relations\BelongsTo;
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
倉庫連携やクーポン発行も、同じように別のリスナークラスとして足します。place() とイベントクラスは、どちらも手つかずのままです。
登録は要りません
第2章で、EventServiceProvider の $listen プロパティにイベントとリスナーの対応を書く方式が無くなったと触れました。いまは命名規約による自動検出です1。
Laravel は app/Listeners を走査し、handle または __invoke で始まるメソッドを見つけると、その引数に型宣言されたクラスのリスナーとして登録します。上の例なら handle(OrderPlaced $event) の型宣言だけで対応が決まります。
対応表を書かなくてよくなった代わりに、どのイベントに誰が反応するかが一覧できなくなりました。app/Listeners を開いて型宣言を読むまで分かりません。リスナーが増えてきたら、php artisan event:list で登録済みの対応を確認できます。
自動検出に頼らず、明示的に登録する方法もあります。AppServiceProvider の boot メソッドで Event::listen() を呼びます1。register ではありません — 第2章で扱った理由がそのまま当てはまります。
通知を作る
メールの中身は Notification クラスに書きます。php artisan make:notification OrderConfirmed で生成します。
<?php
namespace App\Notifications;
use App\Models\Order;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class OrderConfirmed extends Notification
{
public function __construct(
public readonly Order $order,
) {}
/**
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject("ご注文ありがとうございます (注文番号: {$this->order->id})")
->greeting('ご注文を承りました')
->line("合計金額: {$this->order->total_amount} 円")
->action('注文の詳細を見る', url("/orders/{$this->order->id}"))
->line('発送の準備ができ次第、あらためてご連絡します。');
}
}
via() が送信経路を決めます2。ここでは mail だけですが、['mail', 'database'] のように複数を返せます。SMS やプッシュ通知を足すときも、変えるのはこのメソッドと対応する toXxx メソッドだけです。リスナー側は変わりません。
$notifiable は通知を受け取る対象です。User モデルが Notifiable トレイトを使っていれば、$user->notify(...) で送れます。宛先のメールアドレスは email カラムから自動的に解決されます。
Mailable が要るとき
MailMessage は決まった形のメールを組み立てます。件名・あいさつ・本文の行・ボタン、という構成です。多くの通知はこれで足ります。
独自のレイアウトが要るときは Mailable を使います。 toMail() から Mailable を返せます2。php artisan make:mail OrderConfirmedMail --markdown=mail.orders.confirmed で本体と Blade テンプレートを生成し、こう返します。
public function toMail(object $notifiable): Mailable
{
return (new OrderConfirmedMail($this->order))->to($notifiable->email);
}
OrderConfirmedMail はこの連載では作りません。 形だけ示しています。
Mailable なら Blade テンプレートを自由に書けます。ストレージ上のファイルを添付する attachFromStorage も Mailable 側の機能です2。領収書 PDF を添付する場合はこちらになります。ファイルストレージは第19章で扱います。
この連載では MailMessage のまま進みます。 見た目を作り込む段階ではなく、送信の経路を設計する段階だからです。
切り離しても、まだ速くなりません
ここまでで place() は整理されました。レスポンスタイムは 1 ミリ秒も改善していません。
イベントとリスナーは、既定では同期で動きます。event() を呼んだ時点で、その場でリスナーが実行されます。呼び出し元は、リスナーが終わるまで次の行へ進みません。
リクエスト受信
→ 在庫確認・注文作成 (50ms)
→ event(new OrderPlaced($order))
→ SendOrderConfirmation が動く
→ SMTP サーバーへ接続して送信 (3000ms)
→ レスポンス返却
合計時間は足す前と同じです。 変わったのは、コードの置き場所だけです。
これは失敗ではありません。切り離しと非同期化は別の作業で、この順番でやる価値があります。 いま SendOrderConfirmation を非同期にすれば、注文処理の本体には何の影響もありません。切り離す前に非同期化しようとすると、「どこからどこまでを後回しにするのか」の線を引くところから始めることになります。
線は引けました。それを動かすのが第16章です。
ShouldQueue を付ければ非同期になりますリスナークラスに Illuminate\Contracts\Queue\ShouldQueue を実装すると、Laravel はそのリスナーをキューへ送ります1。この章では付けません。 キューを動かすにはワーカープロセスが要り、失敗したジョブの扱いも決める必要があります。仕組みを理解しないまま付けると、送られないメールに気づけません。第16章でまとめて扱います。
どれだけ待っているのか測る
「メール送信が遅い」は感覚で語りやすい話です。手を入れる前に測ってください。
いちばん簡単なのは、送信の前後で時刻を取ることです。
public function handle(OrderPlaced $event): void
{
$startedAt = microtime(true);
$event->order->user->notify(new OrderConfirmed($event->order));
Log::info('注文確認メールを送信しました', [
'order_id' => $event->order->id,
'duration_ms' => (int) ((microtime(true) - $startedAt) * 1000),
]);
}
第9章で決めたログの方針に従い、構造化した値として残します。duration_ms が数値で入っていれば、あとから集計できます。
数値は環境によって大きく変わります。 手元の開発環境で log ドライバを使っていれば、メール送信はファイルへの書き込みなので一瞬で終わります。本番で外部の SMTP サービスを使えば、ネットワークの往復とサービス側の処理が入ります。この連載では具体的な数値を示しません。自分の環境で測った値だけが判断の材料になります。
測るときに見るのは平均だけではありません。平均が 200 ミリ秒でも、20 回に 1 回 5 秒かかるなら、その 5 秒を待たされた人がいます。 最大値と、上位 5 パーセントあたりの値を見てください。
.env の MAIL_MAILER を log にすると、送信内容が storage/logs/laravel.log に書き出されます。実際には送信されないので、テスト用のアドレスへ誤送信する心配がありません。
HTML の見た目まで確認したいなら、受信箱を模したサービス (Mailpit など) をローカルで動かす方法もあります。
トランザクションとの関係
第14章で place() をトランザクションで囲みました。イベントの発行位置を間違えると、そこが壊れます。
return DB::transaction(function () use ($user, $items) {
// ... 在庫の減算、注文の作成 ...
event(new OrderPlaced($order)); // ここで同期リスナーが走る
return $order;
}, attempts: 3);
3 つの問題が同時に起きます。
メールがトランザクションの中で送られます。 リスナーは同期で動くので、SMTP との通信が終わるまでトランザクションが開いたままになります。第14章で扱ったとおり、ロックを握る時間が延びて他のリクエストが待たされます。
デッドロックで再試行されると、メールが複数回送られます。 attempts: 3 はクロージャを頭から実行し直します。データベースへの書き込みは巻き戻りますが、送信済みのメールは戻りません。
ロールバックしたのにメールだけ届きます。 明細の作成に失敗して注文が取り消されても、その前に発行したイベントのリスナーは既に走り終えています。「注文できていないのに確認メールが届いた」という問い合わせになります。
トランザクションを抜けてから発行します。 注文が確定した事実を発表するのだから、確定してから発表するのが素直です。
コミット後まで遅らせる仕組みもあります
イベントクラスに ShouldDispatchAfterCommit を実装すると、トランザクションの中で event() を呼んでも、ディスパッチがコミット後まで遅れます3。ロールバックすればディスパッチそのものが起きません。
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
class OrderPlaced implements ShouldDispatchAfterCommit
{
// ...
}
この連載では使いません。 発行位置を目で追えるほうが、読む人にとって分かりやすいためです。event() がトランザクションの中にあるのに実行はあとから、という状態は、インターフェースを知らない読み手には追えません。
ただし、発行位置を自分で選べない場合には有効です。他のパッケージやフレームワーク側が発火の位置を決めている場合、event() の行を動かせません。トランザクションの外へ出せないなら、こちらを使ってください。
聞く側で遅らせる ShouldHandleEventsAfterCommit もあります3。イベントクラスを触れないとき (パッケージが提供しているイベントなど) は、リスナー側に実装します。
なお、キューに載せたジョブの投入を遅らせる afterCommit は別の設定です。第16章で扱います。
テスト
第10章で予告した Mail::fake() と Notification::fake() のうち、この章で実際に使うのは Notification::fake() です。通知の層で送っているからです。 2 つの使い分けは、この節の最後に整理します。
通知が送られることを確認する
use App\Notifications\OrderConfirmed;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Laravel\Sanctum\Sanctum;
pest()->use(RefreshDatabase::class);
test('注文すると確認通知が送られる', function () {
Notification::fake();
$user = User::factory()->create();
Sanctum::actingAs($user);
$product = Product::factory()->create(['price' => 1200]);
Stock::factory()->create(['product_id' => $product->id, 'quantity' => 10]);
$this->postJson('/api/v1/orders', [
'items' => [['product_id' => $product->id, 'quantity' => 3]],
])->assertStatus(201);
Notification::assertSentTo($user, OrderConfirmed::class);
});
Notification::fake() を最初に呼んでいます2。これ以降、通知は実際には送られず、送ろうとした記録だけが残ります。assertSentTo で「誰に」「どの通知が」送られたかを確認します。
fake() を書き忘れても、標準の構成なら本物のメールは飛びません。 Laravel の phpunit.xml は MAIL_MAILER を array に設定しており、テスト中の送信はメモリ上に溜まるだけです。ただしこれは設定に守られているだけです。phpunit.xml を書き換えたり、外部サービスを直接叩くチャネル (SMS やプッシュ通知) を足したりすれば、その守りは消えます。fake() は自分で書いてください。
注文が失敗したら送られないことを確認する
送られることより、送られないべきときに送られないことのほうが見落とされます。
test('在庫が足りないときは通知が送られない', function () {
Notification::fake();
Sanctum::actingAs(User::factory()->create());
$product = Product::factory()->create();
Stock::factory()->soldOut()->create(['product_id' => $product->id]);
$this->postJson('/api/v1/orders', [
'items' => [['product_id' => $product->id, 'quantity' => 1]],
])->assertStatus(409);
Notification::assertNothingSent();
});
このテストが捕まえるのは、注文の処理に入る前に通知を送ってしまう実装です。バリデーションを通った時点でコントローラが送っているような場合に red になります。
event() をトランザクションの中に移しても、このテストは green のままです。在庫切れの経路では assertEnough が注文を作る前に例外を投げるので、そもそも event() の行に到達しません。第14章でも、同じテストがトランザクションの有無を検証しないことを書きました。
発行位置の誤り (ロールバックしたのに通知が届く) を踏むには、注文の作成に成功したあとで失敗する必要があります。第14章で扱ったとおり、その状況は単一プロセスでは作れません。位置の正しさはテストでなく、コードを読んで守ってください。
通知の中身を確認する
件名や本文まで見たいときは、assertSentTo の第 3 引数にクロージャを渡します。
Notification::assertSentTo($user, OrderConfirmed::class, function ($notification) use ($order) {
return $notification->order->id === $order->id;
});
本文の文字列そのものを固定するかは、慎重に決めてください。 文言は頻繁に変わります。変えるたびにテストが赤くなると、テストを直す作業が増えるだけで、守れているものは増えません。固定する価値があるのは「どの注文の通知か」のような、間違えると実害が出る部分です。
Mail::fake() との使い分けMail::fake() は Mailable を対象にします。この章のように Notification 経由で送っている場合、Mail::assertSent() では捕まえられません。送っている層に合わせて fake を選びます。
toMail() から Mailable を返す形にしたときは、Notification::fake() を使えば通知の送信を、Mail::fake() を使えば Mailable の送信を確認できます。
本番で効く注意点
リスナーの失敗が注文を巻き込みます
同期リスナーで例外が投げられると、それは event() の呼び出し元まで伝わります。メールサーバーが落ちていれば、注文 API が 500 を返します。 データベースへの書き込みは終わっているので、注文は作られているのにエラーが返る形になります。
第16章でキューへ移せばこの問題は消えます。それまでのあいだ、リスナーの中で例外を握りつぶすかどうかは判断が要ります。握りつぶせば注文は通りますが、メールが送られなかった事実が誰にも伝わりません。第9章で扱った「想定内のエラーをどう記録するか」と同じ問題です。
第14章の冪等性と組み合わさると、もう 1 つ厄介な形になります。
- 注文が作られ、コミットされる
- リスナーがメール送信に失敗し、例外が 500 になって返る
- クライアントが同じ
Idempotency-Keyで再送する - 既存の注文が見つかるので、イベントを発行せずに 200 を返す
再送は正しく動いています。注文は 1 件のままです。それでも確認メールは永久に送られません。 発行しないのが正しい経路なので、何度再送しても変わりません。
同期リスナーである以上、この穴は塞げません。送信の失敗を注文の失敗と切り離し、失敗しても後から再実行できる場所へ移す必要があります。 それがキューです。第16章で扱います。
イベントを増やしすぎないでください
何でもイベントにすると、処理の流れが読めなくなります。コードを追っても、次に何が起きるか分かりません。 event:list を見るまで分からない状態は、可読性の観点では後退です。
イベントが向くのは、発表側が聞き手を知らなくてよい場合です。「注文が確定した」に反応したい部署が複数ある状況がそれです。逆に、必ず 1 つの処理が続くだけなら、素直にメソッドを呼ぶほうが読めます。
モデルのイベントとは別物です
Eloquent には created / updated のようなモデルイベントもあります。名前が似ていますが、発火の条件が違います。
モデルイベントは行が保存されたことに反応します。業務上の意味を持ちません。管理画面からの登録でも、データ移行のスクリプトでも、同じように発火します。created に確認メールの送信を結び付けると、移行スクリプトが 1 件ずつ save() した瞬間に、過去の注文の確認メールが全件飛びます。
逆向きの落とし穴もあります。一括操作では発火しません。 公式ドキュメントは「一括更新・一括削除では、対象のモデルが実際に取得されないため saved / updated / deleting / deleted は発行されない」と明記しています4。Model::insert() による一括登録や saveQuietly() でも発火しません。
つまりモデルイベントは、業務上の出来事が起きたのに発火しないことも、起きていないのに発火することもあります。どちらの向きにもずれます。業務上の出来事は、業務の処理から明示的に発行してください。
通知の宛先を間違えない
$user->notify() は User の email カラムへ送ります。第11章で customer_email を注文側に残したのは、注文時のアドレスを記録として保つためでした。
会員がメールアドレスを変更したあとに過去の注文の通知を送る場合、どちらへ送るべきかは業務の判断です。発送通知なら現在のアドレス、注文時の控えの再送なら注文時のアドレス、という分け方があります。どちらでもよい場面はありません。 決めて、コードに書いてください。
まとめ
- 「注文が確定した」という事実を 1 回発表し、反応したい側がそれぞれ聞く形にする
- イベントは過去形の名前を付ける。命令形にすると発表側が聞き手の仕事を決めることになる
- リスナーの登録は要らない。
app/Listenersを走査し、handleの引数の型で対応が決まる - 引き換えに対応関係が一覧できなくなる。
event:listで確認する - メールの中身は Notification に書く。
via()が経路を決めるので、経路が増えてもリスナーは変わらない - イベントに切り出しても速くならない。既定のリスナーは同期で、その場で実行される
- イベントはトランザクションの外で発行する。中で出すと、ロールバックしてもメールだけ届く
- 位置を選べないときは
ShouldDispatchAfterCommit(発行側) かShouldHandleEventsAfterCommit(聞く側) で遅らせる - 再送の経路では発行しない。事実は 1 回しか起きていない
- モデルイベントは業務上の出来事と一致しない。一括操作では発火せず、移行スクリプトでは発火する
- 手を入れる前に測る。平均だけでなく最大値と上位の値を見る
- テストは
Notification::fake()を先に呼ぶ。送られないべきときに送られないことも固定する - ただし発行位置の誤りはテストで捕まえられない。コードを読んで守る
- 同期リスナーが失敗すると、確認メールは再送しても届かない。冪等な再送は既存の注文を返すだけで、イベントを出し直さない
次に読む
切り離しは終わりましたが、利用者の待ち時間はまだ変わっていません。次章 キューの基礎 — 重い処理をリクエストの外に出す では、いま同期で走っているリスナーをキューへ移します。ワーカープロセスの動かし方、ジョブが失敗したときの扱い、そして第14章で触れた afterCommit を扱います。ここで引いた線が、そのまま非同期にする単位になります。
練習問題
次のコードには、注文が失敗したときに問題が起きます。何が起きるか説明してください
public function place(User $user, array $items): Order
{
return DB::transaction(function () use ($user, $items) {
$this->stockChecker->assertEnough($items);
$this->stockChecker->decrease($items);
$order = Order::create([...]);
event(new OrderPlaced($order));
foreach ($items as $item) {
$order->items()->create([...]);
}
return $order;
}, attempts: 3);
}
解答例
注文が取り消されたのに、確認メールだけが届きます。
event() は明細の作成より前にあります。リスナーは同期で動くので、この時点で確認メールが送信されます。そのあとの明細作成で例外が起きると、トランザクションがロールバックして注文も明細も消えます。
送信済みのメールは戻りません。 利用者の手元には、存在しない注文の確認メールが残ります。注文番号を伝えているので、問い合わせを受けた側も混乱します。
同じ理由で、デッドロックによる再試行でもメールが複数回送られます。
イベントの発行はトランザクションを抜けてからにします。「確定した事実を発表する」以上、確定が済んでいない場所で発表することに意味がありません。
「イベントに切り出したので、注文 API が速くなりました」という報告にどう応じますか
速くなっていません。 イベントとリスナーは既定では同期で動きます。event() を呼んだその場でリスナーが実行され、終わるまで呼び出し元は次へ進みません。処理の場所が変わっただけで、合計時間は変わりません。
まず確認したいのは、その報告が測定に基づいているかです。「切り離したから速いはずだ」という推測であれば、それは実装の変更ではなく期待です。送信の前後で時刻を取るか、リクエスト全体の所要時間を記録して、変更の前後で比べてください。
そのうえで、この切り出しには別の価値があることを伝えます。倉庫連携やクーポン発行が増えても place() は変わりません。そして何より、非同期にする単位がはっきりしました。リスナーに ShouldQueue を付けるだけで後回しにできる状態になっています。
速くする作業は次の段階です。切り出しはその準備が終わったという意味で、それ自体が成果です。誤って「もう解決した」と扱うと、本当に必要な作業が残ったまま忘れられます。