Advanced 8 min · March 06, 2026

Laravel Events — Phantom Emails on Failed Registrations

Events inside DB::transaction() dispatch fire-and-forget — ignored transaction boundary causing phantom welcome emails.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • Core mechanism: Dispatcher::dispatch() iterates a registered listener map. Listeners run synchronously unless they implement ShouldQueue.
  • Registration: EventServiceProvider::$listen maps event classes to listener classes. Processed at boot time into an internal closure array.
  • Queueing: ShouldQueue on a listener wraps handle() in a CallQueuedListener job. Control queue name, connection, delay, and retries per listener.
  • Race condition: Events dispatched inside DB transactions can fire before commit. ShouldDispatchAfterCommit on the Event class is the canonical fix.
  • Testing: Event::fake() replaces the dispatcher entirely. assertDispatched() verifies the event; assertListening() verifies registration without running listeners.
  • Biggest mistake: Treating events as a communication channel between listeners. If listener B needs data from listener A, you have a design problem.
Plain-English First

Think of a busy coffee shop. When the barista shouts 'Order ready for Sarah!', Sarah picks it up, the loyalty-points app logs the sale, and the stock system subtracts one cup — all at the same time, from one announcement. Sarah is the Event, and each person reacting is a Listener. The barista doesn't know or care who reacts; they just shout. That's exactly what Laravel Events do — one thing happens, many independent reactions follow, without any of them knowing about each other.

In a production Laravel application, a single user action triggers cascading side effects: emails, analytics, notifications, third-party webhooks. Without a decoupling mechanism, these side effects accumulate inside controllers and models, creating monolithic methods that are impossible to test in isolation and dangerous to extend.

Laravel's event system provides a formal seam. Your controller fires a single event — UserRegistered, OrderPlaced — and walks away. Each side effect lives in its own Listener class. Adding a reaction requires writing a new Listener and registering it. Zero changes to existing code. This is the Open/Closed Principle applied to application workflows.

A common misconception is that events are just a fancy way to call functions. They are not. Events are an architectural boundary that controls coupling, enables independent testing, and — when combined with ShouldQueue — moves expensive work off the HTTP hot path. Understanding the internals of the Dispatcher, the transaction safety guarantees, and the testing patterns is what separates a working event system from one that silently drops emails or fires phantom side effects on rollback.

How Laravel's Event Dispatcher Works Under the Hood

Laravel's event system is built on top of the Illuminate\Events\Dispatcher class, which is bound to the service container as a singleton under the events key. When you call event(new UserRegistered($user)) or use the Event::dispatch() facade, you're calling Dispatcher::dispatch(). That method does three things in order: it resolves every listener registered for that event class (including wildcard matches), it calls each listener in sequence, and it checks the listener's return value — if any listener returns false, propagation stops immediately.

Listener resolution happens through an internal array keyed by the fully-qualified event class name. During the boot phase, EventServiceProvider calls Dispatcher::listen() for every mapping you define in its $listen property. Laravel also performs auto-discovery of events by scanning your Listeners directory if you call Event::discover() — it reads each listener's handle() type-hint to figure out which event it cares about.

Understanding this pipeline is critical because it explains performance: every event() call synchronously iterates that listener array unless you queue your listeners. It also explains why the order of listener registration matters when one listener's side effect is a precondition for another's — and why returning false from a listener is the correct way to halt propagation, not throwing an exception.

EventDispatcherInternals.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
<?php

// ─────────────────────────────────────────────────────────────
// 1. The Event — a plain PHP class, a value object.
//    It holds all the data that listeners will need.
//    No logic here. Think of it as a named payload.
// ─────────────────────────────────────────────────────────────
namespace App\Events;

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

class UserRegistered
{\n    use Dispatchable;     // adds the static ::dispatch() factory\n    use SerializesModels; // safely serialises Eloquent models for queued listeners\n\n    public function __construct(\n        public readonly User $user,  // PHP 8 constructor promotion — clean and immutable\n        public readonly string $registrationSource = 'web'\n    ) {}
}

// ─────────────────────────────────────────────────────────────
// 2. Peek inside how Dispatcher resolves listeners.
//    You'd never write this yourself — it's to show the mechanism.
// ─────────────────────────────────────────────────────────────

// Internally, Illuminate\Events\Dispatcher holds a structure like:
// [\n//   'App\\\Events\\\UserRegistered' => [\n//       Closure (wraps SendWelcomeEmail::class),\n//       Closure (wraps CreateDefaultWorkspace::class),\n//   ]
// ]
//
// When you call event(new UserRegistered($user)), Dispatcher does:
//
//   foreach ($this->getListeners($eventName) as $listener) {
//       $response = $listener($event);          // call the listener
//       if ($response === false) break;         // halt propagation
//   }
//
// Each listener Closure is a wrapper that either:
//   a) Instantiates a class and calls handle(), OR
//   b) Queues a job (if the listener implements ShouldQueue)

// ─────────────────────────────────────────────────────────────
// 3. Dispatching the event — three equivalent ways.
// ─────────────────────────────────────────────────────────────

// Option A — via the Dispatchable trait (most expressive):
UserRegistered::dispatch($user, 'mobile-app');

// Option B — via the global helper (readable in controllers):
event(new UserRegistered($user, 'api'));

