Models & data
Why your observer did not run
The mistake
You add an observer. When a post is saved it updates the search index, or clears a cache, or writes an audit row. You test it, it works, and from then on you treat it as a rule of the system: when a post changes, this happens.
It is not a rule about posts. It is a rule about models. Eloquent fires its
events from the model instance, in save, create and delete. A query builder
update never makes an instance. It compiles one UPDATE statement, sends it, and
returns the number of rows the database touched. There is no model, so there is
nothing to fire an event from, and an observer that is not called does not fail
loudly. It just does not run, and the thing it was keeping in step quietly falls
behind.
The machine
Every event and every order runs on the tested reducer, measured against Laravel 13.31 on PHP 8.4.
Drive it
The panel opens on the mass update, already run: two rows written, zero events, and both rows now marked stale against the search index.
- Press the featured button three times. It puts the drafts back, switches to loading each row as a model, and runs the same job again. Same two rows, same result in the database, ten events, and the index stays in step.
- Press “Save without changing”. Six events and not a single row written.
savingandsavedfire on every save;updatingandupdatedonly fire when there is something to write. This is the mass update in reverse. - Press
Post::create(). Watch wherecreatingandcreatedsit: inside thesavingandsavedpair, not instead of it.
The mechanism
Eloquent’s model events are dispatched by the model, from a handful of methods on
it. save() fires saving, then creating and created or updating and
updated, then saved. delete() fires deleting and deleted. Loading a row
into a model fires retrieved, once per model, which is why loading two rows to
update them costs two events before any saving starts.
So the question is never “did the row change”. It is “did a model do it”.
These go through a model, so the observer runs. $post->save(),
$post->update([...]), $post->delete(), Post::create([...]), and
Model::destroy([1, 2]), which is worth knowing: destroy loads each model
first specifically so the events fire.
These do not. Anything that ends on the query builder:
Post::where(...)->update([...]), ->delete(), ->increment(), and upsert().
They are one statement each, they are fast because of that, and they are silent.
It is the query builder that makes them silent, not the method name:
$post->increment('views') on a model instance does fire updating and
updated.
And these go through a model that has been told to keep quiet.
saveQuietly(), updateQuietly(), deleteQuietly() and
Model::withoutEvents(fn () => ...). In the panel the quiet option still fires
retrieved, because the rows were still loaded. Only the save itself is
suppressed.
The last piece is the one that surprises people even when they know the rest.
saving fires on every save, but updating and updated only fire when the
model is dirty. Save a model you did not change and you get saving and saved
and nothing between them, because there is nothing to write. If your listener
lives on saved and assumes something changed, it will run on saves where
nothing did.
Returning false from a listener on any of the “before” events, saving,
creating, updating, deleting, cancels the operation: the write never
happens and save() returns false. That is a real guard, and it is also worth
knowing that a mass update walks straight past it, for exactly the same reason it
walks past your observer.
In your code
The two versions of one job, and what each costs:
// one statement, no events, and the search index is now wrong
Post::where('status', 'draft')->update(['status' => 'published']);
// N+1 statements, every event fires, the observer keeps up
Post::where('status', 'draft')->eachById(fn (Post $post) => $post->update([
'status' => 'published',
]));
eachById(), not each(). The loop writes to the same column it filtered on, so
every row it publishes leaves the result set. each() pages by counting, and
against a set that keeps shrinking it walks past roughly 40 percent of the
drafts: they stay drafts, and the observer never runs for them either. That is
how chunking skips rows, arriving here by a
different door.
Neither is the right answer on its own. The mass update is the correct tool for a large backfill, where loading a million models to save them one at a time is not a plan. The point is to know which one you wrote, and to make up the difference deliberately when you take the fast path:
$ids = Post::where('status', 'draft')->pluck('id');
Post::whereKey($ids)->update(['status' => 'published']);
SearchIndex::refresh($ids); // the part the observer would have done
If the observer’s work must never be skipped, do not rely on remembering. Put it where the database can enforce it, or move the operation behind a method that is the only way to perform it, so there is no fast path left lying around.
The fine print
- The event names and their order were measured against Laravel 13.31 on PHP 8.4, with a listener on every model event and sqlite underneath. The panel replays those exact sequences.
- The
eachById()above is not a style preference. Measured on illuminate 13.31 with 2,500 drafts and the default page size of 1,000, theeach()version publishes 1,500 and leaves 1,000 still drafts.each()delegates tochunk(), which pages withoffset, so a loop that writes the filtered column walks past the rows that moved up. - Soft deletes add
restoring,restored,trashedandforceDeleting, andSoftDeletesturns->delete()on a model into an update. This page uses hard deletes to keep one idea at a time. - Observers and the static hooks (
Post::saving(...)) are the same mechanism. An observer class is just a tidier way to register the same listeners. - Events fired inside a transaction run when the model is saved, not when the
transaction commits.
$afterCommiton a queued listener, and theafter_commitqueue setting, exist because of that gap. It is a page of its own. retrievedfires once per model, so a page of 100 rows fires it 100 times. A listener there runs far more often than most people expect.- The panel keeps one row published throughout so there is always something the job does not touch. It is not part of the mechanism.
Further reading
- Laravel: Eloquent events lists every event and the quiet variants, and says plainly that mass updates fire nothing.
- Laravel: Observers covers
registering an observer class and the
ShouldHandleEventsAfterCommitinterface. - Laravel: mass updates is the paragraph that explains why, in the framework’s own words.
Spotted a problem, or have a way to make this clearer? Suggest an improvement.