skip to content

In Eloquent, how does $product->update([...]) on a loaded model differ from Product::where('sku', $sku)->update([...]) on a query?

level: middleimportance: should knowfreq 58%

answer

  1. fill() then save() versus one UPDATE
  2. bool versus affected-row count
  3. events and observers only on the model path
  4. casts and $fillable skipped on the query path
  5. only dirty columns written

basics

~20 s

A 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 s

On 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

for a junior

Recall that updating a loaded model saves one row, while calling update() on a query changes all matching rows at once.

for a middle

Explain dirty-only writes, return types, and which behaviours, events, casts, mass assignment, the query path skips.

for a senior

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.

for a principal

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