// Option C — via the facade (useful when you need the return values):
$responses = \Illuminate\Support\Facades\Event::dispatch(new UserRegistered($user));
// $responses is an array of return values from each listener.
// If any listener returned false, dispatch() returns false.
var_dump($responses); // array(2) { [0]=> NULL [1]=> NULL } — typical void listeners
Output
array(2) {
[0]=>
NULL
[1]=>
NULL
}
Why SerializesModels Matters:
When a listener is queued, the event object gets serialised to JSON and pushed onto the queue. Without SerializesModels, the entire Eloquent model — including its loaded relationships — gets serialised as raw data. With it, Laravel only stores the model's class and primary key, then re-fetches the model when the queued job runs. This prevents stale data and keeps your queue payloads tiny.
Production Insight
Cause: The Dispatcher iterates listeners synchronously in registration order. If one listener does I/O (HTTP call, heavy computation), it blocks all subsequent listeners and the HTTP response. Effect: A single slow synchronous listener degrades the entire request. Action: Implement ShouldQueue on any listener that does I/O. Profile your listener execution order with Telescope during load tests. If listener ordering matters (preconditions), use explicit priority integers in Event::listen().
Key Takeaway
The Dispatcher is a synchronous loop over closures. Every event() call pays the cost of every registered synchronous listener in sequence. Understanding this explains performance, ordering, and propagation control. Queue everything that does I/O.
When to Return false vs Throw an Exception from a Listener
IfYou want to halt listener propagation because the event is no longer relevant (e.g., user was soft-deleted mid-process).
UseReturn false. This cleanly stops the Dispatcher loop without treating it as an error.
IfA critical precondition failed (database down, auth token invalid) and the entire operation should abort.
UseThrow an exception. In synchronous listeners, this propagates to the caller. In queued listeners, this triggers retry logic.
IfYou want to skip the remaining listeners but still consider the dispatch successful.
UseReturn false. Do not throw — throwing marks the operation as failed in the caller's context.

Registering Events and Building Listeners That Don't Break Under Load

Registration lives in App\Providers\EventServiceProvider. The $listen array is a map from event class to an array of listener classes. Laravel's artisan command php artisan event:generate reads that map and scaffolds every missing class for you — a massive time-saver. But the real power move is event:list, which prints every registered event–listener pair across your entire app, including those discovered automatically. Run it before a major deploy to audit your event surface.

A Listener class has one required method: handle(YourEvent $event). The type-hint is how auto-discovery identifies which event the listener cares about. Keep handle() focused on a single responsibility — resist the urge to put two side effects in one listener. That defeats the whole purpose.

For listeners that do I/O — sending emails, calling APIs, writing to secondary databases — always implement ShouldQueue. This turns the listener into a queued job automatically; Laravel wraps the handle() call in a CallQueuedListener job and pushes it onto the configured queue driver. You can fine-tune which queue connection and name a listener uses, set a delay, and control retry behaviour — all on the listener class itself, without touching the queue configuration file.

EventServiceProvider.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
<?php
// app/Providers/EventServiceProvider.php

namespace App\Providers;

use App\Events\UserRegistered;
use App\Events\OrderShipped;
use App\Listeners\SendWelcomeEmail;
use App\Listeners\CreateDefaultWorkspace;
use App\Listeners\NotifyAdminSlackChannel;
use App\Listeners\SendShipmentConfirmation;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Event;

class EventServiceProvider extends ServiceProvider
{
    /**
     * Explicit registration — the source of truth.
     * Laravel reads this during boot and calls Dispatcher::listen() for each pair.
     */
    protected $listen = [
        UserRegistered::class => [
            SendWelcomeEmail::class,        // queued — sends email async
            CreateDefaultWorkspace::class,  // queued — heavy DB work
            NotifyAdminSlackChannel::class, // queued — external HTTP call
        ],
        OrderShipped::class => [\n            SendShipmentConfirmation::class,\n        ],
    ];

    public function boot(): void
    {
        parent::boot();

        // Wildcard listener — catches every event whose name matches the pattern.
        // The first argument is the event name, second is the event object.
        // Use this for audit logging, debugging, or feature-flag interception.
        Event::listen('App\Events\*', function (string $eventName, array $eventPayload) {
            $event = $eventPayload[0]; // wildcard passes payload as an array
            \Log::channel('audit')->info('Event dispatched', [\n                'event'      => $eventName,\n                'user_id'    => auth()->id(),\n                'ip_address' => request()->ip(),\n                'timestamp'  => now()->toIso8601String(),\n            ]);
        });
    }
}

// ─────────────────────────────────────────────────────────────
// A production-grade queued listener
// ─────────────────────────────────────────────────────────────
// app/Listeners/SendWelcomeEmail.php

namespace App\Listeners;

use App\Events\UserRegistered;
use App\Mail\WelcomeEmail;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Mail;
use Throwable;

class SendWelcomeEmail implements ShouldQueue
{
    use InteractsWithQueue; // gives access to $this->release(), $this->fail(), etc.

    /**
     * Push this listener onto the 'notifications' queue,
     * using the 'redis' connection specifically.
     * This keeps email jobs separate from heavy data-processing jobs.
     */
    public string $queue = 'notifications';
    public string $connection = 'redis';

    /**
     * Delay execution by 10 seconds — gives the DB transaction
     * that fired the event time to fully commit before we read the user.
     * This avoids a race condition where the email job runs before
     * the user row is visible to a replica database.
     */
    public int $delay = 10;

    /**
     * Maximum number of attempts before the job is marked as failed.
     */
    public int $tries = 3;

    /**
     * The handle method receives the fully hydrated event object.
     * SerializesModels means $event->user was re-fetched from the DB
     * at the moment this job started — always fresh data.
     */
    public function handle(UserRegistered $event): void
    {
        Mail::to($event->user->email)
            ->send(new WelcomeEmail($event->user, $event->registrationSource));
    }

