You dispatch an event from inside a queued job. The event has a listener that implements ShouldQueue. Your job completes successfully, but the listener never executes. No exception. No failed job entry. No log. It just⦠doesnāt run.
This is one of Laravelās most frustrating silent failures.
The Setup
You have a workflow: when a user account is deactivated, trigger a data export automatically. The architecture looks clean:
// In your DeactivationHandler (queued job)
class HandleAccountDeactivation implements ShouldQueue
{
public function handle(): void
{
// Revoke access tokens for the account
$this->revokeAccessTokens($this->account);
// Dispatch event for downstream processing
event(new AccountDeactivated($this->account));
}
}
// The listener
class TriggerDataExport implements ShouldQueue
{
public function handle(AccountDeactivated $event): void
{
// This never runs!
$this->exportService->generate($event->account);
}
}
Why It Fails Silently
When you dispatch an event from within a queued job, and the listener also implements ShouldQueue, the listener gets pushed onto the queue as a new job. But hereās the catch: if the dispatching jobās database transaction hasnāt committed yet (or if the queue connection has issues during nested dispatching), the listener job can fail before it even starts ā and this failure happens at the queue infrastructure level, not in your application code.
A try-catch around event() wonāt help. The event dispatch itself succeeds ā it pushes a message onto the queue. The failure happens later, when the queue worker tries to process the listener job.
The Fix: Make Critical Listeners Synchronous
For listeners that are part of a critical workflow ā where silent failure is unacceptable ā remove ShouldQueue:
// Make it synchronous ā runs in the same process as the dispatcher
class TriggerDataExport // No ShouldQueue
{
public function handle(AccountDeactivated $event): void
{
try {
$this->exportService->generate($event->account);
} catch (\Throwable $e) {
// Now you CAN catch failures
$event->account->addNote(
"Automatic data export failed: {$e->getMessage()}"
);
$event->account->flagForReview('compliance');
}
}
}
Alternative: Direct Method Calls for Critical Paths
If the listener exists solely because of one dispatcher, skip events entirely for the critical path:
class HandleAccountDeactivation implements ShouldQueue
{
public function handle(DataExportService $exportService): void
{
$this->revokeAccessTokens($this->account);
// Direct call instead of event dispatch
try {
$exportService->generateComplianceExport($this->account);
} catch (\Throwable $e) {
$this->account->addNote("Automatic data export failed: {$e->getMessage()}");
$this->account->flagForReview('compliance');
}
}
}
When Events Are Still Right
Events shine when:
- Multiple independent listeners react to the same event
- The listenerās failure doesnāt affect the main workflow
- You genuinely need decoupling (different bounded contexts)
But when a queued job dispatches an event to a queued listener for a single critical operation? Thatās a fragile chain with a silent failure mode. Make it synchronous or call the service directly.
The rule of thumb: if the listener failing means the workflow is broken, donāt put a queue boundary between them.
Leave a Reply