Laravel Events: Stop Calling Services Directly

๐Ÿ“– 3 minutes read

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:

  • CancelOrderService knows 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.

Daryle De Silva

VP of Technology

11+ years building and scaling web applications. Writing about what I learn in the trenches.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *