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

Simulator · model events

Every event and every order runs on the tested reducer, measured against Laravel 13.31 on PHP 8.4.

2 rows written, 0 model events. 2 rows written in one statement, and not one model event. No model was ever built, so there was nothing for an observer to listen to. The search index still has these as drafts.

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. saving and saved fire on every save; updating and updated only fire when there is something to write. This is the mass update in reverse.
  • Press Post::create(). Watch where creating and created sit: inside the saving and saved pair, 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, the each() version publishes 1,500 and leaves 1,000 still drafts. each() delegates to chunk(), which pages with offset, so a loop that writes the filtered column walks past the rows that moved up.
  • Soft deletes add restoring, restored, trashed and forceDeleting, and SoftDeletes turns ->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. $afterCommit on a queued listener, and the after_commit queue setting, exist because of that gap. It is a page of its own.
  • retrieved fires 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

Spotted a problem, or have a way to make this clearer? Suggest an improvement.