Eloquent events: why your observer didn't fire (or fired twice)
Model events and observers are the standard way to react to Eloquent changes — log an audit trail, send a notification, keep a denormalized total in sync. They work exactly as documented for the common case: one model, one save(), one event fired. The surprises show up at the edges — bulk operations that never touch individual models at all, and nested saves that fire the same event more times than you expected.
ON THIS PAGE
The event lifecycle, briefly
Every save fires a predictable sequence: retrieved, then either creating/created or updating/updated, wrapped by saving/saved. Deletes fire deleting/deleted (and restoring/restored for soft deletes). You can hook into these two ways — closures on the model itself, or a dedicated Observer class:
class Order extends Model
{
protected static function booted(): void
{
static::created(function (Order $order) {
Log::info("Order {$order->id} created");
});
}
}
class OrderObserver
{
public function created(Order $order): void
{
Log::info("Order {$order->id} created");
}
}
Both approaches hook into the exact same underlying event system — Observers are just a cleaner way to organize the same hooks when you have several. Register one in a service provider with Order::observe(OrderObserver::class).
The events that never fire: bulk operations
This is the gotcha that catches people most often. Model events only fire when Eloquent actually hydrates and saves individual model instances. A query builder call that operates directly on the table skips all of it:
Order::where('status', 'pending')->update(['status' => 'expired']);
// no `updating`, `updated`, or `saving` events fire — not for a single row.
// This compiles to one UPDATE statement against the table directly.
If an observer is responsible for something that has to happen on every status change — invalidating a cache, sending a notification, updating a related total — a bulk update like this will silently skip it, and nothing will error to tell you so. The fix isn't a trick, it's just awareness: if the side effect matters, either loop and save individual models (slower, but fires events), or make the side effect explicit at the call site rather than relying on an observer to catch it implicitly:
$orders = Order::where('status', 'pending')->get();
foreach ($orders as $order) {
$order->update(['status' => 'expired']); // this one does fire events
}
The same applies to delete() called on a query builder result (Order::where(...)->delete()) versus calling ->delete() on an individual model instance — only the latter fires deleting/deleted.
Firing more than once: nested saves inside event hooks
The opposite problem: an event handler that itself calls save() on the same model, which re-triggers the event chain:
protected static function booted(): void
{
static::saving(function (Order $order) {
$order->total = $order->calculateTotal();
$order->save(); // triggers saving again — recursion risk
});
}
Since you're already inside saving, just mutating the attribute is enough — Eloquent will persist it as part of the save already in progress:
protected static function booted(): void
{
static::saving(function (Order $order) {
$order->total = $order->calculateTotal(); // no save() call needed here
});
}
A more common version of the same issue: a saved hook on Order that updates a related Customer model's total, and a saved hook on Customer that in turn touches something on Order — two models updating each other in their save events can produce a save loop or, at minimum, more writes than you intended per user action.
Silencing events on purpose
Sometimes you genuinely want to update a model without triggering its usual side effects — a data migration script, a background reconciliation job. saveQuietly() and updateQuietly() do exactly this:
$order->status = 'archived';
$order->saveQuietly(); // no events fired
For a broader block of code, withoutEvents() suppresses everything inside the closure:
Order::withoutEvents(function () {
// any saves/deletes in here skip all model events
$order->update(['status' => 'archived']);
});
Use these deliberately and sparingly — silencing events is also how the "why didn't my observer run" bug gets introduced in the first place, just from the opposite direction.
Checklist before relying on an observer for something important
- Could this model ever be updated or deleted via a query builder call (
Model::where(...)->update()) rather than an individual instance? If so, the observer won't see it. - Does any event handler call
save()on the same model it's reacting to, when just mutating the attribute would be enough? - If two models update each other inside their save events, is there a clear reason that can't loop, or a guard against it?
- Is anything in the codebase using
saveQuietly()/withoutEvents()on a model that an observer elsewhere assumes always fires?