    /**
     * Called after all retries are exhausted.
     * Use this to alert your team or write to a dead-letter log.
     */
    public function failed(UserRegistered $event, Throwable $exception): void
    {
        \Log::error('WelcomeEmail failed permanently', [\n            'user_id'   => $event->user->id,\n            'exception' => $exception->getMessage(),\n        ]);

        // Optionally notify your on-call team via PagerDuty, Slack, etc.
    }
}
Output
// When php artisan queue:work processes the job:
// [2024-01-15 09:23:11][abc123] Processing: App\Listeners\SendWelcomeEmail
// [2024-01-15 09:23:11][abc123] Processed: App\Listeners\SendWelcomeEmail
// In storage/logs/audit.log:
// {"event":"App\Events\UserRegistered","user_id":42,"ip_address":"203.0.113.5","timestamp":"2024-01-15T09:23:01+00:00"}
Watch Out: The DB Transaction Race Condition
If you dispatch an event inside a database transaction and a queued listener runs before the transaction commits, it may not find the model in the database. Use $delay = 5 on the listener as a band-aid, but the real fix is to dispatch events after the transaction commits using DB::afterCommit() or Laravel's dispatchAfterResponse() helper — or wrap dispatches in DB::transaction()'s callback return value.
Production Insight
Cause: Listeners that perform I/O (HTTP calls, email sending, DB writes to external systems) block the HTTP response when synchronous. Effect: Registration endpoints with 3 synchronous listeners doing 200ms each add 600ms to the response. Under load, this cascades into request timeouts. Action: Implement ShouldQueue on every listener that touches external systems. Use $queue and $connection properties to isolate listener types — keep email jobs on a notifications queue separate from heavy data-processing jobs on a default queue. This prevents a burst of email jobs from starving your data pipeline.
Key Takeaway
Registration is a boot-time operation. The $listen array is the source of truth — audit it with php artisan event:list before deploys. Every listener that does I/O must implement ShouldQueue. Use queue isolation ($queue, $connection) to prevent job types from starving each other.
Choosing Queue Configuration Per Listener
IfListener sends email or SMS (fast, high-volume).
UseUse a dedicated notifications queue on Redis. Set $tries = 3, $delay = 5 (for transaction safety).
IfListener calls a third-party API with rate limits.
UseUse a dedicated external-api queue with a single worker (--max-jobs=1). This serializes calls and avoids rate-limit bans.
IfListener does heavy data processing (report generation, CSV export).
UseUse a processing queue with a longer timeout (--timeout=300). Consider $tries = 1 with a failed() handler that alerts — retries on long jobs waste resources.
IfListener must run immediately and affect the HTTP response (fraud scoring).
UseDo NOT implement ShouldQueue. Keep it synchronous. This is the exception, not the rule.

Synchronous vs Queued Listeners, Event Faking in Tests, and the ShouldDispatchAfterCommit Pattern

Choosing between a synchronous and a queued listener isn't just a performance call — it's an architectural contract. Synchronous listeners run in the same HTTP process and same database transaction as the code that fired the event. That means if your listener throws an exception, it propagates up to the caller. It also means the listener can affect the HTTP response, which is occasionally what you want (think: real-time fraud scoring that must block a request). Use sync listeners sparingly and only when the caller genuinely needs the result.

Queued listeners are the default for anything touching external services. But they introduce a new problem: testability. In a feature test, you don't want real emails sent or real jobs queued — you want to assert that a listener would run. Event::fake() replaces the real dispatcher with a spy that records all dispatched events without running any listeners. After your action, use Event::assertDispatched() to verify the event fired with the right data.

The ShouldDispatchAfterCommit interface, introduced in Laravel 9, is the clean solution to the race condition described earlier. Implement it on your event class and Laravel will buffer the dispatch until the current database transaction fully commits. If the transaction rolls back, the event is never dispatched — which is exactly the behaviour you want for events like OrderPlaced or PaymentProcessed.

EventTestingAndAfterCommit.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
<?php
// ─────────────────────────────────────────────────────────────
// Pattern 1: ShouldDispatchAfterCommit on the Event class
// ─────────────────────────────────────────────────────────────
namespace App\Events;

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

class OrderPlaced implements ShouldDispatchAfterCommit
{
    use Dispatchable, SerializesModels;

    public function __construct(\n        public readonly Order $order\n    ) {}
    // Laravel will NOT dispatch this event if the wrapping DB transaction
    // rolls back. No race condition. No phantom events.
}


// ─────────────────────────────────────────────────────────────
// Pattern 2: Using Event::fake() in feature tests
// ─────────────────────────────────────────────────────────────
namespace Tests\Feature;

use App\Events\UserRegistered;
use App\Events\OrderPlaced;
use App\Listeners\SendWelcomeEmail;
use App\Models\User;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;

class UserRegistrationTest extends TestCase
{
    public function test_user_registration_dispatches_correct_event(): void
    {
        // Fake ALL events — no listeners run, no emails sent.
        Event::fake();

        $response = $this->postJson('/api/register', [\n            'name'     => 'Alice Nguyen',\n            'email'    => 'alice@example.com',\n            'password' => 'supersecret123',\n        ]);

        $response->assertStatus(201);

        // Assert the right event was dispatched with the right payload.
        Event::assertDispatched(UserRegistered::class, function (UserRegistered $event) {
            return $event->user->email === 'alice@example.com'
                && $event->registrationSource === 'web';
        });

        // Assert a different event was NOT dispatched (guard against accidental fires).
        Event::assertNotDispatched(OrderPlaced::class);
    }

