php elephant sticker
Photo by RealToughCandy.com on Pexels.com
Table of Contents

[Practical Complete Guide] Event-Driven Design in Laravel — Event, Listener, Subscriber, Broadcasting, Separation of Side Effects, Testing, and Accessible Notification Design

What you will learn in this article (key points)

  • The roles of Laravel’s Event / Listener / Subscriber, and criteria for deciding how far to eventize your design
  • How to cleanly separate side effects such as “send an email, update inventory, save an audit log after an order is created”
  • How to choose between synchronous events and queued execution, how to think about failures, and the basics of idempotency
  • How to organize your design so you do not confuse domain events with framework events
  • How to connect events with broadcasting and notifications, and the design considerations for live updates and accessibility
  • Event / Listener testing strategies, plus logging and monitoring ideas that keep operations manageable

Intended readers (who benefits?)

  • Beginner to intermediate Laravel engineers: people whose controllers and services have accumulated too many side effects and become hard to change safely
  • Tech leads: people who want to introduce event-driven design without falling into overengineering or losing visibility
  • QA / maintenance staff: people who want to organize side effects such as email sending and audit logs into forms that are easier to test and less fragile
  • Designers / CS / accessibility staff: people who want to design notifications and live updates in a way that is easy for everyone to understand

Accessibility level: ★★★★★
Event-driven design itself is backend architecture, but in practice it directly affects the quality of screen notifications, live updates, and completion messages. In this article, we also organize ideas around role="status", role="alert", state displays that do not rely only on color, and policies for avoiding excessive automatic updates.


1. Introduction: Events are not “cool architecture,” but tools for organizing side effects

As you continue developing with Laravel, you will eventually run into situations where you want to execute several other actions after one main process is complete. For example, after an order is created, you may want to send a confirmation email, reduce inventory, save an audit log, notify administrators, and perhaps sync with an external system. At first, you can simply write them one after another at the end of a controller or service, and it will work. But as features increase, the “main process” and the “side effects” become mixed together, and it gets harder to see what should be changed and where.

This is where event-driven design becomes useful. An event represents the fact at the center of the process — in other words, what happened — and lets you separate the follow-up actions that respond to it. The important point is not to use event-driven design because it is trendy, but to use it for organizing side effects and improving maintainability. In this article, we will take that idea and translate it into practical Laravel usage centered on Event / Listener, in a way that is easier to apply in real work.


2. First, understand this: an event is “something that happened,” and a listener is “something that reacts”

Laravel events are easy to understand if you think of them very simply.

  • Event
    • Represents the fact that something happened
    • Examples: OrderPlaced, UserRegistered, ArticlePublished
  • Listener
    • The process that reacts to that fact
    • Examples: send a confirmation email, write an audit log, update inventory

The nice thing about this division is that it lets you separate the main purpose — such as “create an order” — from “everything that should happen after the order is created.” For example, whether order creation succeeds and whether email sending succeeds are really different concerns. If everything is packed into one method, then when one part breaks, the impact tends to become much larger.

That said, not everything should become an event. Events are best suited for cases where multiple side effects hang off a single fact that happened. On the other hand, if you push the main process itself, or tightly coupled mandatory processing, entirely into events, the flow of the code becomes harder to read. We will organize criteria for that judgment later.


3. Think through a typical example: splitting order creation into event-driven design

First, let’s consider a straightforward implementation without events.

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', 'Your order has been received.');
}

At first this code is easy to understand, but once you add more notification targets or external integrations, it quickly becomes long. So instead, treat “an order was placed” as an event, and separate the side effects that follow.

3.1 Define the 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 Keep the trigger side simple

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', 'Your order has been received.');
}

3.3 Split the Listeners

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,
        ]);
    }
}

With this form, the order creation itself and the side effects afterward are cleanly separated. Controllers and Actions become shorter, and the impact range becomes easier to read.


4. Processing that suits events, and processing that does not

Just because event-driven design is useful does not mean everything should be turned into an event. If you push everything into events, it can become harder to understand. So it helps to have decision criteria first.

