Skip to main content

Events and notifications — decoupling everything after order confirmation

Of the four problems raised in chapter 1, the one left is "the email send time becomes the response time."

We solve it in two stages. This chapter separates the confirmation email from the order creation processing, and the next moves it outside the request. The order matters: make it asynchronous without separating it first and you end up with code where nobody can tell what was made asynchronous.

What keeps getting added after an order​

Chapter 6 showed a controller with seven concerns living together. The sixth was "sending the confirmation email." The current OrderPlacementService still has no mail sending in it, because we kept deferring it to chapter 14.

Adding it straightforwardly gives us this.

❌ Bad: writing it directly in the service class
public function place(User $user, array $items, ?string $idempotencyKey = null): Order
{
// ... checking and decrementing stock, creating the order ...

Mail::to($user)->send(new OrderConfirmed($order));

return $order;
}

It works. The problem is what gets added after it.

  • Send a shipping instruction to the warehouse system
  • Record the sale with an analytics service
  • Issue a coupon for a first-time purchase
  • Notify an administrator for a high-value order

Every one is "something to do after an order is confirmed." Add them straightforwardly and place() keeps growing. And if any one of them fails, the order itself fails. You end up unable to accept orders because the analytics service is down.

There is one more thing. Chapter 6 made "are there several reasons to change?" the criterion for extraction. These four each change for a different reason. Coupon conditions change because marketing wants them to; the warehouse integration changes because logistics does. There is no reason for them to share a method.

Decoupling with an event​

Announce the fact that an order was confirmed, once, and let whoever wants to react do so on their own. The announcer does not know who is listening.

The event class​

An event is just a class representing what happened. It has no behavior.

app/Events/OrderPlaced.php
<?php

namespace App\Events;

use App\Models\Order;

class OrderPlaced
{
public function __construct(
public readonly Order $order,
) {}
}

Create it with php artisan make:event OrderPlaced. As chapter 2 showed, app/Events does not exist at first; this command creates it.

We removed a couple of traits from the generated class. make:event produces it with traits for WebSocket delivery (broadcasting) and SerializesModels. Broadcasting is out of scope for this series. SerializesModels matters when you put events on a queue, and chapter 16 puts it back. Right now everything runs synchronously, so it makes no difference.

The name is in the past tense. OrderPlaced is a completed fact: the order was confirmed. Make it imperative — PlaceOrder — and the announcer is deciding what listeners should do. Announce a fact alone and the announcer stays unchanged however many listeners appear.

Announcing it​

Dispatch the event from the service class.

Chapter 14's place() has three return paths. Dispatch only on the path that created a new order.

app/Services/OrderPlacementService.php
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; // a retry; do not dispatch
}
}

usort($items, fn ($a, $b) => $a['product_id'] <=> $b['product_id']);

try {
$order = DB::transaction(function () use ($user, $items, $idempotencyKey) {
// ... checking and decrementing stock, creating the order and its lines ...

return $order;
}, attempts: 3);
} catch (UniqueConstraintViolationException $e) {
$existing = $idempotencyKey === null
? null
: $this->findByIdempotencyKey($user, $idempotencyKey);

if ($existing === null) {
throw $e;
}

return $existing; // a concurrent retry; do not dispatch
}

event(new OrderPlaced($order)); // only here

return $order;
}

The catch returns from inside. Assign to a variable and fall through instead, and the retry path reaches event() too. The branching written in chapter 14 to avoid creating the same order twice keeps its paths separate so it also avoids announcing the same fact twice.

That one line is all that gets added to place(). Warehouse integration and coupon issuance can pile up and this line does not change.

The two retry paths do not dispatch. The fact that an order was confirmed happened once, so it is announced once. Dispatch here and a confirmation email arrives on every retry. The unique constraint and key lookup added in chapter 14 double as "do not emit the event twice."

It sits outside the transaction. Chapter 14 decided not to send mail or call external APIs inside a transaction. Dispatching an event is light processing in itself, but the listener that hears it sends mail. Put it inside and you are sending mail inside a transaction after all. And on a retry, it sends again each time.

The listener class​

