Observers and Domain Events — Decoupling Your Application
Series: Every Laravel Project Should Have These Building Blocks
Part: 12 of 35 | Level: Intermediate | Prerequisites: Model Traits, Action Classes
What You’ll Learn
- The difference between Observers and Events/Listeners
- When to use each
- How to write clean, focused Observers
- How to fire and listen to domain events
- How to queue event listeners for background processing
Observers vs. Events — The Right Tool for the Right Job
Both Observers and Events let you react to things that happen in your application. The difference is where and when they’re most useful:
| Observers | Events / Listeners | |
|---|---|---|
| Triggered by | Eloquent model lifecycle (created, updated, deleted) | Explicitly fired from code |
| Coupling | Low — model doesn’t know about side effects | Low — the event is the only contract |
| Use for | Auto-setting fields, cache invalidation, simple notifications | Complex workflows, queued processing, decoupled reactions |
| Example | Set slug on creating, clear cache on updated | OrderPlaced → send email, update inventory, notify Slack |
Rule of thumb: Observers react to model changes. Events communicate that something business-significant happened.
Observers
Registering Observers
Register observers in AppServiceProvider::boot():
// AppServiceProvider.php
public function boot(): void
{
Order::observe(OrderObserver::class);
User::observe(UserObserver::class);
Media::observe(MediaObserver::class);
WarrantyClaim::observe(WarrantyClaimObserver::class);
}
Or on the model directly:
// config/app.php or the Model::observe() inline is fine
A Clean Observer
// app/Observers/OrderObserver.php
<?php
declare(strict_types=1);
namespace App\Observers;
use App\Models\Order;
use Illuminate\Support\Facades\Cache;
class OrderObserver
{
public function created(Order $order): void
{
// Clear dashboard cache when new order arrives
Cache::forget('admin.dashboard.orders');
Cache::forget('admin.dashboard.orders.monthly.' . now()->year);
}
public function updated(Order $order): void
{
if ($order->wasChanged('status')) {
// Clear relevant caches
Cache::forget('admin.dashboard.orders');
}
}
public function deleted(Order $order): void
{
// Clean up associated media files
$order->media()->each(function ($media): void {
Storage::delete($media->path);
});
}
}
Notice what this Observer does NOT do:
- No business logic
- No sending emails (that belongs in an Event/Listener)
- No complex computations
Observers should be fast and focused. Cache invalidation, setting default values, cleanup — these are observer concerns.
The EquipmentObserver Pattern — Generating References
From huntvedealer, an observer that auto-generates unique reference numbers:
// app/Observers/EquipmentObserver.php
class EquipmentObserver
{
public function creating(Equipment $equipment): void
{
if (empty($equipment->reference_number)) {
$equipment->reference_number = $this->generateReference();
}
}
private function generateReference(): string
{
do {
$ref = 'EQ-' . strtoupper(Str::random(8));
} while (Equipment::where('reference_number', $ref)->exists());
return $ref;
}
}
The model doesn’t know this is happening. Any code that creates equipment automatically gets a reference number.
The MediaObserver Pattern — Cleanup on Delete
class MediaObserver
{
public function deleted(Media $media): void
{
if (Storage::exists($media->path)) {
Storage::delete($media->path);
}
// Delete thumbnails
$media->thumbnails()->each(function (MediaThumbnail $thumbnail): void {
Storage::delete($thumbnail->path);
$thumbnail->delete();
});
}
}
Domain Events
Domain events communicate that something business-significant happened. They decouple the code that causes an event from the code that reacts to it.
Defining an Event
// app/Events/OrderPlaced.php
<?php
declare(strict_types=1);
namespace App\Events;
use App\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class OrderPlaced
{
use Dispatchable;
use SerializesModels;
public function __construct(
public readonly Order $order
) {}
}
Firing an Event
Fire events after a transaction commits, from an Action:
final class CreateOrderAction
{
public function execute(User $user, OrderCustomerData $data): Order
{
$order = DB::transaction(function () use ($user, $data): Order {
// ... create order, attach items
return $order;
});
// AFTER the transaction — data is committed
event(new OrderPlaced($order));
return $order;
}
}
Defining a Listener
// app/Listeners/SendOrderConfirmation.php
<?php
declare(strict_types=1);
namespace App\Listeners;
use App\Events\OrderPlaced;
use App\Mail\OrderConfirmationMail;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Mail;
class SendOrderConfirmation implements ShouldQueue
{
public string $queue = 'notifications';
public function handle(OrderPlaced $event): void
{
Mail::to($event->order->email)
->send(new OrderConfirmationMail($event->order));
}
/**
* Handle a failed listener job.
*/
public function failed(OrderPlaced $event, \Throwable $exception): void
{
logger()->error('Failed to send order confirmation', [
'order_id' => $event->order->id,
'error' => $exception->getMessage(),
]);
}
}
Implementing ShouldQueue means the listener runs in the background via the queue — the HTTP response doesn’t wait for the email to send.
Registering Listeners
In EventServiceProvider (Laravel 10 and below):
// app/Providers/EventServiceProvider.php
protected $listen = [
OrderPlaced::class => [
SendOrderConfirmation::class,
UpdateInventoryForOrder::class,
NotifyAdminOfNewOrder::class,
],
ServiceRequestRejected::class => [
SendRejectionNotifications::class,
],
EnrolmentPaid::class => [
GenerateInvoice::class,
SendEnrolmentConfirmation::class,
UpdatePartnerCommission::class,
],
];
In Laravel 11+ (bootstrap/app.php):
->withEvents(discover: [
__DIR__.'/../app/Listeners',
])
Or use #[ListensTo] attribute on the listener class (Laravel 11+):
#[ListensTo(OrderPlaced::class)]
class SendOrderConfirmation implements ShouldQueue
{
public function handle(OrderPlaced $event): void { ... }
}
Mail Event Logging
One recurring pattern across the projects: log every mail send attempt for auditability.
// app/Listeners/LogMailSending.php
class LogMailSending
{
public function handle(MessageSending $event): void
{
Log::channel('mail')->info('Sending mail', [
'to' => $event->message->getTo(),
'subject' => $event->message->getSubject(),
]);
}
}
// app/Listeners/LogMailSent.php
class LogMailSent
{
public function handle(MessageSent $event): void
{
Log::channel('mail')->info('Mail sent successfully', [
'to' => $event->message->getTo(),
'subject' => $event->message->getSubject(),
]);
}
}
Wire these to Laravel’s built-in mail events in EventServiceProvider:
\Illuminate\Mail\Events\MessageSending::class => [LogMailSending::class],
\Illuminate\Mail\Events\MessageSent::class => [LogMailSent::class],
Now every email is recorded in logs/mail.log — useful for debugging and for compliance.
Key Takeaways
- Observers react to model lifecycle events (created, updated, deleted). Use them for auto-setting fields, cache invalidation, and cleanup.
- Domain Events communicate that something business-significant happened. Use them for complex, potentially asynchronous reactions.
- Always fire domain events after a transaction commits — not inside it.
- Implement
ShouldQueueon listeners for anything that sends emails, calls external APIs, or takes more than a few milliseconds. - Log mail sends with Laravel’s built-in
MessageSending/MessageSentevents — it’s a free audit trail. - Register observers in
AppServiceProvider::boot()for a single, scannable list.
Frequently Asked Questions
When should I use an Observer instead of an Event? Use an Observer for model lifecycle hooks — audit trails, cascade deletes, sending a welcome email when a user registers. Use an Event + Listener for business domain events (OrderPlaced, PaymentFailed) that multiple parts of the system need to react to independently. Rule of thumb: if the action is triggered by model state change, Observer; if the action is something that happened in the business, Event.
Can Observers dispatch domain Events? Yes, and this is often the cleanest combination. The Observer’s created() hook fires event(new UserCreated($user)), then independent Listeners handle sending the welcome email, notifying Slack, and creating the billing record. The Observer connects the model lifecycle to the event bus; Listeners implement the consequences.
Are Observers queued automatically? No. Observers run synchronously during the request. If an Observer method is slow (hitting an external API), extract the slow work into a queued Listener triggered by an event the Observer fires, rather than blocking the request.
Tips and Gotchas
⚠️ Warning: Observers run synchronously inside the current request. Heavy work in
updated()(like sending emails, calling APIs, resizing images) will slow down every save. Move anything slow to a queued listener on an Eloquent event instead.
💡 Tip: Observers and events both respond to model lifecycle changes — the difference is audience. Observers are for infrastructure side effects (cache busting, audit logs, cleanup). Events are for business domain reactions (sending confirmation, triggering workflows). Use both; don’t conflate them.
🔥 Expert Note:
ShouldDispatchAfterResponseon a listener fires it after the HTTP response is sent to the browser — the user isn’t waiting for it. Use this for non-critical notifications and logging that shouldn’t block the response.
Further Reading
- Laravel Docs: Eloquent Observers
- Laravel Docs: Events and Listeners
- Laravel Docs: Queued Event Listeners
- Laravel Docs:
ShouldDispatchAfterResponse