    public function test_only_fakes_specific_events_and_runs_others_normally(): void
    {
        // Fake ONLY UserRegistered — all other events fire for real.
        // Useful when you want to test a chain of events but isolate one.
        Event::fake([UserRegistered::class]);

        // ... your test actions ...

        Event::assertDispatched(UserRegistered::class);
    }

    public function test_queued_listener_is_registered_for_event(): void
    {
        Event::fake();

        UserRegistered::dispatch(User::factory()->make());

        // Assert that the listener is registered and would be queued.
        // This checks the listener CLASS is attached, not that it ran.
        Event::assertListening(\n            UserRegistered::class,\n            SendWelcomeEmail::class\n        );
    }
}


// ─────────────────────────────────────────────────────────────
// Pattern 3: dispatchAfterResponse — fire and forget for UI events
// ─────────────────────────────────────────────────────────────
// In a controller action:
public function store(RegisterUserRequest $request): JsonResponse
{
    $user = $this->userService->register($request->validated());

    // The response is sent to the browser FIRST,
    // THEN UserRegistered is dispatched in the shutdown phase.
    // Great for reducing perceived latency on registration endpoints.
    UserRegistered::dispatchAfterResponse($user, 'web');

    return response()->json(['message' => 'Registration successful'], 201);
}
Output
// PHPUnit output for test_user_registration_dispatches_correct_event:
// PASS Tests\Feature\UserRegistrationTest
// ✓ user registration dispatches correct event (0.23s)
// ✓ only fakes specific events and runs others normally (0.18s)
// ✓ queued listener is registered for event (0.15s)
// Tests: 3 passed (6 assertions)
// Duration: 0.56s
Pro Tip: Test the Listener in Isolation
Use Event::fake() in controller tests to assert events fire. But also write a separate unit test for each listener class directly — instantiate it, call handle() with a mock event, and assert its side effect. This gives you fast feedback and pinpoints failures precisely. Two layers of testing, zero ambiguity.
Production Insight
Cause: Teams either test events OR listeners, but not both. Feature tests with Event::fake() verify the event fires but don't test the listener's logic. Direct listener unit tests verify logic but don't test integration with the controller. Effect: Bugs slip through the gap — the event fires with wrong data, or the listener silently fails on edge cases. Action: Use two test layers. Feature tests with Event::fake([EventClass::class]) to assert dispatch. Separate unit tests per listener class to assert handle() behavior with edge-case payloads (null models, missing relationships, expired data).
Key Takeaway
Synchronous listeners are the exception — use them only when the caller needs the result. For everything else, ShouldQueue is mandatory. ShouldDispatchAfterCommit eliminates the transaction race condition at the framework level. Two-layer testing (feature + unit) is the only way to get full coverage.
Sync vs Queued: The Decision Framework
IfThe caller needs the result immediately (fraud score, permission check).
UseSynchronous. The listener's return value or exception directly affects the response.
IfThe side effect is fire-and-forget (email, analytics, webhook).
UseQueued. Implement ShouldQueue. The HTTP response should not wait for SendGrid.
IfThe side effect is idempotent and safe to retry (log, update a counter).
UseQueued with $tries = 3. Safe to retry on transient failures.
IfThe side effect is non-idempotent and destructive (charge a card, send a message).
UseQueued with $tries = 1 and a failed() handler that alerts. Add idempotency key logic inside handle().

Event Subscribers, Prioritised Listeners, and Performance at Scale

When one class logically handles multiple events from the same domain — say, all Order* events — scatter them across separate Listener files feels artificial. Event Subscribers solve this. A Subscriber is a single class with a subscribe() method that registers multiple listeners programmatically. It's registered in EventServiceProvider::$subscribe, not $listen. The result: all order-related event handling lives in OrderEventSubscriber, making it easy to find, audit, and test.

Listener priority is a lesser-known feature. The third argument to Event::listen() is an integer priority (default: 0). Higher numbers run first. Use this when one listener is a precondition for another — for example, an EnrichEventPayload listener that adds data to the event object (make the event properties mutable for this use-case) before other listeners read it.

At scale, the biggest performance trap is accidentally making a listener synchronous when it should be queued — or registering hundreds of wildcard listeners that all fire on every single event. Profile your event listeners with Laravel Telescope or Laravel Debugbar during load testing. If a single page request is firing 50 events and each has 3 sync listeners, that's 150 synchronous function calls in the hot path. Each one that does any I/O is a latency bomb.

OrderEventSubscriber.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
<?php
// ─────────────────────────────────────────────────────────────
// Event Subscriber — one class, multiple events, one registration.
// Ideal when a domain area (Orders) owns several related events.
// ─────────────────────────────────────────────────────────────
namespace App\Listeners;

use App\Events\OrderPlaced;
use App\Events\OrderShipped;
use App\Events\OrderCancelled;
use App\Models\Order;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Events\Dispatcher;

// ShouldQueue on a subscriber applies to ALL its listener methods.
class OrderEventSubscriber implements ShouldQueue
{
    public string $queue = 'orders';

    /**
     * Handle OrderPlaced: reserve inventory, charge the card.
     */
    public function handleOrderPlaced(OrderPlaced $event): void
    {
        $order = $event->order;

        // Each method stays focused on ONE responsibility.
        // Inventory reservation happens here; email happens elsewhere.
        \Log::info('Order placed, reserving inventory', ['order_id' => $order->id]);

        $order->items->each(function ($item) {
            $item->product->decrement('stock_quantity', $item->quantity);
        });
    }

