Laravel Events: Decoupling Your Code

SS Saurav Sitaula

My controllers were doing too much. Create order, send email, update inventory, notify warehouse, log analytics — all in one method. Events and listeners taught me to decouple, and suddenly everything was cleaner.

The Fat Controller Problem

My order controller was getting out of hand:

public function store(Request $request)
{
    $order = Order::create($request->validated());
    
    // Send confirmation email
    Mail::to($order->user)->send(new OrderConfirmation($order));
    
    // Notify admin
    Mail::to(config('admin.email'))->send(new NewOrderAlert($order));
    
    // Update inventory
    foreach ($order->items as $item) {
        $item->product->decrement('stock', $item->quantity);
    }
    
    // Log to analytics
    Analytics::track('order_created', [
        'order_id' => $order->id,
        'total' => $order->total,
    ]);
    
    // Notify warehouse API
    Http::post('https://warehouse.example.com/api/orders', [
        'order_id' => $order->id,
    ]);
    
    // Award loyalty points
    $order->user->increment('loyalty_points', floor($order->total));
    
    return redirect('/orders/' . $order->id);
}

This method does 7 different things. Adding a new feature means editing this already-complex method. Testing is a nightmare. The controller knows about emails, inventory, analytics, external APIs…

It’s a mess.

The Event-Driven Solution

What if the controller just created the order and announced “Hey, an order was created!” — and other parts of the system reacted accordingly?

That’s events and listeners.

Creating an Event

php artisan make:event OrderCreated
<?php

namespace App\Events;

use App\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class OrderCreated
{
    use Dispatchable, SerializesModels;

    public $order;

    public function __construct(Order $order)
    {
        $this->order = $order;
    }
}

An event is just a data container. It holds the information listeners need.

Creating Listeners

php artisan make:listener SendOrderConfirmation --event=OrderCreated
php artisan make:listener NotifyAdmin --event=OrderCreated
php artisan make:listener UpdateInventory --event=OrderCreated
php artisan make:listener TrackOrderAnalytics --event=OrderCreated
php artisan make:listener NotifyWarehouse --event=OrderCreated
php artisan make:listener AwardLoyaltyPoints --event=OrderCreated

Each listener does ONE thing:

// app/Listeners/SendOrderConfirmation.php
<?php

namespace App\Listeners;

use App\Events\OrderCreated;
use App\Mail\OrderConfirmation;
use Illuminate\Support\Facades\Mail;

class SendOrderConfirmation
{
    public function handle(OrderCreated $event)
    {
        Mail::to($event->order->user)->send(
            new OrderConfirmation($event->order)
        );
    }
}
// app/Listeners/UpdateInventory.php
<?php

namespace App\Listeners;

use App\Events\OrderCreated;

class UpdateInventory
{
    public function handle(OrderCreated $event)
    {
        foreach ($event->order->items as $item) {
            $item->product->decrement('stock', $item->quantity);
        }
    }
}
// app/Listeners/TrackOrderAnalytics.php
<?php

namespace App\Listeners;

use App\Events\OrderCreated;
use App\Services\Analytics;

class TrackOrderAnalytics
{
    public function handle(OrderCreated $event)
    {
        Analytics::track('order_created', [
            'order_id' => $event->order->id,
            'total' => $event->order->total,
        ]);
    }
}

Registering Events and Listeners

In app/Providers/EventServiceProvider.php:

protected $listen = [
    \App\Events\OrderCreated::class => [
        \App\Listeners\SendOrderConfirmation::class,
        \App\Listeners\NotifyAdmin::class,
        \App\Listeners\UpdateInventory::class,
        \App\Listeners\TrackOrderAnalytics::class,
        \App\Listeners\NotifyWarehouse::class,
        \App\Listeners\AwardLoyaltyPoints::class,
    ],
];

The Clean Controller

Now the controller is simple:

public function store(Request $request)
{
    $order = Order::create($request->validated());
    
    event(new OrderCreated($order));
    
    return redirect('/orders/' . $order->id);
}

Two lines of actual logic. The controller doesn’t know or care what happens after the event fires.

Why This Is Better

1. Single Responsibility

Each listener does ONE thing. Easy to understand, easy to test, easy to modify.

2. Easy to Add Features

New requirement: “Send SMS for orders over $100.”

php artisan make:listener SendHighValueOrderSms --event=OrderCreated
class SendHighValueOrderSms
{
    public function handle(OrderCreated $event)
    {
        if ($event->order->total >= 100) {
            $event->order->user->notify(new HighValueOrderSms($event->order));
        }
    }
}

Register it. Done. The controller never changes.

3. Easy to Remove Features

Don’t want analytics tracking anymore? Remove the listener from EventServiceProvider. No controller changes.

4. Testable

public function test_order_confirmation_is_sent()
{
    Mail::fake();
    
    $order = factory(Order::class)->create();
    
    $listener = new SendOrderConfirmation();
    $listener->handle(new OrderCreated($order));
    
    Mail::assertSent(OrderConfirmation::class);
}

Test each listener in isolation.

Queued Listeners

Some listeners are slow. Don’t make the user wait.

<?php

namespace App\Listeners;

use Illuminate\Contracts\Queue\ShouldQueue;

class NotifyWarehouse implements ShouldQueue
{
    public function handle(OrderCreated $event)
    {
        // This runs in the background
        Http::post('https://warehouse.example.com/api/orders', [
            'order_id' => $event->order->id,
        ]);
    }
}