Create the listening side. php artisan make:listener SendOrderConfirmation --event=OrderPlaced generates it.

app/Listeners/SendOrderConfirmation.php
<?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));
}
}

Since it uses $event->order->user, add the relationship to Order. It is the counterpart of defining orders() on User in chapter 12.

Added to app/Models/Order.php
use Illuminate\Database\Eloquent\Relations\BelongsTo;

public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}

Warehouse integration and coupon issuance are added the same way, as separate listener classes. Both place() and the event class stay untouched.

No registration needed​

Chapter 2 mentioned that writing event-to-listener mappings in EventServiceProvider's $listen property is gone. Discovery is now by naming convention1.

Laravel scans app/Listeners, and when it finds a method starting with handle or __invoke, it registers it as a listener for the class type-declared on its argument. In the example above, the type declaration on handle(OrderPlaced $event) settles the mapping.

In exchange for not writing a mapping table, you can no longer see at a glance which event has which listeners. You do not know until you open app/Listeners and read the type declarations. Once listeners accumulate, php artisan event:list shows the registered mappings.

You can also register explicitly rather than relying on discovery. Call Event::listen() in AppServiceProvider's boot method1. Not register — the reason from chapter 2 applies directly.

Creating the notification​

The content of the email goes in a Notification class. Generate it with php artisan make:notification OrderConfirmed.

app/Notifications/OrderConfirmed.php
<?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("Thank you for your order (order number: {$this->order->id})")
->greeting('We have received your order')
->line("Total: {$this->order->total_amount} yen")
->action('View order details', url("/orders/{$this->order->id}"))
->line('We will be in touch again once it is ready to ship.');
}
}

via() decides the delivery channels2. Here it is mail only, but it can return several, as in ['mail', 'database']. Adding SMS or push notifications means changing only this method and the corresponding toXxx method. The listener does not change.

$notifiable is the recipient of the notification. If the User model uses the Notifiable trait, $user->notify(...) sends it. The destination email address is resolved from the email column automatically.

When you need a Mailable​

MailMessage assembles an email in a fixed shape: subject, greeting, lines of body, and a button. Most notifications get by with that.

Use a Mailable when you need your own layout. You can return a Mailable from toMail()2. Generate the class and a Blade template with php artisan make:mail OrderConfirmedMail --markdown=mail.orders.confirmed, then return it like this.

public function toMail(object $notifiable): Mailable
{
return (new OrderConfirmedMail($this->order))->to($notifiable->email);
}

We do not build OrderConfirmedMail in this series. The shape is shown for reference only.

With a Mailable you can write the Blade template freely. attachFromStorage, which attaches a file from storage, is also a Mailable feature2. Attaching a receipt PDF goes through it. File storage is covered in chapter 19.

This series stays with MailMessage. We are at the stage of designing the delivery path, not of polishing the appearance.

Decoupling did not make it faster​

place() is tidied up now. The response time has not improved by a single millisecond.

Events and listeners run synchronously by default. The moment event() is called, the listener runs right there. The caller does not move to the next line until the listener finishes.

Receive the request
→ Check stock, create the order (50ms)
→ event(new OrderPlaced($order))
→ SendOrderConfirmation runs
→ Connect to the SMTP server and send (3000ms)
→ Return the response

The total time is the same as before. All that changed is where the code lives.

That is not a failure. Decoupling and making things asynchronous are separate jobs, and doing them in this order is worth it. Now, making SendOrderConfirmation asynchronous has no effect at all on the order processing itself. Try to make it asynchronous before decoupling and you start by having to draw the line for what gets deferred.

The line is drawn. Chapter 16 moves it.

Adding ShouldQueue to a listener makes it asynchronous

Implement Illuminate\Contracts\Queue\ShouldQueue on a listener class and Laravel sends that listener to a queue1. We do not add it in this chapter. Running a queue needs a worker process, and you have to decide how failed jobs are handled. Add it without understanding the machinery and you never notice the mail that was not sent. Chapter 16 covers it all together.

Measure how long you are waiting​

"Mail sending is slow" is easy to say by feel. Measure before you touch anything.

The simplest thing is to take the time before and after sending.

