Table of Contents
You’re building a workflow. When an order is cancelled, you need to process a refund. The straightforward approach: call the refund service directly.
But that creates coupling you’ll regret later.
The Tight-Coupling Trap
Here’s what most developers write first:
class CancelOrderService
{
public function __construct(
private RefundService $refundService
) {}
public function cancel(Order $order): void
{
$order->update(['status' => 'cancelled']);
// Directly call refund logic
if ($order->payment_status === 'paid') {
$this->refundService->process($order);
}
}
}
This works. But now:
CancelOrderServiceknows about refunds- Adding more post-cancellation logic (notifications, inventory updates, analytics) means editing this class every time
- Testing cancellation requires mocking refund logic
You’ve tightly coupled two separate concerns.
Event-Driven Decoupling
Instead, dispatch an event and let listeners handle side effects:
class CancelOrderService
{
public function cancel(Order $order): void
{
$order->update(['status' => 'cancelled']);
// Broadcast what happened, don't dictate what happens next
event(new OrderCancelled($order));
}
}
Now the cancellation service doesn’t know or care what happens after. It announces the cancellation and moves on.
Listeners Do the Work
Register listeners to handle post-cancellation tasks:
// app/Providers/EventServiceProvider.php
protected $listen = [
OrderCancelled::class => [
ProcessAutomaticRefund::class,
NotifyCustomer::class,
UpdateInventory::class,
LogAnalytics::class,
],
];
Each listener is independent:
class ProcessAutomaticRefund
{
public function __construct(
private RefundService $refundService
) {}
public function handle(OrderCancelled $event): void
{
$order = $event->order;
if ($order->payment_status === 'paid') {
$this->refundService->process($order);
}
}
}
Now you can:
- Add listeners without touching the cancellation logic
- Remove listeners when features are deprecated
- Test independently โ cancel logic doesn’t know refund logic exists
- Queue listeners individually for better performance
When to Use Events
Use events when:
- Multiple things need to happen: One action triggers several side effects
- Logic might expand: You anticipate adding more post-action tasks
- Cross-domain concerns: Orders triggering inventory, notifications, analytics
- Async processing: Some tasks can be queued, others need to run immediately
Skip events when:
- Single responsibility: Only one thing ever happens
- Tight integration required: Caller needs return values or transaction control
- Simple workflows: Over-engineering a two-step process
Bonus: Queue Listeners Selectively
Make slow listeners async:
class NotifyCustomer implements ShouldQueue
{
public function handle(OrderCancelled $event): void
{
// Runs in background, doesn't slow down cancellation
Mail::to($event->order->customer)->send(new OrderCancelledEmail());
}
}
Now critical listeners (refund) run immediately, while optional ones (email) queue in the background.
The Takeaway
Stop calling side effects directly. Dispatch events instead. Your code becomes more modular, testable, and flexible. When requirements change (they always do), you add a listener instead of refactoring core logic.
Events aren’t overkill โ they’re how you build systems that scale without breaking.

Leave a Reply