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