app/Listeners/SendOrderConfirmation.php
public function handle(OrderPlaced $event): void
{
$startedAt = microtime(true);

$event->order->user->notify(new OrderConfirmed($event->order));

Log::info('Sent the order confirmation email', [
'order_id' => $event->order->id,
'duration_ms' => (int) ((microtime(true) - $startedAt) * 1000),
]);
}

Following the log policy settled in chapter 9, keep it as a structured value. With duration_ms stored as a number, you can aggregate it later.

The numbers vary enormously by environment. With the log driver in your local development environment, sending mail is a file write and finishes instantly. In production with an external SMTP service, you get network round trips plus the service's own processing. This series does not give concrete numbers. Only values measured in your own environment are grounds for a decision.

Do not look only at the average. Even at a 200-millisecond average, one request in 20 taking 5 seconds means someone waited 5 seconds. Look at the maximum and around the 95th percentile.

Checking the email content locally

Set MAIL_MAILER to log in .env and the message contents are written to storage/logs/laravel.log. Nothing is actually sent, so there is no risk of misdirecting mail to a test address.

If you want to check the HTML appearance too, you can run a service that emulates an inbox (such as Mailpit) locally.

The relationship with transactions​

Chapter 14 wrapped place() in a transaction. Put the event dispatch in the wrong place and that breaks.

❌ Bad: dispatching inside the transaction
return DB::transaction(function () use ($user, $items) {
// ... decrementing stock, creating the order ...

event(new OrderPlaced($order)); // the synchronous listener runs here

return $order;
}, attempts: 3);

Three problems occur at once.

Mail is sent inside the transaction. The listener runs synchronously, so the transaction stays open until the SMTP exchange finishes. As chapter 14 covered, the lock is held longer and other requests wait.

A deadlock retry sends the mail several times. attempts: 3 re-runs the closure from the top. Database writes are rolled back, but sent mail does not come back.

A rollback still leaves the mail delivered. Even if creating the lines fails and the order is cancelled, the listener for the event dispatched beforehand has already finished. You get a support ticket saying "I received a confirmation email for an order I never placed."

Dispatch after leaving the transaction. You are announcing the fact that an order was confirmed, so announcing it after confirmation is the natural thing.

There is also a mechanism to defer until after the commit​

Implement ShouldDispatchAfterCommit on the event class and calling event() inside a transaction defers the dispatch until after the commit3. A rollback means the dispatch never happens at all.

use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;

class OrderPlaced implements ShouldDispatchAfterCommit
{
// ...
}

This series does not use it. Being able to follow the dispatch position by eye is clearer for readers. A state where event() sits inside a transaction but runs later is not followable by a reader who does not know the interface.

That said, it is useful when you cannot choose the dispatch position. If another package or the framework decides where the event fires, you cannot move the event() line. When you cannot get it outside the transaction, use this.

There is also ShouldHandleEventsAfterCommit for deferring on the listening side3. When you cannot touch the event class (an event a package provides, say), implement it on the listener.

Note that afterCommit, which defers dispatching a queued job, is a separate setting. Chapter 16 covers it.

Tests​

Of the Mail::fake() and Notification::fake() promised in chapter 10, what this chapter actually uses is Notification::fake(), because we send at the notification layer. The last part of this section sorts out when to use which.

Confirm the notification is sent​

tests/Feature/OrderNotificationTest.php
use App\Notifications\OrderConfirmed;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Laravel\Sanctum\Sanctum;

pest()->use(RefreshDatabase::class);

test('placing an order sends a confirmation notification', 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() is called first2. From then on, notifications are not really sent; only a record of the attempt remains. assertSentTo confirms who received which notification.

Even if you forget fake(), no real mail flies with the standard setup. Laravel's phpunit.xml sets MAIL_MAILER to array, so sends during tests only accumulate in memory. But that is being protected by configuration alone. Rewrite phpunit.xml, or add a channel that hits an external service directly (SMS or push), and that protection is gone. Write fake() yourself.

Confirm nothing is sent when the order fails​

More often overlooked than "it is sent" is "it is not sent when it should not be."

tests/Feature/OrderNotificationTest.php
test('no notification is sent when stock is insufficient', 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();
});