Just implement ShouldQueue. The listener goes to the queue automatically.

Event Subscribers

If one class handles multiple events:

<?php

namespace App\Listeners;

class UserEventSubscriber
{
    public function handleUserRegistered($event)
    {
        // Send welcome email
    }

    public function handleUserLoggedIn($event)
    {
        // Log login time
    }

    public function handleUserLoggedOut($event)
    {
        // Clear sessions
    }

    public function subscribe($events)
    {
        $events->listen(
            UserRegistered::class,
            [UserEventSubscriber::class, 'handleUserRegistered']
        );

        $events->listen(
            UserLoggedIn::class,
            [UserEventSubscriber::class, 'handleUserLoggedIn']
        );

        $events->listen(
            UserLoggedOut::class,
            [UserEventSubscriber::class, 'handleUserLoggedOut']
        );
    }
}

Register in EventServiceProvider:

protected $subscribe = [
    \App\Listeners\UserEventSubscriber::class,
];

Model Events (Observers)

Eloquent models fire events automatically:

  • creating / created
  • updating / updated
  • deleting / deleted
  • saving / saved
  • restoring / restored

Inline in the Model

class User extends Model
{
    protected static function booted()
    {
        static::created(function ($user) {
            // Fires after user is created
            Log::info('User created: ' . $user->id);
        });

        static::deleting(function ($user) {
            // Fires before user is deleted
            // Return false to cancel deletion
            if ($user->hasActiveSubscription()) {
                return false;
            }
        });
    }
}

Using Observers (Cleaner)

php artisan make:observer UserObserver --model=User
<?php

namespace App\Observers;

use App\User;

class UserObserver
{
    public function created(User $user)
    {
        // Send welcome email
        $user->notify(new WelcomeNotification());
    }

    public function updated(User $user)
    {
        // Clear cache
        Cache::forget('user_' . $user->id);
    }

    public function deleted(User $user)
    {
        // Clean up related data
        Storage::delete($user->avatar);
    }
}

Register in AppServiceProvider:

public function boot()
{
    User::observe(UserObserver::class);
}

Now these fire automatically whenever a User is created, updated, or deleted. No need to remember to call them.

Broadcasting Events (Real-time)

Want events to reach the browser? Laravel can broadcast to WebSockets:

class OrderStatusUpdated implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public $order;

    public function __construct(Order $order)
    {
        $this->order = $order;
    }

    public function broadcastOn()
    {
        return new PrivateChannel('orders.' . $this->order->user_id);
    }
}

Frontend (with Laravel Echo):

Echo.private(`orders.${userId}`)
    .listen('OrderStatusUpdated', (e) => {
        console.log('Order updated:', e.order);
        // Update UI
    });

Real-time updates without polling!

Practical Example: User Registration Flow

// Events
class UserRegistered { public $user; }
class UserVerified { public $user; }

// Listeners for UserRegistered
class SendWelcomeEmail { }
class CreateDefaultSettings { }
class NotifyAdminOfNewUser { }
class TrackRegistrationAnalytics { }

// Listeners for UserVerified  
class SendVerificationConfirmation { }
class UnlockPremiumTrial { }
// EventServiceProvider
protected $listen = [
    UserRegistered::class => [
        SendWelcomeEmail::class,
        CreateDefaultSettings::class,
        NotifyAdminOfNewUser::class,
        TrackRegistrationAnalytics::class,
    ],
    UserVerified::class => [
        SendVerificationConfirmation::class,
        UnlockPremiumTrial::class,
    ],
];
// Controller - clean and simple
public function register(Request $request)
{
    $user = User::create($request->validated());
    
    event(new UserRegistered($user));
    
    return redirect('/dashboard');
}

public function verify(Request $request, $token)
{
    $user = User::where('verification_token', $token)->firstOrFail();
    $user->markAsVerified();
    
    event(new UserVerified($user));
    
    return redirect('/dashboard')->with('verified', true);
}

Stopping Event Propagation

Sometimes a listener should stop other listeners from running:

class CheckOrderFraud
{
    public function handle(OrderCreated $event)
    {
        if ($this->isFraudulent($event->order)) {
            $event->order->cancel();
            
            // Stop other listeners (don't send confirmation, etc.)
            return false;
        }
    }
}

Make sure fraud check is first in the $listen array!

What I Wish I’d Known Earlier

  1. Events are for things that happened. Name them in past tense: OrderCreated, UserRegistered, PaymentFailed.

  2. Listeners are reactions. They answer “what should happen when X occurs?”

  3. Queue slow listeners. Email, API calls, anything that takes time — implement ShouldQueue.

  4. Observers are for model lifecycle. Use them for model-specific reactions.

  5. Don’t over-event. Not everything needs to be an event. Simple code is fine for simple cases.

The Journey Continues

Events transformed my architecture. Instead of monolithic methods, I had focused listeners. Adding features became easy. Removing them was trivial.

But there was still magic I didn’t understand. How does Mail::send() work? What’s behind Cache::remember()? Time to learn about facades.


P.S. — The moment I realized I could add a new feature by creating one file and adding one line to EventServiceProvider — without touching any existing code — was when I understood the power of event-driven architecture. Decoupling isn’t just academic; it’s practical sanity.

SS

Saurav Sitaula

Software Architect • Nepal

Back to all posts
Saurav.dev

© 2026 Saurav Sitaula.AstroNeoBrutalism