    /**
     * Handle OrderShipped: notify the customer, update analytics.
     */
    public function handleOrderShipped(OrderShipped $event): void
    {
        $order = $event->order;

        \Log::info('Order shipped, sending tracking info', ['order_id' => $order->id]);
        // ... send tracking email, push to analytics ...
    }

    /**
     * Handle OrderCancelled: release reserved inventory, issue refund.
     */
    public function handleOrderCancelled(OrderCancelled $event): void
    {
        $order = $event->order;

        \Log::info('Order cancelled, releasing inventory', ['order_id' => $order->id]);

        $order->items->each(function ($item) {
            $item->product->increment('stock_quantity', $item->quantity);
        });
    }

    /**
     * subscribe() tells the Dispatcher which method handles which event.
     * This is called during boot — same timing as EventServiceProvider::$listen.
     *
     * The second argument to listen() is a priority integer.
     * We give handleOrderPlaced a higher priority (10) than the default (0)
     * to ensure inventory is reserved BEFORE fulfilment kicks off.
     */
    public function subscribe(Dispatcher $events): void
    {
        $events->listen(
            OrderPlaced::class,
            [self::class, 'handleOrderPlaced'],
            10 // run before other OrderPlaced listeners
        );

        $events->listen(\n            OrderShipped::class,\n            [self::class, 'handleOrderShipped']\n        );

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

// ─────────────────────────────────────────────────────────────
// Register the subscriber in EventServiceProvider:
// ─────────────────────────────────────────────────────────────
// protected $subscribe = [
//     \App\Listeners\OrderEventSubscriber::class,
// ];


// ─────────────────────────────────────────────────────────────
// Performance diagnostic — run this in Tinker or an artisan command
// to see every registered listener and whether it's queued.
// ─────────────────────────────────────────────────────────────
$dispatcher = app('events');
$listeners  = $dispatcher->getRawListeners(); // returns the internal $listeners array

foreach ($listeners as $eventName => $listenerClosures) {
    echo sprintf(
        "%-60s %d listener(s)\n",
        $eventName,
        count($listenerClosures)
    );
}
Output
App\Events\OrderPlaced 1 listener(s)
App\Events\OrderShipped 1 listener(s)
App\Events\OrderCancelled 1 listener(s)
App\Events\UserRegistered 3 listener(s)
App\Events\* 1 listener(s)
Interview Gold: Event Subscriber vs Multiple Listeners
Interviewers love asking when you'd use a Subscriber vs separate Listener classes. The answer: use Subscribers when a single cohesive domain area (like Orders) owns the handling of multiple related events. Use separate Listeners when different teams or packages own the reactions — a payment package shouldn't need to know about a notification package's listener.
Production Insight
Cause: Wildcard listeners (Event::listen('App\Events\*', ...)) fire on every single event dispatch. In a high-traffic app dispatching 100+ events per request, a wildcard listener doing any I/O (even a log write) adds measurable latency. Effect: A wildcard audit listener that does a synchronous Log::info() call adds ~1ms per event. At 100 events per request, that is 100ms of pure logging overhead in the hot path. Action: Replace wildcard listeners with explicit registration for events you actually care about. If you must use wildcards, ensure the listener is queued or does zero I/O. Profile with php artisan telescope:prune and check the Timeline view for event dispatch overhead.
Key Takeaway
Subscribers are a code organization tool, not a performance optimization. Use them for domain cohesion. Listener priority controls execution order — use it when one listener is a precondition for another. At scale, audit your event surface: count events per request, count sync listeners per event, and eliminate wildcard listeners that do I/O.
Subscriber vs Separate Listeners
IfOne domain area owns reactions to multiple related events (Orders: placed, shipped, cancelled).
UseUse a Subscriber. One class, one test file, one registration. Improves discoverability.
IfDifferent teams or packages own different reactions to the same event.
UseUse separate Listener classes. Each team registers their own. Avoids cross-team coupling.
IfA single event has many unrelated side effects (UserRegistered: email, Slack, analytics, workspace).
UseUse separate Listener classes. Each side effect is independently testable and deployable.

Eloquent Model Events: Tapping into the Lifecycle of Your Models

Eloquent automatically dispatches events at key points in a model's lifecycle: retrieved, creating, created, updating, updated, saving, saved, deleting, deleted, restoring, restored, and forceDeleted. These model events are part of the Eloquent ORM itself and work independently from the custom event system — but they can be connected to it. You have two primary ways to listen for model events:

  1. Define a $dispatchesEvents property on the model mapping lifecycle events to custom event classes. For example, protected $dispatchesEvents = ['created' => UserRegistered::class];.
  2. Use the boot() method of a service provider or a trait like ObservesAttributes with a dedicated observer class.

Observers are the recommended approach for complex logic. They are classes that group multiple model event handlers. You can attach an observer via User::observe(UserObserver::class) in your AppServiceProvider::boot().

Model events are synchronous by default and run within the same database transaction as the operation that triggered them. This means if a model event listener throws an exception, the entire save operation will be rolled back in a transaction. That's powerful for enforcing invariants — for example, you can prevent a user from being created if their email domain is not allowed by throwing an exception in a creating listener.

However, model events are not queued by default. If you want to perform side effects like sending an email after a user is created, you should dispatch a custom event inside the observer handler, and let that event's listener handle the queueing. Avoid heavy I/O directly inside model event listeners.

ModelEventsExample.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
<?php
// ─────────────────────────────────────────────────────────────
// Option 1: Using $dispatchesEvents to map to custom events
// ─────────────────────────────────────────────────────────────
namespace App\Models;

use App\Events\UserCreated;
use App\Events\UserUpdated;
use App\Events\UserDeleted;
use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    protected $dispatchesEvents = [
        'created' => UserCreated::class,   // after the model is persisted
        'updated' => UserUpdated::class,   // after an existing model is updated
        'deleted' => UserDeleted::class,   // after a model is soft-deleted or hard-deleted
    ];
    // Now whenever User::create() completes, it dispatches UserCreated event.
}

// ─────────────────────────────────────────────────────────────
// Option 2: Using an Observer (cleaner for multiple event handlers)
// ─────────────────────────────────────────────────────────────
namespace App\Observers;

use App\Models\User;
use Illuminate\Support\Facades\Log;

class UserObserver
{
    /**
     * Handle the User "creating" event.
     * This fires before the model is saved. Return false to abort creation.
     */
    public function creating(User $user): void
    {
        // Enforce invariant: reject banned email domains
        $bannedDomains = ['tempmail.com', 'throwaway.com'];
        $domain = substr(strrchr($user->email, '@'), 1);
        if (in_array($domain, $bannedDomains)) {
            throw new \InvalidArgumentException("Email domain $domain is not allowed.");
        }
    }

