<?php
namespace App\Providers;
use App\Events\OrderPlaced;
use App\Listeners\DecrementInventory;
use App\Listeners\RecordOrderAnalytics;
use App\Listeners\SendOrderConfirmation;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
class EventServiceProvider extends ServiceProvider
{
protected $listen = [
OrderPlaced::class => [
SendOrderConfirmation::class,
DecrementInventory::class,
RecordOrderAnalytics::class,
],
];
public function shouldDiscoverEvents(): bool
{
return false;
}
}
<?php
namespace App\Events;
use App\Models\Order;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class OrderPlaced
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public Order $order;
public function __construct(Order $order)
{
$this->order = $order;
}
}
<?php
namespace App\Listeners;
use App\Events\OrderPlaced;
use App\Mail\OrderConfirmationMail;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Mail;
use Throwable;
class SendOrderConfirmation implements ShouldQueue
{
public string $queue = 'mail';
public int $tries = 5;
public array $backoff = [10, 30, 60];
public function viaConnection(): string
{
return 'redis';
}
public function handle(OrderPlaced $event): void
{
$order = $event->order;
$lockKey = "order_confirmation_sent:{$order->id}";
// Guard against at-least-once double delivery on retry.
if (! Cache::add($lockKey, true, now()->addDay())) {
return;
}
Mail::to($order->customer_email)
->send(new OrderConfirmationMail($order));
}
public function failed(OrderPlaced $event, Throwable $e): void
{
Cache::forget("order_confirmation_sent:{$event->order->id}");
logger()->error('Order confirmation failed', [
'order_id' => $event->order->id,
'error' => $e->getMessage(),
]);
}
}
<?php
namespace App\Http\Controllers;
use App\Events\OrderPlaced;
use App\Http\Requests\PlaceOrderRequest;
use App\Models\Order;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
class OrdersController extends Controller
{
public function store(PlaceOrderRequest $request): JsonResponse
{
$order = DB::transaction(function () use ($request) {
$order = Order::create($request->validated());
$order->items()->createMany($request->input('items'));
return $order;
});
// Dispatch only after the commit so listeners never see a rolled-back order.
OrderPlaced::dispatch($order->fresh('items'));
return response()->json([
'id' => $order->id,
'status' => $order->status,
], 201);
}
}
This snippet shows how a single domain event on order placement can fan out to several independent, asynchronously processed listeners in Laravel. The pattern decouples the write path (persisting the order) from the side effects (email, inventory, analytics) so the HTTP request stays fast and each side effect can fail and retry in isolation.
In OrderPlaced event, the event is a plain value object carrying the Order model. It uses Dispatchable, SerializesModels, and InteractsWithSockets, and exposes a read-only $order on construction. SerializesModels matters because queued listeners are serialized to the queue backend; rather than shipping the whole model, only its identifier is stored and the model is re-resolved from the database when the job runs, which keeps payloads small and avoids stale state.
EventServiceProvider wires the fan-out declaratively via the $listen map: one event key, an array of listener classes. Registering multiple listeners under OrderPlaced::class is what makes this a fan-out — the framework invokes each one, and because they implement ShouldQueue, each is pushed as its own queue job with independent retries. SendOrderConfirmation listener demonstrates the queued listener contract: implementing ShouldQueue moves work off the request thread, $queue = 'mail' and $tries/$backoff tune where and how aggressively it retries, and viaConnection can route to a specific broker.
The listener's handle method is written to be idempotent using Cache::add keyed by order id, because at-least-once delivery means a job can legitimately run twice after a transient failure and a re-dispatch. Without this guard, a retry could send a duplicate confirmation email. failed receives the throwable after the final attempt is exhausted, giving a place to alert or record a dead-letter record.
OrdersController triggers the whole flow: it persists the order inside a DB::transaction, then calls OrderPlaced::dispatch only after the commit closure returns, so listeners never observe an order that was rolled back. The trade-off of this approach is eventual consistency — side effects complete slightly after the response — in exchange for a responsive endpoint and resilient, individually retryable side effects. A developer reaches for this when one user action must trigger several unrelated jobs that should not block each other.
Related snips
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
<?php
namespace App\Providers;
use App\Contracts\PaymentGateway;
use App\Services\StripePaymentGateway;
Laravel service container and dependency injection
module EmailNormalization
extend ActiveSupport::Concern
included do
attr_accessor :soft_warnings
Soft Validation: Normalize + Validate Email
{
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build"
},
Laravel mix/Vite for asset compilation
Share this code
Here's the card — post it anywhere.