The audit log had holes in it. Not many — a few thousand rows out of several million — but enough that a compliance question couldn't be answered. Every one of the missing entries traced back to a data-fix command someone had run eight months earlier: a single Order::whereIn('id', $ids)->update(['status' => 'settled']) inside an Artisan command.
The OrderObserver that wrote the audit rows never ran. Nothing errored. Nothing logged. The code path simply didn't involve a model.
The rule: models fire events, the query builder does not#
Eloquent events are dispatched from inside Model::save() and Model::delete(). If you look at the framework source, save() opens with $this->fireModelEvent('saving') and performUpdate() opens with $this->fireModelEvent('updating'). They're instance methods on a hydrated model.
Order::where(...)->update([...]) is a different object entirely. In Illuminate\Database\Eloquent\Builder:
public function update(array $values)
{
return $this->toBase()->update($this->addUpdatedAtColumn($values));
}
It adds updated_at and hands the array straight to the query builder. One SQL statement, zero models hydrated, zero events. The Laravel docs state it plainly — "the saving, saved, updating, and updated model events will not be fired... because the models are never actually retrieved" — in a callout that is very easy to read past.
This is not a bug and it is not going to change. Firing per-row events on a bulk update would mean selecting a thousand rows, hydrating a thousand models and issuing a thousand UPDATE statements. That's the slow path you chose ->update() to avoid.
What makes it expensive is that the failure is silent and asymmetric. The "edit" button in your admin panel fires the observer. The "mark selected as settled" bulk action sitting six pixels to its right does not. Both look correct in review. The data diverges a few rows at a time, and by the time a report surfaces it you can no longer tell which rows are stale.
The full inventory#
Everything below is verified against the current framework source. "Fires" means the model's observer methods run.
Dispatches events
| Operation | Events, in order |
|---|---|
$model->save() (new) |
saving → creating → created → saved |
$model->save() (dirty) |
saving → updating → updated → saved |
$model->save() (clean) |
saving → saved only |
Model::create([...]) |
as save() (new) |
$model->update([...]) |
as save() (dirty) |
updateOrCreate(), firstOrCreate() |
as the branch it takes |
$model->touch() |
as save() (dirty) — it sets updated_at then calls save() |
$model->increment() / decrement() |
updating → updated only |
$model->delete() |
deleting → deleted |
$model->delete() (soft) |
deleting → trashed → deleted |
$model->forceDelete() |
forceDeleting → deleting → deleted → forceDeleted |
$model->restore() |
restoring → saving → updating → updated → saved → restored |
Model::destroy($ids) |
full delete cycle per model |
$model->push() |
full save cycle on the model and every loaded relation |
$model->replicate() |
replicating on the new instance (nothing is saved yet) |
Does not dispatch events
| Operation | Why |
|---|---|
Model::where(...)->update([...]) |
query builder |
Model::where(...)->delete() |
query builder (soft deletes included) |
Model::where(...)->restore() |
query builder |
Model::where(...)->increment() |
query builder |
Model::truncate() |
raw TRUNCATE |
Model::insert([...]) |
query builder |
Model::upsert($rows, $by) |
one INSERT ... ON CONFLICT |
attach() / detach() / sync() / toggle() |
pivot written by the query builder |
updateExistingPivot() |
query builder |
$touches parent touching |
Relation::touch() → rawUpdate() |
DB::table(...) anything |
never involved Eloquent |
any *Quietly() method |
by design |
Model::destroy() is the odd one out and worth knowing: it loads the models first specifically so events fire. The source comment says exactly that. It's destroy() that's safe and ->delete() on a builder that isn't — which is the opposite of what the names suggest.
Two traps inside the "fires" column
increment() is the one that bites. On a model instance it fires updating and updated — but never saving or saved. An observer hooked only to saved to invalidate a cache will miss every view-counter bump:
// Fires updating + updated. Does NOT fire saving or saved.
$post->increment('view_count');
// Fires the full saving → updating → updated → saved cycle.
$post->view_count++;
$post->save();
The inverse is also true. save() only calls performUpdate() if the model is dirty, so saving an unchanged model fires saving and saved and nothing in between. If your saved handler assumes something actually changed, guard it with $model->wasChanged().
Relationship writes and pivots#
Many-to-many writes are the trap people hit second, usually in an admin panel. Here's attach():
$this->newPivotStatement()->insert($this->formatAttachRecords(
$this->parseIds($ids), $attributes
));
A raw query-builder insert. No pivot model, no related model, no events on either side. sync() and detach() work the same way, and sync() additionally fires nothing on the related models it attaches or removes.
Worse, the parent isn't covered either. attach() calls touchIfTouching(), which goes to Relation::touch(), which calls rawUpdate() — another builder update. So a $touches declaration bumps updated_at on the parent without firing the parent's updated event. If you cache-bust on that event, your cache is now stale and the timestamp says otherwise.
The one exception: a pivot declared with ->using(MembershipPivot::class) makes attach() instantiate and save() each pivot record, so the pivot model's events fire. The related model's still don't.
// RoleUser is a Pivot subclass — its observer runs on attach.
public function roles(): BelongsToMany
{
return $this->belongsToMany(Role::class)->using(RoleUser::class);
}
This is exactly the seam where a Filament relation manager quietly skips your observer — if you're wiring one up, the patterns in building a Filament relation manager for many-to-many pivots are worth reading alongside this.
Which events can cancel a write#
Returning false from any -ing handler aborts the operation. Returning anything from a -ed handler does nothing at all — the write has already happened.
public function updating(Order $order): bool
{
// Abort the write entirely. save() returns false; nothing hits the database.
if ($order->isDirty('total') && $order->locked_at !== null) {
return false;
}
return true;
}
Note the subtlety: save() returning false is indistinguishable from a validation failure to the caller, and most controllers don't check it. If you cancel a write this way, throw from the handler instead, or make sure every call site inspects the return value.
The full observable set is fixed: retrieved, creating, created, updating, updated, saving, saved, restoring, restored, replicating, trashed, deleting, deleted, forceDeleting, forceDeleted. booting and booted are boot hooks, not events — you can't observe them.
Observers inside transactions#
An observer that dispatches a job is the most common production failure here, and it has nothing to do with bulk writes:
public function created(Order $order): void
{
// If the surrounding transaction rolls back, this job still runs —
// against an order ID that no longer exists.
SendOrderConfirmation::dispatch($order);
}
created fires inside save(), which is inside your transaction. The queue driver doesn't care. The worker picks the job up, looks for the order, and either fails or — worse — finds a half-written one. Fix it at the dispatch:
SendOrderConfirmation::dispatch($order)->afterCommit();
The reasoning and the config-level default are covered properly in dispatching jobs after database transactions commit. The same caution applies to anything irreversible in an observer: an outbound webhook, a Stripe call, an email. If it can't be undone by a rollback, it doesn't belong in a -ed handler without afterCommit().
Turning events off on purpose#
Three escape hatches, three very different blast radii. Reach for the narrowest.
// 1. One write, one model. Almost always the right answer.
$order->saveQuietly();
$order->deleteQuietly();
$order->restoreQuietly();
$order->forceDeleteQuietly();
// 2. A closure — but this is GLOBAL, not per-model.
Order::withoutEvents(function () {
$order->save(); // silent, as intended
$customer->save(); // also silent. Probably not intended.
});
// 3. A whole process — seeding, a one-off import.
Order::unsetEventDispatcher();
Option 2 is the one that surprises people. withoutEvents() swaps the static event dispatcher that every Eloquent model shares, then restores it in a finally. Calling it on Order silences Customer, Invoice and everything else written inside that closure. The method name reads like it's scoped to the class it's called on. It isn't.
Bulk writes that still keep the invariant#
Three honest options when you need the side effect and the volume.
Chunk and save each model. Correct, and slow — you're paying one UPDATE per row plus the observer's work. Fine for thousands, not for millions. Use lazyById() so you don't hold the result set in memory:
Order::query()
->where('status', 'pending')
->where('created_at', '<', now()->subDays(30))
->lazyById(500)
->each(function (Order $order) {
$order->status = 'expired';
$order->save(); // observer runs
});
lazyById() beats chunk() here because you're mutating the column you'd otherwise be paginating against — the same keyset-pagination argument made in processing large CSV files with lazy collections.
Bulk write, then a second bulk statement for the derived value. Fastest and perfectly safe when the side effect is itself expressible as SQL:
DB::transaction(function () use ($ids) {
Order::whereIn('id', $ids)->update(['status' => 'settled']);
// The thing the observer would have done, done in bulk.
AuditEntry::insert(collect($ids)->map(fn ($id) => [
'order_id' => $id,
'action' => 'settled',
'created_at' => now(),
])->all());
});
The cost is duplication: the rule now lives in two places and can drift. Only do this where the two statements sit in the same method, so a reviewer sees both.
Move the invariant into the database. A generated column, a CHECK constraint or a trigger. Unfashionable, and the only option that holds regardless of which code path wrote the row — including a DBA with a psql prompt. If the rule is genuinely an invariant rather than a convenience, this is where it belongs.
The rule I use: if the side effect is derivable from the row, put it in the database. If it reaches outside the database, put it in one service method that every path calls. Observers are for the convenient middle — nice-to-have denormalisation where a missed row is annoying rather than wrong.
final class OrderSettlementService
{
public function settle(Collection $orders): void
{
DB::transaction(function () use ($orders) {
Order::whereIn('id', $orders->modelKeys())->update(['status' => 'settled']);
$this->writeAuditEntries($orders);
});
}
}
Both the admin panel's single action and the bulk action call settle(). There is now one path, so there's nothing to diverge.
Catching the next one in CI#
The durable fix is making the builder refuse. Override newEloquentBuilder() on observed models with one that throws unless you opt in:
namespace App\Eloquent;
use Illuminate\Database\Eloquent\Builder;
use RuntimeException;
class GuardedBuilder extends Builder
{
protected bool $bulkAllowed = false;
/** Opt in explicitly: Order::query()->allowBulkWrites()->update([...]) */
public function allowBulkWrites(): static
{
$this->bulkAllowed = true;
return $this;
}
public function update(array $values)
{
if (! $this->bulkAllowed) {
throw new RuntimeException(sprintf(
'%s has an observer. Use allowBulkWrites() if you have handled the side effects.',
$this->getModel()::class,
));
}
return parent::update($values);
}
}
// On the model
protected function newEloquentBuilder($query): GuardedBuilder
{
return new GuardedBuilder($query);
}
A developer who needs the bulk write gets a one-word diff and a moment's thought. A developer who didn't know gets an exception in the test suite rather than a data bug in eighteen months.
If you'd rather catch it statically, the same rule expresses well as a custom PHPStan rule flagging update() calls on a builder whose model has an observer — see writing a custom PHPStan rule to enforce Laravel conventions. And a cheap architecture test is worth adding regardless, to assert that every model with an observer also declares the guarded builder; Pest architecture testing covers the syntax.
Testing observers without fooling yourself#
Event::fake() is a trap here, and the reason is in the facade:
Model::setEventDispatcher($fake);
Faking the dispatcher replaces the dispatcher your observers are registered on. The observer does not run. So this test passes while proving nothing:
it('writes an audit entry', function () {
Event::fake(); // the observer is now disabled
$order = Order::factory()->create();
Event::assertDispatched('eloquent.created: '.Order::class); // ✅ and meaningless
});
Assert the outcome instead:
it('writes an audit entry on create', function () {
$order = Order::factory()->create();
expect(AuditEntry::where('order_id', $order->id)->where('action', 'created')->exists())
->toBeTrue();
});
it('writes no audit entry on a bulk update', function () {
$orders = Order::factory()->count(3)->create();
AuditEntry::query()->delete();
Order::whereIn('id', $orders->modelKeys())->update(['status' => 'settled']);
// Documents the behaviour so nobody "fixes" it by accident.
expect(AuditEntry::count())->toBe(0);
});
That second test looks perverse. It isn't — it pins the behaviour down so a future reader knows the gap was understood rather than missed, and it fails loudly if someone adds a trigger that changes the answer.
Gotchas and Edge Cases#
Event::fake()kills observers. Covered above. UseEvent::fakeExcept()or assert outcomes.withoutEvents()is global for its closure. Not scoped to the model you called it on.$touchesdoesn't fire the parent's events. It's arawUpdate(). Timestamp moves, observer doesn't run.- Seeders may miss observers if the service provider registering them isn't loaded in that context. Prefer the
#[ObservedBy(OrderObserver::class)]attribute on the model — it's registered by the model's own boot, so it can't be skipped. - Observer method order is unspecified across multiple observers on the same model. If two handlers must run in sequence, that's one handler.
retrievedfires on every hydration, including every row of a thousand-rowget(). Anything slow in there scales with your result sets.saveQuietly()is not transactional. It silences events; it doesn't make the write atomic with anything.
Wrapping Up#
Write down which of your models have observers, then grep for ->update(, ->delete(, attach(, sync( and upsert( against each one. The asymmetric paths will be obvious inside ten minutes, and they're always in bulk actions, data-fix commands and migrations.
Then decide, per observer, whether it's a convenience or an invariant — and move the invariants into the database or a single service method. If you're reviewing Eloquent habits more broadly, Eloquent strict mode catches a related class of silent failure at the model layer.
FAQ#
Why is my Laravel observer not being called?
The most common cause is that the write went through the query builder rather than a model instance. Model::where(...)->update(), ->delete(), truncate(), insert(), upsert() and the pivot methods attach()/sync()/detach() never hydrate a model, so no event is dispatched and no observer method runs. The other two causes are Event::fake() in a test, which replaces the dispatcher your observer is registered on, and an observer registered in a service provider that isn't loaded in that context — seeders and some console commands are the usual suspects.
Do Eloquent events fire on mass updates?
No. Laravel's documentation states it directly: when you issue a mass update or delete through Eloquent, the saving, saved, updating, updated, deleting and deleted events are not dispatched, because the models are never retrieved. This is deliberate — a bulk update is one SQL statement, and firing per-row events would require loading every affected model. If you need the side effect, either iterate the models with lazyById() and call save(), or perform the derived write as a second bulk statement inside the same transaction.
What order do Laravel model events fire in?
For a new record: saving, creating, created, saved. For an update: saving, updating, updated, saved. For a delete: deleting, then deleted — with trashed in between on a soft-deleting model, and forceDeleting before and forceDeleted after on a force delete. A restore runs restoring, the full save cycle, then restored. Two exceptions catch people out: increment() and decrement() fire only updating and updated, and saving an unchanged model fires only saving and saved.
What is the difference between saving and saved in Eloquent?
saving fires before the row is written and saved fires after. The practical difference is what you can do in each: returning false from saving cancels the write entirely, while returning anything from saved has no effect because the write has already happened. Use saving to mutate attributes or veto the operation, and saved for side effects that should only happen once the data is persisted. Both fire for creates and updates, so saving runs before creating or updating, and saved runs after created or updated.
How do I update many records and still fire model events?
Iterate the models and call save() on each — Model::where(...)->lazyById(500)->each(fn ($m) => $m->save()) keeps memory flat while still going through the model layer. That is correct but slow, since you pay one UPDATE plus the observer's work per row. For large volumes, do the bulk write and then express the observer's side effect as a second bulk statement inside the same transaction. If the derived value must be correct regardless of code path, move it into the database as a generated column, trigger or constraint.
What does saveQuietly() do and when should I use it?
saveQuietly() persists the model without dispatching any model events, so no observer method runs for that one write. It is the narrowest of the three escape hatches — the others being Model::withoutEvents() for a closure and unsetEventDispatcher() for a whole process — and it is the one you should reach for by default. Use it inside an observer that needs to write back to the same model without recursing, or in a data fix where you have already handled the side effect by hand. Matching methods exist for deleteQuietly(), restoreQuietly() and forceDeleteQuietly().