What this test catches is an implementation that sends the notification before entering the order processing — a controller sending it as soon as validation passes, for instance, goes red.

This test does not catch a misplaced dispatch

Move event() inside the transaction and this test stays green. On the out-of-stock path, assertEnough throws before the order is created, so the event() line is never reached at all. Chapter 14 noted the same test not verifying the presence of the transaction.

To hit a misplaced dispatch (a notification arriving after a rollback), order creation has to succeed and then something has to fail. As chapter 14 covered, that situation cannot be produced in a single process. Protect the correctness of the position by reading the code, not by testing.

Confirm the notification's contents​

To check the subject or the body, pass a closure as the third argument to assertSentTo.

Notification::assertSentTo($user, OrderConfirmed::class, function ($notification) use ($order) {
return $notification->order->id === $order->id;
});

Think carefully before pinning down the body text itself. Wording changes often. A test that goes red on every change only adds work fixing the test; it does not add anything guarded. What is worth pinning down is the part that causes real harm when wrong, such as "which order is this notification about?"

Choosing between Mail::fake() and Notification::fake()

Mail::fake() targets Mailables. When you send via a Notification as in this chapter, Mail::assertSent() cannot catch it. Choose the fake that matches the layer you send at.

When you return a Mailable from toMail(), Notification::fake() confirms the notification send and Mail::fake() confirms the Mailable send.

What matters in production​

A listener failure takes the order down with it​

An exception thrown by a synchronous listener propagates to the caller of event(). With the mail server down, the order API returns a 500. The database write has already finished, so the order exists while an error comes back.

Moving to a queue in chapter 16 makes this problem disappear. Until then, whether to swallow the exception inside the listener is a judgment call. Swallow it and the order goes through, but nobody learns that the mail was not sent. It is the same problem as "how to record expected errors" from chapter 9.

A retry never sends the mail either

Combined with chapter 14's idempotency, one more awkward shape appears.

  1. The order is created and committed
  2. The listener fails to send mail; the exception comes back as a 500
  3. The client retries with the same Idempotency-Key
  4. The existing order is found, so a 200 comes back with no event dispatched

The retry works correctly. There is still one order. And yet the confirmation email is never sent. Not dispatching is the correct path, so no number of retries changes it.

While the listener is synchronous, this hole cannot be closed. The send failure has to be separated from the order failure, and moved somewhere it can be re-run after a failure. That is a queue. Chapter 16 covers it.

Do not create too many events​

Turn everything into an event and the flow becomes unreadable. Following the code no longer tells you what happens next. Needing to look at event:list to find out is a step backwards for readability.

Events suit cases where the announcer need not know its listeners — a situation where several departments want to react to "an order was confirmed." Conversely, when exactly one piece of processing always follows, calling the method directly reads better.

Model events are a different thing​

Eloquent also has model events such as created and updated. The names are similar, but they fire under different conditions.

A model event reacts to a row being saved. It carries no business meaning. It fires the same way for a registration from an admin screen and for a data migration script. Tie confirmation email sending to created and the instant a migration script calls save() one row at a time, confirmation emails go out for every historical order.

There is a trap in the other direction too. They do not fire on bulk operations. The official documentation states that "the saved, updated, deleting, and deleted model events will not be dispatched" for mass updates or deletes because the models are never actually retrieved4. Bulk inserts via Model::insert() and saveQuietly() do not fire them either.

In other words, model events can fail to fire when a business event has occurred, and can fire when one has not. They drift in both directions. Dispatch business events explicitly from the business processing.

Do not send notifications to the wrong address​

$user->notify() sends to the User's email column. Chapter 11 kept customer_email on the order to preserve the address used at order time.

When sending a notification about a past order after the user changed their email address, which one to use is a business decision. One split is: current address for a shipping notification, order-time address for resending a copy of the original confirmation. There is no situation where either will do. Decide, and write it in the code.

