Copied!
Programming
Laravel
PHP

Event-Driven Queues in Laravel – ShouldQueue on Listeners, Batching & Chaining

Event-Driven Queues in Laravel – ShouldQueue on Listeners, Batching & Chaining
Shahroz Javed
Mar 27, 2026 . 95 views

Why Event-Driven Queue Architecture

The traditional approach puts all post-action logic in the controller:

// ❌ Bloated controller — knows too much, does too much
public function store(StoreOrderRequest $request): JsonResponse
{
    $order = Order::create($request->validated());
    Mail::to($order->customer)->send(new OrderConfirmation($order));
    $order->customer->notify(new OrderPlacedNotification($order));
    SyncToErp::dispatch($order);
    UpdateInventory::dispatch($order);
    NotifyFulfillmentTeam::dispatch($order);
    GenerateInvoice::dispatch($order);
    // ... and growing every sprint
    return response()->json($order, 201);
}

The event-driven approach decouples everything. The controller fires one event. Every downstream action is an independent listener:

// ✅ Clean controller — does one thing
public function store(StoreOrderRequest $request): JsonResponse
{
    $order = Order::create($request->validated());
    event(new OrderPlaced($order));
    return response()->json($order, 201);
}

Adding a new action on order placement = adding one new listener class. The controller, and every existing listener, stays untouched. This is the Open/Closed Principle applied to queue architecture.

ShouldQueue on Listeners

Add ShouldQueue to any listener to make it run asynchronously in the queue instead of blocking the request:

// app/Listeners/SendOrderConfirmationEmail.php
namespace App\Listeners;

use App\Events\OrderPlaced;
use App\Mail\OrderConfirmation;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Mail;

class SendOrderConfirmationEmail implements ShouldQueue
{
    use InteractsWithQueue;

    public function handle(OrderPlaced $event): void
    {
        Mail::to($event->order->customer)
            ->send(new OrderConfirmation($event->order));
    }
}

// app/Listeners/SyncOrderToErp.php
class SyncOrderToErp implements ShouldQueue
{
    use InteractsWithQueue;

    public int $tries = 5;
    public array $backoff = [30, 60, 120, 300, 600];

    public function handle(OrderPlaced $event): void
    {
        ErpService::syncOrder($event->order);
    }

    public function failed(OrderPlaced $event, \Throwable $e): void
    {
        \Log::error("ERP sync failed for order #{$event->order->id}: " . $e->getMessage());
    }
}

Listener Queue, Connection & Retries

Queued listeners support the same job properties as regular jobs — set them as public properties on the listener class:

class ProcessHighValueOrder implements ShouldQueue
{
    use InteractsWithQueue;

    // Route to a dedicated queue
    public string $queue = 'high-value-orders';

    // Use Redis for this listener specifically
    public string $connection = 'redis';

    // Retry logic
    public int $tries = 3;
    public array $backoff = [10, 30, 60];
    public int $timeout = 120;

    // Delete if the order was deleted before this listener ran
    public bool $deleteWhenMissingModels = true;

    public function handle(OrderPlaced $event): void
    {
        // process...
    }
}
⚠️ The failed() method on a queued listener receives both the event AND the exception as arguments — different from a job's failed() which only receives the exception.

Dispatching Job Chains from Listeners

Listeners can orchestrate complex job chains. The listener is the entry point; it builds and dispatches the entire workflow:

class OrchestrateOrderFulfillment implements ShouldQueue
{
    use InteractsWithQueue;

    public function handle(OrderPlaced $event): void
    {
        $order = $event->order;

        Bus::chain([
            new ValidateOrderItems($order),
            new ReserveInventory($order),
            new ProcessPayment($order),
            new GenerateInvoice($order),
            new ArrangeShipping($order),
            new SendOrderConfirmation($order),
        ])
        ->catch(function (\Throwable $e) use ($order) {
            $order->update(['status' => 'fulfillment_failed']);
            $order->customer->notify(new OrderFulfillmentFailed($order));
        })
        ->onQueue('fulfillment')
        ->dispatch();
    }
}

Multiple Independent Listeners per Event

Multiple listeners on the same event run independently — one failure doesn't affect others. Register them all in EventServiceProvider:

// app/Providers/EventServiceProvider.php
protected $listen = [
    OrderPlaced::class => [
        SendOrderConfirmationEmail::class,   // queued — email
        SendOrderSms::class,                 // queued — SMS
        OrchestrateOrderFulfillment::class,  // queued — fulfillment chain
        UpdateAnalyticsDashboard::class,     // queued — analytics
        SyncOrderToErp::class,               // queued — ERP sync
        AddToLoyaltyProgram::class,          // queued — loyalty points
    ],
];

All six listeners are dispatched to the queue the moment event(new OrderPlaced($order)) fires. They run in parallel, independently, with their own retry configuration.

Conditional & Filtered Listeners

Use the shouldQueue() method to conditionally prevent a listener from being queued at all. This is evaluated synchronously before the job is dispatched — zero queue overhead for skipped listeners:

class SendPremiumOrderGift implements ShouldQueue
{
    use InteractsWithQueue;

    // Return false to skip queuing this listener entirely
    public function shouldQueue(OrderPlaced $event): bool
    {
        return $event->order->total >= 500  // only for large orders
            && $event->order->customer->isPremium()
            && ! $event->order->customer->hasReceivedGiftThisMonth();
    }

    public function handle(OrderPlaced $event): void
    {
        SendPremiumGift::dispatch($event->order->customer);
    }
}

Event Batching Pattern

When many events fire rapidly (e.g. inventory updates from a CSV import), dispatching a queue job per event creates massive overhead. Batch them instead:

// Instead of: event(new InventoryUpdated($product)) per product...
// Collect all changes and fire one batch event:

class ProcessInventoryImport implements ShouldQueue
{
    public function handle(): void
    {
        $updates = [];

        foreach ($this->csvRows as $row) {
            $product = Product::find($row['id']);
            $product->update(['stock' => $row['stock']]);
            $updates[] = $product->id;
        }

        // Fire one event with all updated IDs — listeners handle the batch
        event(new InventoryBatchUpdated($updates));
    }
}

class InventoryBatchUpdated
{
    public function __construct(public readonly array $productIds) {}
}

class ReindexProductSearchBatch implements ShouldQueue
{
    public function handle(InventoryBatchUpdated $event): void
    {
        // Reindex all updated products in one Elasticsearch bulk request
        SearchIndex::bulkUpdate(
            Product::whereIn('id', $event->productIds)->get()
        );
    }
}

Conclusion

  • Add ShouldQueue + use InteractsWithQueue to any listener to make it async

  • Listeners support all job properties: $queue, $connection, $tries, $backoff

  • Use shouldQueue() to conditionally skip dispatching — zero overhead for skipped listeners

  • Use listeners to orchestrate job chains — the listener is the workflow entry point

  • Multiple listeners per event = parallel, independent processing — one failure doesn't block others

  • Batch related events to avoid per-record queue overhead on high-volume imports

📑 On This Page