When Queued Event Listeners Silently Die: The ShouldQueue Trap

📖 3 minutes read

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.

Daryle De Silva

VP of Technology

11+ years building and scaling web applications. Writing about what I learn in the trenches.

Comments

Leave a Reply

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