Processing that suits events

  • Multiple side effects hang off a fact that occurred
  • The side effects can remain loosely coupled
  • Some processing may be added or removed later
  • Auxiliary processing such as notifications, audit logs, external sync, and search index updates
  • Processing that can be slightly delayed and is suitable for queuing

Processing that does not suit events

  • The main process itself
  • Processing whose success and failure must absolutely stay together
  • Processing that must complete strictly together inside a transaction
  • Processing with highly strict ordering or consistency, where invisible separation is risky

For example, “create an order” itself is the main process. On the other hand, “notify the administrator after order creation” is a side effect. Separating those makes the design cleaner. But “create the order record,” “create the order items,” and “save the total amount” are usually more natural as one business process inside an Action or Service.


5. A way to avoid confusing domain events and Laravel events

One point that can get a little confusing in practice is that “domain events” and “Laravel events” are not always exactly the same thing.

  • Domain event
    • A fact with business meaning
    • Examples: OrderPlaced, SubscriptionRenewed, UserSuspended
  • Framework event
    • Events provided internally by Laravel, or events for technical reasons
    • Examples: login success, job failure, model lifecycle hooks

At first, it is easiest not to overcomplicate this and simply use facts with business meaning as your event names. OrderPlaced is much easier to understand than OrderSaved, and kinder to the next person reading the code.
Technical events are better kept conceptually separate from business events, both in naming and in handling, to reduce confusion.


6. Queueing Listeners: do not make heavy side effects synchronous

Once you adopt event-driven design, it becomes tempting to put heavy processing inside listeners. But side effects such as email sending, external API integration, image generation, and aggregation can slow down the main request if run synchronously. That is why using ShouldQueue to send the listener itself to the queue is such an effective design.

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

This lets the main order creation complete quickly, while follow-up actions continue asynchronously. Operationally, it also becomes easier to monitor failed jobs and Horizon, which makes it easier to distinguish between the main process and side effects.

However, it is risky to move processing into the queue if it truly must finish together with the order creation. It is safer to decide what remains synchronous and what becomes asynchronous based on user impact and consistency needs.


7. How to think about failures: decide whether listener failures should affect the main process

An important part of event-driven design is deciding in advance whether if a follow-up process fails, the main process should also fail.
For example, if administrator notification fails after order creation, you often still want the order itself to be valid. But if inventory updating fails, the order may become risky. This is not something that should be decided uniformly; you need to think about it per process.

Example of this organization

  • Order creation
    • Required: the order itself, order details, payment confirmation
    • Important but follow-up: inventory update
    • Auxiliary: confirmation email, audit log, administrator notification
  • User registration
    • Required: user creation
    • Follow-up: welcome email, analytics event, CRM sync

When you divide things this way, it becomes easier to see what belongs inside the transaction and what should be separated through events.


8. Idempotency: events and listeners should stay safe even when re-run

Events and queues may be retried or triggered more than once. That is why it is safer to design listeners so that they do not break when run twice.

For example, for confirmation emails, it is reassuring to keep a “sent” flag for important notifications.

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();
    }
}

With this, even retries or duplicate jobs can avoid double-sending. Event-driven design is powerful, but getting into the habit of asking “what happens if this same event is processed twice?” makes operations much calmer.


9. Subscriber: where to organize things when events increase

As listeners increase, you may want to manage related groups of events together. This is where a Subscriber can help.
A Subscriber is a mechanism for collecting subscriptions to multiple events into a single class.

For example, if you want to gather audit-log-related events into one place, you can organize them like this.

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']
        );
    }
}

You do not need to turn everything into a Subscriber, but using one only for things that are clearer when grouped together can make the structure much cleaner.


10. Things to watch when connecting to Broadcasting or real-time notifications

Events can also be combined with screen live notifications and broadcasting. For example, there are cases where you want to show things like “export completed,” “a new order has arrived,” or “a comment was added” in real time on the screen.

However, while real-time updates are convenient, you need to be careful about accessibility and cognitive load.

  • Do not let the screen change drastically on its own
  • Do not make non-important updates into alerts
  • Clearly show numbers and counts in text
  • Do not distinguish states by color alone
  • Even if there are automatic updates, do not steal the user’s focus

