Your User model has six boot() hooks, a registered observer, and two event listeners all firing on created. Nobody on the team knows which one runs first, or why three emails are going out.
That is what happens when you pick whichever hook syntax you saw last - rather than the one that fits the job.
Laravel gives you three distinct mechanisms for reacting to things that happen in your app. They look similar on the surface. They are not the same tool.
What each one actually is
Model Observers
An observer is a plain PHP class that groups Eloquent model lifecycle callbacks: creating, created, updating, updated, deleting, deleted, and a few others.
php artisan make:observer OrderObserver --model=Order
Laravel registers it against a specific model, and every method name maps directly to an Eloquent lifecycle event. The observer only knows about that one model.
Events and Listeners
An event is a data-carrying class. It represents something that happened in your domain - not an Eloquent lifecycle moment, but a business fact.
// The event
class OrderShipped
{
public function __construct(public Order $order) {}
}
// The listener
class SendShipmentNotification
{
public function handle(OrderShipped $event): void
{
// send the email
}
}
One event can have zero, one, or many listeners. Listeners can be queued independently. The event does not care who is listening.
The core difference - and why it matters
Eloquent model events (created, updated, etc.) are low-level hooks on persistence. They fire on every save, from anywhere in the codebase: a seeder, a migration, a background job, a test factory.
Domain events represent intentional business moments. OrderShipped fires when your fulfillment service deliberately ships an order - not whenever the shipped_at column gets a value written to it.
That distinction is the whole decision.

When to use a Model Observer
Use an observer when:
- The behavior is tightly coupled to the model's persistence lifecycle and has no business meaning beyond that.
- You want to keep model-specific hooks grouped in one readable class instead of scattered across
boot()methods. - The side effect is low-stakes and synchronous: slugifying a field, setting a default, logging a record change to an audit table.
Practical examples:
- Auto-generating a
uuidorslugoncreating - Clearing a cache tag on
updated - Writing to an audit log on
deleted
If the observer starts calling services, dispatching jobs, or sending notifications, stop. That logic belongs in a domain event.
When to use Events and Listeners
Use events when:
- Something business-meaningful happened and multiple parts of the system need to react independently.
- You want each reaction to be testable in isolation.
- Some reactions should be queued and some should run synchronously.
- You want to add or remove reactions later without touching the place where the thing happened.
Practical examples:
UserRegistered- send welcome email, provision a free trial, notify Slack, create onboarding tasks. Four listeners, one event, zero coupling.SubscriptionCancelled- email the user, flag the account, trigger a retention workflow.OrderShipped- send a tracking notification, update inventory records, push a webhook.
The moment you find yourself writing if ($this->shouldSendEmail && $this->shouldNotifySlack) inside an observer, you have outgrown the observer.
The rule-of-thumb table
| Situation | Best tool |
|---|---|
| Auto-set a field before save | Observer (creating) |
| Keep model hooks grouped, readable | Observer |
| Reacting to a business action from a service layer | Event + Listener |
| Multiple independent side effects | Event + Listener |
| Side effects that must be queued | Event + Queued Listener |
| Side effect involves a second model or external service | Event + Listener |
| Need to test each reaction separately | Event + Listener |
A mistake worth naming: observers that grow teeth
The most common mess I see in Laravel codebases is an observer that starts small and reasonable, then grows:
// OrderObserver - six months in
public function updated(Order $order): void
{
if ($order->isDirty('status') && $order->status === 'shipped') {
Mail::to($order->user)->send(new ShipmentMail($order));
Notification::send($order->vendor, new VendorNotification($order));
Http::post(config('webhooks.erp'), $order->toArray());
Cache::forget("order_{$order->id}");
}
}
This fires on every Order::save() call across the entire codebase. A seed script, a fix-data artisan command, a test - all of them trigger this block. And you cannot queue parts of it without splitting it up anyway.
The fix: fire OrderShipped from your fulfillment service - the one place in your code where shipping intentionally happens. Let three separate queued listeners handle the email, the vendor notification, and the ERP webhook. The cache clear can stay in the observer because it genuinely is a persistence side effect.

What about model events without observers?
You can listen to Eloquent's built-in model events directly through Event::listen or $dispatchesEvents on the model:
// In Order.php
protected $dispatchesEvents = [
'created' => OrderCreated::class,
];
This bridges the two worlds. Laravel dispatches OrderCreated automatically whenever an Order is saved for the first time. Your listeners take it from there.
This pattern is useful when you want the ergonomics of the event/listener system but need the trigger point to be the Eloquent lifecycle. Use it carefully - you are still tying business logic to persistence, so be deliberate about which events you map.
Testing each approach
Observers are tested by saving a model and asserting the side effect happened. Straightforward, but you cannot test just one reaction in isolation.
Events and listeners are separately testable:
// Test the event fires
Event::fake();
$this->fulfillmentService->ship($order);
Event::assertDispatched(OrderShipped::class);
// Test the listener in isolation
$listener = new SendShipmentNotification();
$listener->handle(new OrderShipped($order));
// assert the mail was queued
Isolation is the clearest argument for the event/listener pattern when the stakes of a reaction are high.
Quick decision flow
- Is this a low-level persistence side effect with no business name? Observer.
- Does this represent something that happened in your domain, with a name your product manager would recognize? Event.
- Do multiple things need to react to it, or do any reactions need to be queued? Event + Listeners.
- Do you need to test each reaction in isolation? Event + Listeners.
- Still unsure? Write the event. You can always remove a listener. You cannot easily split an observer that has grown.

The short version
Observers are for model lifecycle housekeeping. Events are for business moments. The difference is not syntax - it is intent.
When you name something OrderShipped instead of hooking updated, you are documenting what happened in plain English, decoupling the trigger from every reaction, and making the codebase testable piece by piece.
That distinction will save the next developer (or you in six months) from staring at three simultaneous emails and having no idea which hook sent them.
Built something with this pattern that you'd do differently? Drop a comment - one specific tradeoff you hit.
#Laravel #PHP #SoftwareArchitecture #BackendDevelopment #CleanCode
Frequently asked questions
A model observer hooks into Eloquent's persistence lifecycle - it fires whenever a model is saved, updated, or deleted, from anywhere in the codebase. A domain event represents an intentional business moment, fired explicitly from the part of your code where that action deliberately happens. Observers are for persistence housekeeping; events are for business logic.
Yes. A common pattern is to use the observer for low-stakes persistence side effects (clearing a cache, setting a slug) and fire a domain event from your service layer for business actions (shipping an order, cancelling a subscription). The two tools are not mutually exclusive - they solve different problems.
Implement the ShouldQueue interface on your listener class. Laravel will automatically push it onto the queue when the event is dispatched. You can also implement ShouldBeUnique or specify a queue name and connection on the listener class itself.
It is a map on your Eloquent model that tells Laravel to automatically dispatch a specific event class when a lifecycle moment occurs. For example, mapping 'created' to 'OrderCreated::class' means Laravel fires that event every time an Order is first saved. It bridges Eloquent's lifecycle with the event/listener system.
When your observer method starts calling external services, sending notifications, dispatching jobs, or checking multiple conditions to decide what to do - it has outgrown the observer pattern. The clearest signal is needing to queue part of the observer's work. At that point, fire an explicit domain event from your service and let separate listeners handle each reaction.
Enjoyed this article?
Get notified when I publish new posts on SaaS, Laravel, and remote engineering.



