In Eloquent, how does $product->update([...]) on a loaded model differ from Product::where('sku', $sku)->update([...]) on a query?
answer
- fill() then save() versus one UPDATE
- bool versus affected-row count
- events and observers only on the model path
- casts and $fillable skipped on the query path
- only dirty columns written
basics
~20 sA model update fills and saves one loaded model, firing events, applying casts and writing only changed columns. A query update sends one UPDATE for every matching row, returns the affected count and skips model events, casts and mass-assignment rules.
solid answer
~40 sOn a loaded model, `update([...])` is `fill()` plus `save()`: mass-assignment rules, mutators and casts apply, the `saving`, `updating`, `updated` and `saved` events fire, `updated_at` is set, and only dirty columns are written, or nothing if nothing changed. It returns a boolean. On a query, `Product::where(...)->update([...])` compiles to a single UPDATE across all matching rows and returns the number of affected rows. It still sets `updated_at`, but no model is loaded, so no model events or observers run, and casts, mutators and `$fillable` are not consulted. I use the model path when per-record rules must run and the query path for bulk changes that do not depend on them.
go deeper
Recall that updating a loaded model saves one row, while calling update() on a query changes all matching rows at once.
Explain dirty-only writes, return types, and which behaviours, events, casts, mass assignment, the query path skips.
Pick the path per change: model saves where observers and casts carry business rules, query updates for bulk housekeeping, with explicit side effects when needed.
Decide whether business rules may live in model events at all, given that any query-level write bypasses them.
## Two ways to change a row Eloquent can update data along two very different paths. - **The model path**: load a model, change it, and persist it with `$product->save()` or `$product->update([...])`. - **The query path**: call `update([...])` on a query, as in `Product::where('sku', $sku)->update([...])`, which never builds a model. They look similar in code and behave very differently. ## The model path, step by step `$product->update(['price_cents' => 49900])` is `fill()` followed by `save()`: 1. `fill()` applies mass-assignment rules, so unlisted keys are filtered. 2. Mutators and casts run as each attribute is set. 3. `save()` fires `saving`, then, because the model exists, checks `isDirty()`. 4. If something changed it fires `updating`, sets `updated_at`, and issues an UPDATE containing **only the dirty columns**, keyed by the primary key. 5. It fires `updated` and `saved`, then syncs the original state. If nothing changed, no UPDATE is sent at all and `save()` still returns `true`. `update()` on a model that does not exist yet returns `false` without writing. The path costs a SELECT to load the model plus the UPDATE. `save()` is the general call: it inserts a new model or updates an existing one with whatever you have set. `update($array)` is the shortcut for "fill these and save". ## The query path `Product::where('discontinued', true)->update(['stock' => 0])` compiles to one UPDATE statement: - it returns the **number of affected rows**, an integer, not a boolean; - it adds `updated_at` to the values when the model uses timestamps; - it does **not** load models, so no `saving` / `updating` / `updated` / `saved` events fire and observers never see the change; - values go to the query builder as given, so **mutators, casts and mass-assignment rules do not apply**; - models already loaded in memory keep their old values until reloaded. ## Side by side | | `$product->update([...])` | `Product::where(...)->update([...])` | |---|---|---| | Queries | SELECT to load, then UPDATE if dirty | one UPDATE | | Rows | one model | every matching row | | Return value | `bool` | `int` affected rows | | Model events and observers | fire | skipped | | Casts and mutators | applied | not applied | | `$fillable` / `$guarded` | enforced | not consulted | | `updated_at` | set | set | ## Choosing in the electronics feed The nightly import touches two kinds of change: - **Per-product edits with business rules**, such as a price change that must be logged by an observer or recalculate a cached margin. These need the model path so the rules run. - **Bulk housekeeping**, such as zeroing stock for every SKU missing from tonight's feed. One query-level UPDATE across thousands of rows is far cheaper than loading each model, as long as nothing depends on per-model events. When a bulk change **does** need per-model behaviour, the choices are to iterate the models in chunks and save each one, or to perform the side effect explicitly after the query-level update. ## Pitfalls - Expecting an observer to audit a price change made with a query-level `update()`. - Passing a PHP array to a JSON column through a query-level update and expecting the model's cast to encode it. - Reading `$product->stock` after a query-level update on the same row and seeing the stale value; call `refresh()` if the in-memory model must match. - Treating the integer return of a query update as a success flag; `0` just means no row matched or nothing needed changing. ## A quick decision checklist When reviewing a write, ask in order: 1. Does anything hang off this change, an observer, an audit row, a cache flush, a cast that shapes the stored value? If yes, take the model path, or do the side effect explicitly. 2. How many rows? One or a handful: the model path's extra SELECT is negligible. Thousands: a query-level update, or chunked model saves if per-row logic is unavoidable. 3. Is the input user-supplied? The query path does not consult `$fillable`, so pass it only values your code chose. 4. Will anything read the same row from memory afterwards? Refresh it, or it will act on stale values.
- After `Product::where('sku', 'TV-1')->update(['stock' => 0])`, why does an already-loaded `$product` for TV-1 still show the old stock?The query-level update goes straight to the database and never touches models in memory. The loaded instance keeps the attributes it was hydrated with until you call `$product->refresh()` or fetch it again.
- What does `$product->save()` do when no attribute has changed since it was loaded?It fires `saving`, sees that `isDirty()` is false, skips the UPDATE entirely, then fires `saved` and returns `true`. No `updating` or `updated` events fire and `updated_at` is not bumped, because those belong to the update path that never ran.
saying these in an interview costs you the question
- A query-level update fires the model's updated event for each row
- Model::where()->update() applies the model's casts to the values
- $product->save() always writes every column of the row
- A query-level update returns true or false
- $fillable protects query-level updates from unexpected columns