Minimal UI example

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

When a real-time notification arrives, place a short but meaningful sentence in this area.
A bad example would be just “Updated.”
A good example would be something like “One new order has been added” or “The export has completed. You can now download it,” where the next action is clear.


11. Event design and accessible notification design work well together

Once event-driven design is organized, it becomes easier to see what should be notified on the UI side. For example, if there is an event called ExportFinished, then the screen should show a “completion notification.” If there is a UserSuspended, then the admin screen can show “The user has been suspended” with role="status".

In other words, the more clearly the backend facts are organized, the less inconsistent the UI state design becomes. As a result, this kind of consistency becomes possible.

  • Success: role="status"
  • Important failure: role="alert"
  • Wording that does not depend only on color
  • Messages that make retries or next actions clear

This consistency is a big help not only for end users, but also for QA and customer support.


12. Logging and monitoring: events are harder to see, so observability matters

One weakness of event-driven design is that the processing flow becomes harder to trace directly in code. That is why having at least minimal logs and monitoring is reassuring.

For important events, it is helpful to leave structured logs with information like:

  • event name
  • target ID (order_id, user_id, etc.)
  • trace_id
  • listener name that ran
  • result (success / failed)

When you are investigating failed jobs or unsent notifications, it becomes much easier if you can see which event they originated from.


13. Testing: how to think about testing events

Event-driven design may look clean, but without tests it is hard to trust. In Laravel, Event::fake() and individual listener tests are very useful.

13.1 Test whether the event was dispatched

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 Test the listener individually

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

Separating the “event dispatch” test from the listener test makes it easier to identify what actually broke.


14. Common pitfalls and how to avoid them

14.1 Turning everything into events and losing visibility

A good way to avoid this is to keep the main process inside an Action or Service, and separate only the side effects into events.

14.2 Event names becoming too technical to understand

Use names that communicate business meaning, like OrderPlaced rather than OrderSaved, to make the code easier to read.

14.3 Listeners becoming too heavy and slowing the main process

Heavy processing should be queued with ShouldQueue instead of being handled synchronously.

14.4 Retries causing duplicate notifications or duplicate integrations

You can create safety with sent flags, unique constraints, locks, and similar idempotency strategies.

14.5 Not knowing the impact range when something fails

If you log event names, target IDs, and trace_ids, and monitor via Horizon or exception tracking, investigations become much faster.


15. Checklist (for distribution)

Design

  • [ ] The main process and side effects are considered separately
  • [ ] Event names express business facts
  • [ ] Each listener has one purpose and stays short
  • [ ] Not everything is eventized; the main process stays in a Service / Action

Reliability

  • [ ] Heavy listeners are queued
  • [ ] The design takes idempotency into account
  • [ ] It is clear whether listener failures should affect the main process
  • [ ] Logs keep the event name, target ID, and trace_id

UI / Accessibility

  • [ ] Success notifications can be consistently handled with role="status"
  • [ ] Important failures are conveyed with role="alert"
  • [ ] Real-time updates do not steal focus
  • [ ] State is not conveyed by color alone

Testing

  • [ ] There are tests for event dispatching
  • [ ] There are individual tests for important listeners
  • [ ] There are tests for idempotency and duplicate execution prevention

16. Conclusion

Laravel event-driven design is not meant to make code harder. It is a tool for separating the main process from its side effects so that the code becomes easier to maintain. When you use business facts such as OrderPlaced and UserRegistered as the axis, follow-up work like email sending, audit logging, external synchronization, and notifications can be organized naturally. If you push heavy processing into queues and think carefully about failure scope and idempotency, the design also becomes calmer in operations. And once your events are organized, UI notifications become easier to keep consistent, which also makes it easier to create accessible status displays. A good place to start is to pick just one side effect currently sitting at the end of a controller or service, and try extracting it into an event.


Reference links

By greeden

Leave a Reply

Your email address will not be published. Required fields are marked *

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)