Laravel Events decouple actions from side effects. One event fires, zero or many listeners react — without knowing about each other.
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.
Performance insight: A single synchronous listener adding 200ms can degrade response times by seconds under load — queue every I/O listener.
Production insight: 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.
✦ Definition~90s read
What is Laravel Events and Listeners?
Laravel Events are a pub/sub implementation that decouples side effects from your core application logic. Instead of jamming email-sending, logging, or cache-clearing directly into a controller or model method, you fire an event (a simple data class) and let one or more listeners react to it.
★
Think of a busy coffee shop.
This prevents a failed registration from breaking the entire request when a mail server is down — the event fires, the listener queues the email, and the user gets a 201 response immediately. Without events, you'd be writing fragile, tightly-coupled code that mixes concerns and makes testing a nightmare.
Under the hood, Laravel's event dispatcher is a dependency-injected singleton that maintains an array of event-to-listener mappings. When you call Event::dispatch(), it loops through registered listeners, resolves them from the container, and calls their handle() method — either synchronously in the same request lifecycle or asynchronously via the queue.
The ShouldDispatchAfterCommit trait ensures events only fire after the current database transaction commits, preventing phantom actions on rolled-back data. For high-traffic apps, you queue listeners that do I/O (email, HTTP calls) and keep synchronous listeners only for in-memory operations like incrementing counters.
Eloquent Model Events (creating, created, updating, updated, etc.) are a specialized form of this pattern that hook into ActiveRecord lifecycle callbacks. They're ideal for audit trails, cache invalidation, or firing domain events when a model changes state.
But beware: model events execute inside the same transaction as the model save, so a failing listener can roll back the entire operation. For production systems, prefer dispatching a custom event from your service layer rather than relying on model event listeners for critical side effects — it gives you explicit control over when and how side effects happen, and makes your code testable with Event::fake().
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 Events & Listeners Decouple Side Effects
Laravel Events and Listeners implement the observer pattern: an Event class encapsulates data about an action (e.g., UserRegistered), and one or more Listener classes react to it. The core mechanic is decoupling — the code that triggers the event doesn't know or care what happens next. You fire the event with event(new UserRegistered($user)); each registered listener runs sequentially in the same process by default. This is synchronous, not async, unless you queue the listener. The framework resolves listeners from the service container, so you can inject dependencies directly. A single event can fan out to ten listeners; each one is a separate class with a single responsibility. This keeps your controllers and services lean — they dispatch events instead of chaining side effects like sending emails, logging, or invalidating caches. The performance cost is the sum of all listener execution times, so heavy work must be queued or it blocks the response.
Sync by default
Listeners run synchronously in the same request unless you implement ShouldQueue — a slow listener blocks the HTTP response.
Production Insight
A registration endpoint fires UserRegistered, and a listener sends a welcome email via an external API. The external API is slow or down — the entire registration request hangs for 30 seconds, timing out the user. The rule: any listener that calls an external service or does I/O must be queued, or the event must be dispatched after the response is sent (dispatchAfterResponse).
Key Takeaway
Events decouple producers from consumers — the dispatcher never knows what listeners exist.
Listeners are synchronous by default; queue them for any I/O or slow operation.
Each listener is a single-responsibility class resolved from the container — test them in isolation.
thecodeforge.io
Laravel Events & Listeners Flow
Laravel Events Listeners
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
53
54
55
56
57
58
59
60
61
62
63
<?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.// ─────────────────────────────────────────────────────────────namespaceApp\Events;
useApp\Models\User;
useIlluminate\Foundation\Events\Dispatchable;
useIlluminate\Queue\SerializesModels;
classUserRegistered
{
use Dispatchable; // adds the static ::dispatch() factory
use SerializesModels; // safely serialises Eloquent models for queued listenerspublicfunction__construct(
public readonly User $user, // PHP 8 constructor promotion — clean and immutablepublicreadonly string $registrationSource = 'web'
) {}
}
// ─────────────────────────────────────────────────────────────// 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:// [// 'App\Events\UserRegistered' => [// Closure (wraps SendWelcomeEmail::class),// Closure (wraps CreateDefaultWorkspace::class),// ]// ]//// 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(newUserRegistered($user, 'api'));
// Option C — via the facade (useful when you need the return values):
$responses = \Illuminate\Support\Facades\Event::dispatch(newUserRegistered($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
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, 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
105
106
107
108
109
110
111
112
113
114
<?php
// app/Providers/EventServiceProvider.phpnamespaceApp\Providers;
useApp\Events\UserRegistered;
useApp\Events\OrderShipped;
useApp\Listeners\SendWelcomeEmail;
useApp\Listeners\CreateDefaultWorkspace;
useApp\Listeners\NotifyAdminSlackChannel;
useApp\Listeners\SendShipmentConfirmation;
useIlluminate\Foundation\Support\Providers\EventServiceProviderasServiceProvider;
useIlluminate\Support\Facades\Event;
classEventServiceProviderextendsServiceProvider
{
/**
* 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 asyncCreateDefaultWorkspace::class, // queued — heavy DB workNotifyAdminSlackChannel::class, // queued — external HTTP call
],
OrderShipped::class => [
SendShipmentConfirmation::class,
],
];
publicfunctionboot(): 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', [
'event' => $eventName,
'user_id' => auth()->id(),
'ip_address' => request()->ip(),
'timestamp' => now()->toIso8601String(),
]);
});
}
}
// ─────────────────────────────────────────────────────────────// A production-grade queued listener// ─────────────────────────────────────────────────────────────// app/Listeners/SendWelcomeEmail.phpnamespaceApp\Listeners;
useApp\Events\UserRegistered;
useApp\Mail\WelcomeEmail;
useIlluminate\Contracts\Queue\ShouldQueue;
useIlluminate\Queue\InteractsWithQueue;
useIlluminate\Support\Facades\Mail;
useThrowable;
classSendWelcomeEmailimplementsShouldQueue
{
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.
*/
publicfunctionhandle(UserRegistered $event): void
{
Mail::to($event->user->email)
->send(newWelcomeEmail($event->user, $event->registrationSource));
}
/**
* Called after all retries are exhausted.
* Use this to alert your team or write to a dead-letter log.
*/
publicfunctionfailed(UserRegistered $event, Throwable $exception): void
{
\Log::error('WelcomeEmail failed permanently', [
'user_id' => $event->user->id,
'exception' => $exception->getMessage(),
]);
// Optionally notify your on-call team via PagerDuty, Slack, etc.
}
}
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
Listeners that perform I/O (HTTP calls, email sending, DB writes to external systems) block the HTTP response when synchronous.
Effect: 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
94
95
96
97
98
99
100
101
102
<?php
// ─────────────────────────────────────────────────────────────// Pattern 1: ShouldDispatchAfterCommit on the Event class// ─────────────────────────────────────────────────────────────namespaceApp\Events;
useApp\Models\Order;
useIlluminate\Contracts\Events\ShouldDispatchAfterCommit;
useIlluminate\Foundation\Events\Dispatchable;
useIlluminate\Queue\SerializesModels;
classOrderPlacedimplementsShouldDispatchAfterCommit
{
useDispatchable, SerializesModels;
publicfunction__construct(
publicreadonlyOrder $order
) {}
// 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// ─────────────────────────────────────────────────────────────namespaceTests\Feature;
useApp\Events\UserRegistered;
useApp\Events\OrderPlaced;
useApp\Listeners\SendWelcomeEmail;
useApp\Models\User;
useIlluminate\Support\Facades\Event;
useTests\TestCase;
classUserRegistrationTestextendsTestCase
{
publicfunctiontest_user_registration_dispatches_correct_event(): void
{
// Fake ALL events — no listeners run, no emails sent.Event::fake();
$response = $this->postJson('/api/register', [
'name' => 'Alice Nguyen',
'email' => 'alice@example.com',
'password' => 'supersecret123',
]);
$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);
}
publicfunctiontest_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);
}
publicfunctiontest_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(
UserRegistered::class,
SendWelcomeEmail::class
);
}
}
// ─────────────────────────────────────────────────────────────// Pattern 3: dispatchAfterResponse — fire and forget for UI events// ─────────────────────────────────────────────────────────────// In a controller action:publicfunctionstore(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');
returnresponse()->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
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.
Action: Use two testing layers — feature tests with Event::fake([EventClass::class]) to assert dispatch, and 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. 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
108
109
110
<?php
// ─────────────────────────────────────────────────────────────// Event Subscriber — one class, multiple events, one registration.// Ideal when a domain area (Orders) owns several related events.// ─────────────────────────────────────────────────────────────namespaceApp\Listeners;
useApp\Events\OrderPlaced;
useApp\Events\OrderShipped;
useApp\Events\OrderCancelled;
useApp\Models\Order;
useIlluminate\Contracts\Queue\ShouldQueue;
useIlluminate\Events\Dispatcher;
// ShouldQueue on a subscriber applies to ALL its listener methods.classOrderEventSubscriberimplementsShouldQueue
{
public string $queue = 'orders';
/**
* HandleOrderPlaced: reserve inventory, charge the card.
*/
publicfunctionhandleOrderPlaced(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);
});
}
/**
* HandleOrderShipped: notify the customer, update analytics.
*/
publicfunctionhandleOrderShipped(OrderShipped $event): void
{
$order = $event->order;
\Log::info('Order shipped, sending tracking info', ['order_id' => $order->id]);
// ... send tracking email, push to analytics ...
}
/**
* HandleOrderCancelled: release reserved inventory, issue refund.
*/
publicfunctionhandleOrderCancelled(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 asEventServiceProvider::$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.
*/
publicfunctionsubscribe(Dispatcher $events): void
{
$events->listen(
OrderPlaced::class,
[self::class, 'handleOrderPlaced'],
10// run before other OrderPlaced listeners
);
$events->listen(
OrderShipped::class,
[self::class, 'handleOrderShipped']
);
$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 arrayforeach ($listeners as $eventName => $listenerClosures) {
echosprintf(
"%-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
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 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.
At scale, audit your event surface: count events per request, eliminate I/O in wildcard listeners.
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:
Define a $dispatchesEvents property on the model mapping lifecycle events to custom event classes. For example, protected $dispatchesEvents = ['created' => UserRegistered::class];.
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// ─────────────────────────────────────────────────────────────namespaceApp\Models;
useApp\Events\UserCreated;
useApp\Events\UserUpdated;
useApp\Events\UserDeleted;
useIlluminate\Database\Eloquent\Model;
classUserextendsModel
{
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)// ─────────────────────────────────────────────────────────────namespaceApp\Observers;
useApp\Models\User;
useIlluminate\Support\Facades\Log;
classUserObserver
{
/**
* Handle the User"creating" event.
* This fires before the model is saved. Returnfalse to abort creation.
*/
publicfunctioncreating(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)) {
thrownew \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.
*/
publicfunctioncreated(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. Canreturnfalse to prevent deletion.
*/
publicfunctiondeleting(User $user): void
{
// Prevent deletion of admin accountsif ($user->isAdmin()) {
return false; // stops the delete
}
}
/**
* Handle the User"retrieved" event.
* Fires whenever a model is fetched from the database.
* Usefor caching or transforming attributes.
*/
publicfunctionretrieved(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
Model events are synchronous and run inside the same transaction as the operation.
If a listener performs a slow I/O operation, 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.
Dispatch custom events for async side effects. Avoid recursive loops using withoutEvents.
When to Use Each Model Event Approach
IfNeed to enforce invariant or abort save (e.g., disallow certain email domains).
→
UseUse creating event in an observer. Return false or throw exception.
IfNeed to perform a side effect after successful persistence (e.g., send email, log).
→
UseUse created event. Avoid heavy I/O directly; dispatch a custom event with a queued listener.
IfNeed to perform an action before deletion (e.g., check permissions).
→
UseUse deleting event. Return false to prevent deletion.
IfNeed to transform data on model retrieval (e.g., set computed attributes).
→
UseUse retrieved event in observer.
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
69
70
71
72
73
<?php
// ─────────────────────────────────────────────────────────────// Listener that only queues for VIP users// ─────────────────────────────────────────────────────────────namespaceApp\Listeners;
useApp\Events\UserRegistered;
useIlluminate\Contracts\Queue\ShouldQueue;
useIlluminate\Queue\InteractsWithQueue;
useIlluminate\Support\Facades\Mail;
useApp\Mail\WelcomePremiumEmail;
useApp\Mail\WelcomeBasicEmail;
useThrowable;
classSendWelcomeEmailimplementsShouldQueue
{
useInteractsWithQueue;
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.
*/
publicfunctionshouldQueue(UserRegistered $event): bool
{
// Only queue for VIP users (premium or enterprise plans)returnin_array($event->user->subscription_tier, ['premium', 'enterprise']);
}
/**
* Handle the event - this runs either synchronously or via queue
* depending on shouldQueue()'s return value.
*/
publicfunctionhandle(UserRegistered $event): void
{
if ($event->user->subscription_tier === 'premium') {
Mail::to($event->user->email)->send(newWelcomePremiumEmail($event->user));
} else {
Mail::to($event->user->email)->send(newWelcomeBasicEmail($event->user));
}
}
publicfunctionfailed(UserRegistered $event, Throwable $exception): void
{
\Log::error('Welcome email failed', [
'user_id' => $event->user->id,
'tier' => $event->user->subscription_tier,
]);
}
}
// ─────────────────────────────────────────────────────────────// Alternative: Using shouldQueue to skip queue for specific event payloads// ─────────────────────────────────────────────────────────────classAuditLogListenerimplementsShouldQueue
{
publicfunctionshouldQueue($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
}
returntrue;
}
publicfunctionhandle($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
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 per-dispatch control over sync vs async execution.
Use it to avoid queue overhead for cheap operations.
Still benefit from async execution for expensive ones.
When to Use shouldQueue()
IfExpensive side effect (e.g., sending rich email to premium users, calling external API).
→
UseReturn true from shouldQueue() to queue the listener.
IfCheap side effect (e.g., logging, updating a local counter).
→
UseReturn false from shouldQueue() to run synchronously and avoid queue overhead.
IfSide effect must be skipped entirely for certain conditions.
→
UseDo not use shouldQueue for skipping. Instead, return early from handle() based on the condition.
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
86
87
88
<?php
// ─────────────────────────────────────────────────────────────// Listener with advanced retry configuration// ─────────────────────────────────────────────────────────────namespaceApp\Listeners;
useApp\Events\OrderPlaced;
useIlluminate\Contracts\Queue\ShouldQueue;
useIlluminate\Queue\InteractsWithQueue;
useIlluminate\Queue\Middleware\RateLimitedWithRedis;
useIlluminate\Support\Facades\Log;
useThrowable;
classProcessOrderPaymentimplementsShouldQueue
{
useInteractsWithQueue;
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.
*/
publicarray $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// }
/**
* Middlewarefor rate limiting external API calls.
*/
publicfunctionmiddleware(): array
{
return [
newRateLimitedWithRedis('payment-gateway')
];
}
publicfunctionhandle(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.
*/
publicfunctionfailed(OrderPlaced $event, Throwable $exception): void
{
Log::critical('Payment processing failed after all retries', [
'order_id' => $event->order->id,
'exception' => $exception->getMessage(),
]);
// 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
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 Insight
Without a failed() method, failed jobs silently disappear into the failed_jobs table.
Effect: No one gets alerted, leading to silent data loss. Critical operations like payments or notifications fail without visibility.
Action: Always implement a failed() method that logs the failure and alerts your team via Slack, PagerDuty, or email. Additionally, monitor the failed_jobs table size in your observability dashboard.
Key Takeaway
Always implement failed() on critical queued listeners.
Never rely on failed_jobs table alone for monitoring.
Use alerts to ensure you know when jobs fail.
Retry Strategy Decision Guide
IfTransient failures likely (network timeouts, temporary service degradation).
→
UseSet $tries = 3-5 with exponential $backoff. Use $maxExceptions to distinguish transient from persistent errors.
IfJob loses relevance after a time window (e.g., time-sensitive notification).
→
UseDefine retryUntil() method returning a timestamp. Job will not retry past that point.
IfCritical, non-idempotent operation (e.g., charging a credit card).
→
UseSet $tries = 1 and implement a failed() method that alerts immediately. Add idempotency key inside handle() to prevent duplicate charges.
IfRate-limited external API calls.
→
UseUse middleware like RateLimitedWithRedis. Adjust $backoff based on API rate limits.
Introduction to Event-Driven Architecture
You've seen the tight-coupling nightmare: a UserRegistered handler that sends three emails, updates CRM, calls analytics, and pokes a legacy API. One service goes down, the whole registration breaks. That's not architecture, that's a house of cards.
Event-driven architecture flips this. Components shout "something happened" and walk away. They don't care who listens. Listeners pick up the signal and act independently. If the email listener fails, the registration still succeeds. Your system degrades gracefully instead of collapsing.
Laravel's event system implements this pattern at the framework level. It's not just a feature — it's the mortar that keeps your application modular when requirements explode. Events decouple the what from the how. Listeners handle the how without touching the what.
The payoff? You can add new features by dropping in a new listener. No existing code changes. That's the difference between a codebase that ages like wine and one that rots.
EventDrivenPayment.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// io.thecodeforge — php tutorial// Without events: tight couplingclassOrderController {
publicfunctioncheckout(Request $request) {
$order = Order::create($request->all());
$this->paymentGateway->charge($order);
$this->emailService->sendConfirmation($order);
$this->analytics->track('order_placed', $order->id);
$this->inventoryService->decrement($order);
// If any of these fails, order might not save
}
}
// With events: decoupledclassOrderController {
publicfunctioncheckout(Request $request) {
$order = Order::create($request->all());
event(newOrderPlaced($order));
// Done. Listeners handle the rest.
}
}
Output
No output — this is structural code showing the before/after pattern.
Production Trap:
Never put business logic inside the controller or event dispatcher. Events are notifications, not execution engines. Your controller should only fire events after domain actions complete.
Key Takeaway
Events decouple producers from consumers. A controller fires an event and forgets. Listeners handle the fallout independently.
Setting Up Events and Listeners the Sane Way
Stop generating event and listener classes by hand like it's 2015. Artisan has your back: php artisan event:generate reads your EventServiceProvider and creates missing files. But that's the easy part.
The real trick is auto-discovery. In production, you don't want to manually register every event-listener pair in a giant array. Laravel's event:cache command scans your listeners and builds a manifest. This kills the need to load every file on every request — your code runs faster, your memory drops.
Here's the workflow: drop your listeners in app/Listeners, your events in app/Events. Don't touch the provider map unless you need manual control. Run php artisan event:cache in deployment. Done.
One caveat: auto-discovery only works if your listener's handle method type-hints the event class. Miss the type-hint? Silent failure, zero output, wasted debugging. Always verify with event:list.
Add event:cache to your deployment script. It's one line that cuts request latency by skipping file scans. If you change listeners, run event:clear in dev, then recache.
Key Takeaway
Auto-discovery + caching is the default for performance. The handle() method must type-hint the event class or your listener silently vanishes.
● 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.
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.
Synchronous vs Queued Listeners
Aspect
Synchronous
Queued
Execution timing
Immediately in same request
Asynchronously via queue worker
Impact on HTTP response
Blocks until complete
Returns immediately; worker processes later
Exception handling
Propagates to caller
Retried up to $tries, then failed() called
Ideal use case
Validation, fraud scoring, critical preconditions
Emails, notifications, API calls, heavy processing
Testing
Directly call handle() on the listener instance
Use Event::fake() and assert dispatched, or Queue::fake()
Key takeaways
1
Use ShouldDispatchAfterCommit on events dispatched inside DB::transaction() to prevent phantom side effects when the transaction rolls back.
2
Queued listeners (implementing ShouldQueue) move I/O off the HTTP hot path and prevent a failing external service from blocking the response.
3
Event::fake() in tests replaces the dispatcher with a spy, letting you assert events were dispatched without executing any listeners.
4
Synchronous listeners run in the same process and transaction as the caller; exceptions propagate and can roll back the entire operation.
5
Returning false from a listener halts event propagation
use this instead of throwing an exception to stop subsequent listeners.
Common mistakes to avoid
4 patterns
×
Dispatching events inside a transaction without ShouldDispatchAfterCommit
Symptom
Listeners fire before the transaction commits, leading to phantom events or missing models in queued jobs.
Fix
Implement ShouldDispatchAfterCommit on the event class or dispatch after the transaction completes using DB::afterCommit().
×
Not implementing ShouldQueue on listeners that do I/O
Symptom
HTTP responses are slow because listeners block the request, causing timeouts under load.
Fix
Add ShouldQueue to any listener that sends emails, calls APIs, or performs heavy computation. Profile with Telescope to find sync listeners.
×
Registering duplicate listeners in EventServiceProvider
Symptom
Side effects run twice (e.g., double emails, duplicate log entries).
Fix
Run php artisan event:list and check for duplicates. Remove the extra registration from the $listen array.
×
Using wildcard listeners that do I/O
Symptom
Every event dispatch adds latency due to logging or analytics calls. Wildcards fire on all events.
Fix
Replace wildcard listeners with explicit registrations for specific events, or ensure the wildcard listener is queued and lightweight.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01SENIOR
Explain the difference between synchronous and queued listeners in Larav...
Q02SENIOR
How does Laravel handle events dispatched inside a database transaction?...
Q03JUNIOR
Describe how Event::fake() works in tests and what it replaces. How do y...
Q04SENIOR
What is the purpose of the `shouldQueue()` method on a listener? Give a ...
Q01 of 04SENIOR
Explain the difference between synchronous and queued listeners in Laravel. When would you use each?
ANSWER
Synchronous listeners run in the same process as the dispatch and block the response. Use them when the caller needs the result (e.g., fraud scoring, validation). Queued listeners run asynchronously via a queue driver like Redis or database. Use them for side effects like emails, API calls, or analytics that don't need immediate completion. Queued listeners implement ShouldQueue and can be configured with retries, backoff, and failure callbacks.
Q02 of 04SENIOR
How does Laravel handle events dispatched inside a database transaction? What is the potential issue and how do you fix it?
ANSWER
Events dispatched inside a DB transaction are fired immediately, regardless of whether the transaction commits or rolls back. This can lead to 'phantom events' — actions like sending an email even though the transaction failed. The fix is to implement ShouldDispatchAfterCommit on the event class, which buffers the dispatch until the outermost transaction commits. If the transaction rolls back, the event is never dispatched. Alternatively, dispatch events after the transaction completes using DB::afterCommit() or by moving the dispatch outside the transaction block.
Q03 of 04JUNIOR
Describe how Event::fake() works in tests and what it replaces. How do you assert that a specific listener is attached to an event?
ANSWER
Event::fake() replaces the real event dispatcher with a spy that records all dispatched events without running any listeners. After the tested action, you can use assertDispatched() to check that a specific event was fired with the correct data. Additionally, assertListening() verifies that a specific listener class is registered for an event, without executing it. This allows testing the coupling between events and listeners without side effects.
Q04 of 04SENIOR
What is the purpose of the `shouldQueue()` method on a listener? Give a real-world example.
ANSWER
shouldQueue() allows a listener that implements ShouldQueue to conditionally decide whether to queue itself or run synchronously for a specific dispatch. For example, an email listener might only queue for premium users to avoid overhead for basic users who get a simple synchronous email. The method receives the event instance and returns a boolean. If it returns false, the listener runs synchronously in the current process.
01
Explain the difference between synchronous and queued listeners in Laravel. When would you use each?
SENIOR
02
How does Laravel handle events dispatched inside a database transaction? What is the potential issue and how do you fix it?
SENIOR
03
Describe how Event::fake() works in tests and what it replaces. How do you assert that a specific listener is attached to an event?
JUNIOR
04
What is the purpose of the `shouldQueue()` method on a listener? Give a real-world example.
SENIOR
FAQ · 3 QUESTIONS
Frequently Asked Questions
01
Can I dispatch an event without registering any listeners?
Yes. Dispatching an event with no listeners is harmless. The dispatcher will iterate an empty listener array and return null quickly. This can be useful for pluggable architectures where listeners are optional or for events that may have listeners added later.
Was this helpful?
02
How do I test that a queued listener is pushed to the correct queue?
Use Event::fake() to verify the event was dispatched. To inspect queue details, use Queue::fake() and call assertPushed() with the listener job class. Alternatively, run php artisan queue:work in a test and assert the side effect. For granular control, write a unit test for the listener and mock the queue facade.
Was this helpful?
03
What happens if a listener throws an exception during synchronous execution?
The exception propagates to the caller (the code that dispatched the event). If inside a DB transaction, it will typically roll back the transaction. For queued listeners, the exception causes a retry (up to $tries), then calls the failed() method after exhausting retries.