    /**
     * Handle the User "created" event.
     * This fires after the model is successfully saved.
     * Avoid heavy I/O here — dispatch a custom event instead.
     */
    public function created(User $user): void
    {
        // Log the creation, but do NOT send email here directly.
        Log::info('User created', ['user_id' => $user->id, 'email' => $user->email]);

        // Dispatch custom event so listeners can handle async side effects
        \App\Events\UserCreated::dispatch($user);
    }

    /**
     * Handle the User "deleting" event.
     * Runs before deletion. Can return false to prevent deletion.
     */
    public function deleting(User $user): void
    {
        // Prevent deletion of admin accounts
        if ($user->isAdmin()) {
            return false; // stops the delete
        }
    }

    /**
     * Handle the User "retrieved" event.
     * Fires whenever a model is fetched from the database.
     * Use for caching or transforming attributes.
     */
    public function retrieved(User $user): void
    {
        // Automatically set a computed attribute
        $user->full_name = trim($user->first_name . ' ' . $user->last_name);
    }
}

// Register the observer in a service provider's boot method:
// \App\Models\User::observe(\App\Observers\UserObserver::class);
Avoid Recursive Model Events
If you update a model inside an updating or updated listener, it will trigger the same event again, leading to an infinite loop. Use static $model->withoutEvents() or User::withoutEvents(function () { ... }) to update the model without firing events.
Production Insight
Cause: Model events are synchronous and run inside the same transaction as the operation. If a listener performs a slow I/O operation (e.g., sending an HTTP request to an external service), it blocks the database commit and delays the response. Effect: Database transactions hold locks longer, increasing contention and deadlock probability. Action: Use model events only for fast, failure-critical logic (validation, invariants). For side effects, dispatch a custom event with a queued listener inside the observer's created() or updated() method.
Key Takeaway
Model events are a powerful tool for enforcing invariants and reacting to model lifecycle changes. Use observers for clean organization, avoid heavy I/O inside them, and dispatch custom events for asynchronous side effects. Be careful with recursive event loops by using withoutEvents when updating models inside event listeners.

Conditionally Queuing Listeners with shouldQueue()

By default, when a listener implements ShouldQueue, it is always queued for every dispatch of its matching event. But what if you only want to queue the listener for certain conditions — for example, only send a push notification if the user is a premium member? Laravel provides the shouldQueue() method on the listener class. If this method returns false, the listener is executed synchronously for that particular dispatch. This gives you fine-grained control without needing to create separate listener classes.

The shouldQueue() method receives the event instance as its parameter. It runs before the job is pushed to the queue, so it can prevent unnecessary queue operations entirely. If you need to make a decision based on the event data, this is the cleanest pattern.

Note that shouldQueue() does not affect the listener's synchronous execution — if it returns false, the listener runs synchronously immediately. If you want to completely skip the listener for certain conditions, return early from handle() instead.

ConditionalQueueListener.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
<?php
// ─────────────────────────────────────────────────────────────
// Listener that only queues for VIP users
// ─────────────────────────────────────────────────────────────
namespace App\Listeners;

use App\Events\UserRegistered;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Mail;
use App\Mail\WelcomePremiumEmail;
use App\Mail\WelcomeBasicEmail;
use Throwable;

class SendWelcomeEmail implements ShouldQueue
{
    use InteractsWithQueue;

    public string $queue = 'emails';
    public int $tries = 3;

    /**
     * Decide whether to queue this listener based on the user's subscription level.
     * Only premium users get the queued email; basic users get a synchronous email.
     */
    public function shouldQueue(UserRegistered $event): bool
    {
        // Only queue for VIP users (premium or enterprise plans)
        return in_array($event->user->subscription_tier, ['premium', 'enterprise']);
    }

    /**
     * Handle the event - this runs either synchronously or via queue
     * depending on shouldQueue()'s return value.
     */
    public function handle(UserRegistered $event): void
    {
        if ($event->user->subscription_tier === 'premium') {
            Mail::to($event->user->email)->send(new WelcomePremiumEmail($event->user));
        } else {
            Mail::to($event->user->email)->send(new WelcomeBasicEmail($event->user));
        }
    }

