Laravel Events: Decoupling Your Code
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/createdupdating/updateddeleting/deletedsaving/savedrestoring/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
-
Events are for things that happened. Name them in past tense:
OrderCreated,UserRegistered,PaymentFailed. -
Listeners are reactions. They answer “what should happen when X occurs?”
-
Queue slow listeners. Email, API calls, anything that takes time — implement
ShouldQueue. -
Observers are for model lifecycle. Use them for model-specific reactions.
-
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.
Saurav Sitaula
Software Architect • Nepal