An Eloquent observer syncs listings to a search index and audit log, yet Listing::where('status', 'pending')->update([...]) triggered neither; why, and how do you fix it?
answer
- query, not model instances
- Builder::update() returns affected rows
- no models hydrated, nothing to fire
- destroy() loads each model first
- chunkById and save, or explicit reindex
basics
~20 sBuilder::update() runs one UPDATE and returns the affected-row count; no Listing models are loaded, so saving, updating, updated and saved never fire. Load and save each model, or keep the bulk write and reindex and audit the affected IDs explicitly.
solid answer
~40 sModel events are fired by methods on a loaded model, such as `save()` and `delete()`. `Listing::where(...)->update([...])` goes through the Eloquent Builder straight to one SQL UPDATE and returns an integer, so no `Listing` object exists to fire `updating` or `updated`, and the observer is never called. Query-level `delete()` skips `deleting`/`deleted` the same way, including soft deletes, while `Listing::destroy($ids)` does fire them because it loads each model first. For small sets, iterate with `chunkById()` and save each model so the observer runs. For large sets, `pluck('id')` the targets, run the bulk update with `whereKey($ids)`, and dispatch an explicit reindex-and-audit job for those IDs.
code
php · 13 lines<?php
use App\Models\Listing;
// Small sets: per-model saves, ListingObserver runs for each row.
Listing::where('status', 'pending')
->where('listed_at', '<', now()->subDays(90))
->chunkById(200, function ($listings) {
foreach ($listings as $listing) {
$listing->status = 'expired';
$listing->save(); // saving, updating, updated, saved
}
});go deeper
Recall that model events fire from loaded models; a where()->update() query changes rows without any model object.
Contrast instance update() with builder update(), and list the other set-based writes that skip events: query delete, soft deletes by query, insert and upsert.
Pick between per-model saves and a bulk write with an explicit reindex job by row count, and make the side effect impossible to forget in bulk service methods.
Judge whether an invariant like a search index should rely on model hooks at all, or be rebuilt from the table so every writer is covered.
## The symptom A real-estate app keeps two things in step with its `listings` table through a `ListingObserver`: a search index (so buyers see current prices and statuses) and an audit log (who changed what). A nightly task expires stale listings: ```php Listing::where('status', 'pending') ->where('listed_at', '<', now()->subDays(90)) ->update(['status' => 'expired']); ``` The rows change, but the observer's `updated` method never runs: the search index still shows the listings as pending, and the audit log has no entries. ## Why no event fired Eloquent model events belong to **model instances**. They are fired from methods on a `Model` object, such as `save()`, `delete()` and `restore()`, using that object's before and after state. `Listing::where(...)->update([...])` never creates a `Listing` object: 1. `where()` returns an Eloquent **Builder**. 2. `Builder::update()` adds `updated_at` to the values (when the model uses timestamps) and hands them to the base query builder. 3. One `UPDATE ... WHERE ...` statement runs, and the method returns the **number of affected rows** as an integer. No row is loaded, so there is no model to fire `saving`, `updating`, `updated` or `saved` on, and nothing to pass to an observer. The same holds for other set-based writes: | Write | Model events | |---|---| | `$listing->update([...])` / `$listing->save()` | fire | | `Listing::where(...)->update([...])` | none | | `Listing::where(...)->delete()` | none (no `deleting`/`deleted`) | | `where(...)->delete()` on a `SoftDeletes` model | none; one UPDATE of `deleted_at`, no `trashed` | | `Listing::destroy([4, 9])` | fire, because it loads each model and calls `delete()` | | `insert()` / `upsert()` | none | The trap is that `Listing::findOrFail($id)->update([...])` and `Listing::where('id', $id)->update([...])` look almost identical: the first updates a loaded model, the second runs a query. ## Fix 1: load the models and save each Iterate the matching rows as models and save them one by one: - use `chunkById()` so memory stays bounded and changing `status` does not shift the pages; - every save fires the full event sequence, so the observer reindexes and audits each listing. Costs: one UPDATE per row plus whatever the observer does per row, so a 50,000-row expiry becomes 50,000 writes and 50,000 index calls. Acceptable for hundreds of rows, slow for large sweeps. ## Fix 2: keep the bulk write, make the side effects explicit When the set is large, keep the single UPDATE and perform the follow-up work yourself: 1. select the affected keys first with `pluck('id')`; 2. run `Listing::whereKey($ids)->update([...])`; 3. dispatch one job that reindexes those IDs in batches and writes the audit rows. This is fast, but the observer is no longer the only place the rule lives. Document it next to the observer, and restrict it to one well-named service method so the next bulk write does not forget it again. ## Fix 3: move the invariant off the model If an index or audit trail must stay correct whatever writes the table, including raw SQL and other services, a model hook is the wrong home. Consider rebuilding the index periodically from the table, or recording changes at the database level. That is an architectural choice beyond Eloquent, but it is the honest answer when "every write" really means every write. ## Choosing between the fixes | Situation | Better fix | Why | |---|---|---| | A few hundred rows, hooks must behave exactly as for a single edit | per-model saves with `chunkById()` | the observer stays the single source of truth | | Tens of thousands of rows in a nightly job | bulk UPDATE plus an explicit job for the IDs | one statement for the data, batched side effects | | Writers outside Laravel touch the table | rebuild or reconcile the index from the table | model hooks cannot see those writes at all | Whichever fix you choose, collect the IDs **before** the bulk update. After `status` has changed, the original `where('status', 'pending')` no longer matches those rows, so there is no way to find them again for the reindex step. ## How to catch it early - Grep for `->update(` and `->delete(` chained after `where(`, `whereIn(` or `whereKey(` on models that have observers. - In feature tests, assert the side effect (the index call or audit row) after the service method, not only the database state. - Name bulk methods explicitly, such as `expireStaleListings()`, and keep the reindex step inside them.
- Why does Listing::destroy([4, 9]) fire the observer's deleting and deleted methods when Listing::whereIn('id', [4, 9])->delete() does not?`Model::destroy()` queries the rows with `whereIn()->get()` and calls `delete()` on each loaded model, so every instance fires `deleting` and `deleted` and it returns how many it deleted. The builder's `delete()` issues one DELETE statement, or on a `SoftDeletes` model one UPDATE of `deleted_at`, with no models in memory.
- Does calling $listing->increment('views') on a loaded Eloquent model fire the observer?Partly. The instance `increment()` fires `updating` before its UPDATE and `updated` after, so an `updated` observer method runs. It does not go through `save()`, so `saving` and `saved` do not fire. On a query, `Listing::where(...)->increment('views')` fires nothing.
saying these in an interview costs you the question
- Builder update() loads each row and fires updated per model
- Only saving and saved are skipped by a mass update
- Soft-deleting with where()->delete() still fires the trashed event
- Listing::destroy() skips model events just like a query delete
- Wrapping a mass update in a transaction makes observers fire