    public function failed(UserRegistered $event, Throwable $exception): void
    {\n        \\\Log::error('Welcome email failed', [\n            'user_id' => $event->user->id,\n            'tier' => $event->user->subscription_tier,\n        ]);\n    }
}

// ─────────────────────────────────────────────────────────────
// Alternative: Using shouldQueue to skip queue for specific event payloads
// ─────────────────────────────────────────────────────────────
class AuditLogListener implements ShouldQueue
{
    public function shouldQueue($event): bool
    {
        // Don't queue audit logs for read-only events (like 'retrieved')
        // This assumes the event has a $action property.
        if (property_exists($event, 'action') && $event->action === 'read') {
            return false; // run synchronously
        }
        return true;
    }

    public function handle($event): void
    {
        // Write audit log entry
    }
}
shouldQueue Only Affects Queue Decision
If shouldQueue() returns false, the listener runs synchronously in the current process. The handle() method still executes — you are not skipping the listener, just choosing its execution mode. To completely skip the listener for certain conditions, use an early return inside handle().
Production Insight
Cause: Unconditionally queueing every listener dispatch leads to high queue volume and worker saturation. Effect: Slow processing of truly important jobs because workers are busy with unnecessary jobs (e.g., sending an email to every new user even though only premium users need a rich email). Action: Use shouldQueue() to conditionally queue only when the side effect is expensive enough to warrant asynchronous processing. For cheap operations like logging, run them synchronously.
Key Takeaway
shouldQueue() gives you per-dispatch control over whether a listener runs synchronously or on the queue. Use it to avoid queue overhead for cheap operations while still benefiting from async execution for expensive ones.

Failed Job Handling for Queued Listeners: Retry, Backoff, and Failure Callbacks

When a queued listener fails (its handle() method throws an exception), Laravel's queue system automatically retries the job. By default, a job is tried once and then marked as failed. You can customize retry behavior using properties and methods on the listener class:

  • $tries: Maximum number of attempts (including the first). Default is 1 for base jobs, but for Illuminate\Contracts\Queue\ShouldQueue listeners the default is 1 unless overridden.
  • $backoff: Array of seconds to wait between retries. For example, [2, 4, 8] waits 2 seconds after first failure, 4 after second, 8 after third. Alternatively, $backoff can be an integer used as a base for exponential backoff when combined with $maxAttempts.
  • $maxExceptions: Allows configuring the number of allowed exceptions before marking as failed. This is useful for transient errors vs permanent failures.
  • $retryUntil: A method that returns a timestamp. The job will be retried until that time, regardless of $tries.
  • failed() method: Called after all retries are exhausted. It receives the event and the exception. Use this for logging, alerting, or moving the job to a dead-letter queue.

Also, the retryUntil() method is a powerful pattern for jobs that should only be retried within a certain window — for example, a notification that loses relevance after 5 minutes.

When a job is released back to the queue using $this->release(30), the retry count is incremented even though failed() hasn't been called yet. The release() method is useful when you want to explicitly delay the job for a custom interval without waiting for the failure count.

FailedJobListener.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
<?php
// ─────────────────────────────────────────────────────────────
// Listener with advanced retry configuration
// ─────────────────────────────────────────────────────────────
namespace App\Listeners;

use App\Events\OrderPlaced;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\RateLimitedWithRedis;
use Illuminate\Support\Facades\Log;
use Throwable;

class ProcessOrderPayment implements ShouldQueue
{
    use InteractsWithQueue;

    public string $queue = 'payments';
    public string $connection = 'redis';

    /**
     * Retry up to 5 times before calling failed().
     */
    public int $tries = 5;

    /**
     * Exponential backoff in seconds between retries.
     */
    public array $backoff = [10, 30, 60, 120];

    /**
     * Only retry if the exception count is below this limit.
     * Uses the exception count from the job's attempts.
     */
    public int $maxExceptions = 3;

    /**
     * Alternatively, define a retryUntil method:
     */
    // public function retryUntil(): Carbon
    // {
    //     return now()->addMinutes(5); // job will not retry after 5 minutes
    // }

    /**
     * Middleware for rate limiting external API calls.
     */
    public function middleware(): array
    {
        return [
            new RateLimitedWithRedis('payment-gateway')
        ];
    }

    public function handle(OrderPlaced $event): void
    {
        // Attempt to process payment
        // If the payment gateway is down, throw an exception
        // The job will be retried with backoff
        $paymentService = app(PaymentService::class);
        $paymentService->charge($event->order->total, $event->order->paymentToken);
    }