Summary​

  • Announce the fact that an order was confirmed once, and let whoever wants to react listen
  • Name events in the past tense. An imperative name means the announcer is deciding the listeners' work
  • Listeners need no registration. app/Listeners is scanned and the mapping comes from the argument type on handle
  • In exchange, the mappings are no longer visible at a glance. Check with event:list
  • The email content goes in a Notification. via() decides the channels, so listeners do not change as channels are added
  • Extracting into an event does not make it faster. By default a listener is synchronous and runs on the spot
  • Dispatch events outside the transaction. Inside, a rollback still leaves the mail delivered
  • When you cannot choose the position, defer with ShouldDispatchAfterCommit (dispatch side) or ShouldHandleEventsAfterCommit (listening side)
  • Do not dispatch on the retry paths. The fact happened once
  • Model events do not line up with business events. They do not fire on bulk operations, and they do fire in migration scripts
  • Measure before you touch anything. Look at the maximum and the upper percentiles, not just the average
  • Call Notification::fake() first in tests. Also pin down that nothing is sent when nothing should be
  • But a misplaced dispatch cannot be caught by a test. Protect it by reading the code
  • When a synchronous listener fails, the confirmation email never arrives even on retry. An idempotent retry just returns the existing order without re-dispatching the event

The decoupling is done, but the user's wait has not changed. The next chapter, Queue basics — moving heavy work out of the request, moves the currently synchronous listener onto a queue. It covers running a worker process, handling a failed job, and the afterCommit mentioned in chapter 14. The line drawn here becomes the unit that goes asynchronous.

Exercises​

The following code causes a problem when the order fails. Explain what happens
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);
}

Sample answer

The order is cancelled, but the confirmation email still arrives.

event() comes before the lines are created. The listener runs synchronously, so the confirmation email is sent at that point. If creating the lines then throws, the transaction rolls back and both the order and the lines disappear.

Sent mail does not come back. The user is left holding a confirmation email for an order that does not exist. It quotes an order number, so whoever fields the support call is confused too.

For the same reason, a deadlock retry sends the mail several times.

Dispatch the event after leaving the transaction. Since you are announcing a confirmed fact, there is no sense in announcing it where confirmation has not happened.

How do you respond to a report that "extracting into an event made the order API faster"?

It did not get faster. Events and listeners run synchronously by default. The listener runs the moment event() is called, and the caller does not proceed until it finishes. Only the location of the processing changed; the total time is the same.

The first thing to check is whether the report is based on measurement. If it is the assumption "we decoupled it, so it should be faster," that is an expectation, not an implementation change. Take the time before and after sending, or record the whole request's duration, and compare before and after the change.

Then convey that the extraction has a different kind of value. Warehouse integration and coupon issuance can be added and place() will not change. And above all, the unit for going asynchronous is now clear. Adding ShouldQueue to the listener is all it takes to defer it.

Making it faster is the next stage. The extraction is a result in its own right, in the sense that the preparation is done. Treat it wrongly as "already solved" and the work that is actually needed gets left behind and forgotten.


Footnotes​

  1. Source: Events (Laravel documentation 13.x). On three points: Laravel scanning the Listeners directory by default and registering methods beginning with handle or __invoke as listeners for the event type-declared on their argument; manual registration with Event::listen() in AppServiceProvider's boot; and implementing ShouldQueue on a listener sending it to a queue. ↩ ↩2 ↩3

  2. Source: Notifications (Laravel documentation 13.x). On five points: make:notification generating a class in app/Notifications; via() returning an array of delivery channels; toMail() returning a MailMessage that assembles subject, body, and action; returning a Mailable from toMail() and then being able to use attachFromStorage; and verification with Notification::fake() and assertSentTo / assertNothingSent. ↩ ↩2 ↩3 ↩4

  3. Source: Illuminate/Contracts/Events/ShouldDispatchAfterCommit.php (laravel/framework 13.x). On an event class implementing this interface having its dispatch deferred until after any open transaction commits. ShouldHandleEventsAfterCommit, which does the same on the listener side, is in the same directory. ↩ ↩2

  4. Source: Eloquent: Getting Started (Laravel documentation 13.x), the Important Limitation in the "Events" section. On saved / updated / deleting / deleted not being dispatched for mass updates and deletes because the models are never actually retrieved, and on saveQuietly saving without firing events. ↩