Events and listeners in Laravel: lessons from production
When an order ships, we don't just update the database—we need to send confirmation emails, update inventory dashboards, notify warehouse systems, and log analytics events. Without a solid pattern, this logic sprawls across your controller or service, creating tight coupling and making tests brittle. Events and listeners in Laravel provide a clean way to decouple side effects from core business logic, and we've learned hard lessons about when and how to use them effectively.
Why events matter in production systems
In real-world applications, a single action often triggers multiple downstream processes. An e-commerce platform placing an order needs to:
- Reserve inventory
- Charge the customer's payment method
- Send a confirmation email
- Update warehouse management systems
- Trigger fraud detection checks
- Log events to analytics
Handling all of this synchronously in your controller or service leads to slow response times, tight coupling, and tests that require mocking ten different dependencies. Events let you dispatch a single OrderPlaced event and let listeners handle the rest independently. Your core logic stays focused on the essential workflow, while side effects are managed elsewhere.
Dispatch events after the main action
The key principle: dispatch events after your core business logic completes successfully. This ensures your main operation (creating the order, recording the payment) isn't blocked by slow or failing side effects.
Start by creating an event:
<?php
namespace App\Events;
use App\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class OrderPlaced
{
use Dispatchable, SerializesModels;
public function __construct(
public Order $order
) {}
}In your service or controller, dispatch it after the order is persisted:
<?php
namespace App\Services;
use App\Events\OrderPlaced;
use App\Models\Order;
class CreateOrderService
{
public function execute(array $data): Order
{
// Core logic: validate and create the order
$order = Order::create([
'user_id' => $data['user_id'],
'total' => $data['total'],
'status' => 'pending',
]);
// Persist to database first
$order->save();
// Then dispatch the event
OrderPlaced::dispatch($order);
return $order;
}
}Notice we don't create any side effect logic here. We handle the primary operation, commit it to the database, and then signal that something important happened. Other parts of the system listen for that signal.
Listeners: handling side effects decoupled
Listeners respond to events. Generate them with Artisan:
php artisan make:listener SendOrderConfirmationEmail --event=OrderPlacedEach listener is responsible for one concern:
<?php
namespace App\Listeners;
use App\Events\OrderPlaced;
use App\Mail\OrderConfirmation;
use Illuminate\Contracts\Mail\Mailer;
class SendOrderConfirmationEmail
{
public function __construct(private Mailer $mailer) {}
public function handle(OrderPlaced $event): void
{
$this->mailer->send(
new OrderConfirmation($event->order)
);
}
}Register it in EventServiceProvider:
<?php
namespace App\Providers;
use App\Events\OrderPlaced;
use App\Listeners\SendOrderConfirmationEmail;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
class EventServiceProvider extends ServiceProvider
{
protected $listen = [
OrderPlaced::class => [
SendOrderConfirmationEmail::class,
// Add more listeners here
],
];
}Now you can add more listeners without touching your service:
<?php
namespace App\Listeners;
use App\Events\OrderPlaced;
class UpdateInventory
{
public function handle(OrderPlaced $event): void
{
foreach ($event->order->items as $item) {
$item->product->decrement('stock_quantity', $item->quantity);
}
}
}<?php
namespace App\Listeners;
use App\Events\OrderPlaced;
use Illuminate\Support\Facades\Slack;
class NotifySlack
{
public function handle(OrderPlaced $event): void
{
Slack::send('New order: ' . $event->order->id . ' for $' . $event->order->total);
}
}Each listener is independently testable, independently deployable, and independent in its concerns. If the Slack notification fails, it doesn't block email delivery or inventory updates.
Queued listeners for slow operations
Not all side effects are quick. Sending emails, calling external APIs, or updating data warehouses might take seconds or minutes. Use queued listeners to defer these to a background queue:
<?php
namespace App\Listeners;
use App\Events\OrderPlaced;
use App\Mail\OrderConfirmation;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
class SendOrderConfirmationEmail implements ShouldQueue
{
use InteractsWithQueue;
public function __construct(private Mailer $mailer) {}
public function handle(OrderPlaced $event): void
{
$this->mailer->send(
new OrderConfirmation($event->order)
);
}
}By implementing ShouldQueue, Laravel automatically queues the listener. The user gets an instant response while the email sends in the background via your queue driver (Redis, SQS, etc.).
You can also configure which queue and delay:
<?php
namespace App\Listeners;
use App\Events\OrderPlaced;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
class SendOrderConfirmationEmail implements ShouldQueue
{
use InteractsWithQueue;
public $queue = 'emails';
public $delay = 60; // Delay by 60 seconds
// ...
}In production, we typically queue slower operations (emails, external API calls, analytics batching) while keeping fast, critical operations synchronous (inventory updates, payment logging). This balances response time with reliability.
Keep core logic explicit
A common mistake in growing applications is letting events become the primary flow control mechanism. Events are for side effects, not for the main workflow.
Don't do this:
<?php
// Anti-pattern: using events for core workflow
$order = new Order();
OrderCreated::dispatch($order); // Listener does validation
OrderCreated::dispatch($order); // Another listener charges payment
OrderCreated::dispatch($order); // Another listener confirms inventory
// Now you don't know what your business logic actually does
Instead, keep the primary flow explicit in your service:
<?php
namespace App\Services;
use App\Events\OrderPlaced;
use App\Models\Order;
class CreateOrderService
{
public function execute(array $data): Order
{
// Step 1: Validate
$this->validate($data);
// Step 2: Reserve inventory
$this->inventoryService->reserve($data['items']);
// Step 3: Charge payment
$this->paymentService->charge($data['payment_method'], $data['amount']);
// Step 4: Create and persist order
$order = Order::create($data);
// Step 5: Trigger side effects
OrderPlaced::dispatch($order);
return $order;
}
}The reader of this code understands exactly what happens. The event is the signal, not the mechanism. Side effects (like sending notifications or logging analytics) are decoupled, but your core business logic is readable and testable as a unit.
Common mistakes
Forgetting to persist before dispatching: If you dispatch an event and the listener queries the order, it needs to exist in the database. Always save first, dispatch second.
Ignoring listener failures: A queued listener that fails will retry by default. If it keeps failing, it eventually goes to the dead-letter queue. Monitor your queue and set up alerts. In production, failing emails or notifications are not acceptable.
Event payload too large: If you pass large objects through events, they get serialized into your queue. Use IDs instead: public Order $order becomes public int $orderId, and the listener re-fetches the order. This keeps queue messages small and lets you fetch fresh data.
Over-relying on events for testing: Test your services directly without invoking listeners. Use Event::fake() to verify events are dispatched, then test listeners independently.
<?php
use App\Events\OrderPlaced;
use Illuminate\Support\Facades\Event;
Event::fake();
$service = new CreateOrderService();
$order = $service->execute($data);
Event::assertDispatched(OrderPlaced::class, function ($event) use ($order) {
return $event->order->id === $order->id;
});Key takeaways
- Dispatch events after core business logic succeeds and is persisted to the database.
- Use listeners to decouple side effects: emails, notifications, external API calls, analytics logging.
- Implement
ShouldQueuefor slow listeners to keep responses fast and use background queues. - Keep your primary workflow explicit in services. Events are signals, not control flow.
- Each listener should handle one concern, making them independently testable and deployable.
- Monitor queued listeners in production; failing background jobs need alerting and dead-letter handling.
- Use event IDs rather than full objects to keep queue payloads small and data fresh.






Comments
Loading comments…