    /**
     * Called after all retry attempts exhausted.
     * This is your last chance to do something — log, alert, or push to a dead-letter store.
     */
    public function failed(OrderPlaced $event, Throwable $exception): void
    {
        Log::critical('Payment processing failed after all retries', [\n            'order_id'  => $event->order->id,\n            'exception' => $exception->getMessage(),\n        ]);

        // Send a Slack notification to the on-call engineer
        // Or change the order status to 'payment_failed'
        $event->order->update(['status' => 'payment_failed']);
    }
}

// ─────────────────────────────────────────────────────────────
// Check failed jobs via artisan:
// php artisan queue:failed
// php artisan queue:retry all (retries all failed jobs)
// php artisan queue:forget 5 (forgets a specific failed job)

// View failed jobs table:
// SELECT * FROM failed_jobs;
Output
// Example output when running php artisan queue:failed
// +---+----------------+-------------------+-----------+
// | ID| Connection | Queue | Failed at |
// +---+----------------+-------------------+-----------+
// | 7 | redis | payments | 2026-05-12|
// +---+----------------+-------------------+-----------+
Do Not Rely on Failed Jobs Silence
Always implement a failed() method on queued listeners that do critical work (payments, emails, notifications). Without it, failed jobs silently disappear into the failed_jobs table, and no one gets alerted. That's a silent data loss channel. At minimum, log the failure and notify your team via Slack or PagerDuty.
● Production incidentPOST-MORTEMseverity: high

Phantom Welcome Emails After Failed Registrations

Symptom
Welcome emails arriving for users who never completed registration. Support tickets from confused users. Email service provider flagged unusual volume spikes.
Assumption
The team assumed that if a database transaction rolled back, any dispatched events would also be discarded automatically.
Root cause
The UserRegistered event was dispatched inside a DB::transaction() callback. When the transaction committed, the event had already been dispatched to the queue during the controller action — before the transaction boundary was checked. On retry logic in the queue worker, the job re-executed against a user row that existed in a replica but had been rolled back on the primary. Worse, a separate code path dispatched the same event outside the transaction on validation failure, sending duplicate emails.
Fix
1. Implemented ShouldDispatchAfterCommit on the UserRegistered event class, so dispatch is buffered until the outermost transaction commits. If it rolls back, the event is silently dropped. 2. Moved all event dispatches to occur after the service layer's transaction boundary, not inside controller logic. 3. Added idempotency checks in the SendWelcomeEmail listener: before sending, check a welcome_email_sent_at timestamp on the user model. 4. Added a monitoring alert for queue job volume anomalies per event type.
Key lesson
  • Events dispatched inside transactions are fire-and-forget from the dispatcher's perspective — the dispatcher does not know about your transaction boundary.
  • ShouldDispatchAfterCommit is not optional for events that depend on database state. It is a correctness requirement.
  • Idempotency in listeners is your last line of defense. Even with correct dispatch timing, queue retries can duplicate side effects.
  • Monitor queue job volumes per event type. A sudden spike in SendWelcomeEmail jobs is a signal something broke upstream.
Production debug guideWhen listeners don't fire, fire at the wrong time, or silently drop side effects.5 entries
Symptom · 01
Queued listener never executes — job appears in the jobs table but the worker never picks it up.
Fix
Check that php artisan queue:work is running with the correct --queue name. The listener's $queue property must match the queue name the worker is listening on. Run php artisan queue:failed to see if the job hit max attempts. Check Horizon dashboard if using Horizon.
Symptom · 02
Listener fires but can't find the model — ModelNotFoundException in the queue worker logs.
Fix
This is the classic DB transaction race condition. The event was dispatched before the transaction committed. Check if the Event class implements ShouldDispatchAfterCommit. If not, add it. As a band-aid, add public int $delay = 5; to the listener, but the interface fix is the real solution.
Symptom · 03
Event fires but the wrong listener reacts, or a listener reacts to events it shouldn't.
Fix
Run php artisan event:list to audit all registered event-listener pairs. Check for wildcard listeners (Event::listen('App\Events\*', ...)) that may be catching events unintentionally. Verify each listener's handle() type-hint matches the correct event class.
Symptom · 04
Listener runs twice — duplicate side effects (two emails, two invoices).
Fix
Check for duplicate listener registrations in EventServiceProvider. Run php artisan event:list and look for duplicates. If using Horizon, check for overlapping queue workers processing the same job. Add idempotency guards inside the listener (e.g., check a flag on the model before sending).
Symptom · 05
Event::fake() in tests passes, but in production the listener does nothing.
Fix
The listener was likely registered incorrectly — wrong namespace, wrong class name, or not registered at all. Run php artisan event:list in production. Use Event::assertListening() in tests to verify registration. Check that EventServiceProvider is listed in config/app.php providers array.
★ Laravel Events & Queues Triage Cheat SheetFast diagnostics for event dispatch failures, queue issues, and listener misbehavior.
Queued listener not executing.
Immediate action
Verify queue worker is running and listening on the correct queue name.
Commands
php artisan queue:work --queue=notifications,default --tries=3
php artisan queue:failed
Fix now
Check listener's $queue property matches the worker's --queue flag. Restart worker: php artisan queue:restart
ModelNotFoundException in queued listener.+
Immediate action
DB transaction race condition — event dispatched before commit.
Commands
grep -r 'ModelNotFoundException' storage/logs/laravel.log | tail -5
php artisan tinker --execute="dump(app('events')->getRawListeners());"
Fix now
Add ShouldDispatchAfterCommit to the Event class. Remove any $delay band-aid.
Duplicate listener execution (side effects fire twice).+
Immediate action
Check for duplicate registrations and overlapping queue workers.
Commands
php artisan event:list | sort | uniq -d
ps aux | grep 'queue:work'
Fix now
Kill duplicate workers. Remove duplicate listener registrations. Add idempotency check in listener.
Slow HTTP response due to synchronous listeners.+
Immediate action
Identify which listeners are running synchronously in the hot path.
Commands
php artisan telescope:prune # then check Telescope for slow events
php artisan event:list # verify which listeners have ShouldQueue
Fix now
Add ShouldQueue interface to the offending listener class. Add $delay = 0 if immediate execution is needed.
Wildcard listener catching unintended events.+
Immediate action
Audit wildcard patterns and their scope.
Commands
grep -r "Event::listen\(" app/Providers/
php artisan event:list
Fix now
Narrow wildcard pattern or replace with explicit event registration in $listen array.
🔥

That's Laravel. Mark it forged?

8 min read · try the examples if you haven't

Previous
Laravel Service Container
12 / 15 · Laravel
Next
Laravel Testing with PHPUnit