In Eloquent, what do isDirty(), wasChanged() and getOriginal() report before and after save(), and how do refresh() and fresh() differ?
answer
- attributes versus original
- isDirty before save, wasChanged after
- getOriginal returns the new value after save
- getPrevious() holds replaced values
- refresh() same instance, fresh() new one
basics
~20 sBefore save(), isDirty() flags changed columns and getOriginal() returns the loaded values. After save(), isDirty() is false, wasChanged() reports what was written and getOriginal() returns the new values. refresh() reloads the same instance; fresh() returns a new one.
solid answer
~30 sEloquent keeps the current attributes and the original values side by side. Before saving, `isDirty('price_cents')` is true if that column changed and `getOriginal('price_cents')` returns the loaded value. `save()` writes only the dirty columns, records them as changes, then syncs original to the current attributes. So afterwards `isDirty()` is false, `wasChanged('price_cents')` is true, and `getOriginal()` returns the **new** value; the replaced values are in `getPrevious()`. To reload, `refresh()` overwrites the same instance, including its loaded relations, and returns it, while `fresh()` returns a new instance and leaves the current one alone. `replicate()` makes an unsaved copy without the key and timestamps.
go deeper
Recall that isDirty() means changed but not saved, and wasChanged() means changed by the last save.
Explain how original is synced after save, why getOriginal() then returns the new value, and how refresh() and fresh() differ in what they return.
Use getPrevious() or a pre-save snapshot for audit trails, and reload models after query-level writes that bypass them.
Decide whether change detection belongs in model state or in explicit domain events that do not depend on Eloquent's save ordering.
## The state Eloquent keeps on a model An Eloquent model holds two arrays of column values: - **attributes** — the current values, including anything you have set since loading; - **original** — the values as they were when the model was loaded or last saved. Comparing the two is how Eloquent knows what changed, which drives both the UPDATE it sends and the change-inspection methods interviewers ask about. After each successful save it also records the **changes** that save wrote and the **previous** values they replaced. ## Before `save()` ```php $product = Product::firstWhere('sku', 'TV-55-OLED'); // price_cents = 129900 $product->price_cents = 119900; $product->isDirty(); // true $product->isDirty('price_cents'); // true $product->isDirty('name'); // false $product->getDirty(); // ['price_cents' => 119900] $product->getOriginal('price_cents'); // 129900 ``` - `isDirty()` with no argument asks "has anything changed?"; with names it checks those columns. `isClean()` is its negation. - `getOriginal('price_cents')` returns the loaded value, with casts applied; `getRawOriginal()` returns it uncast. - Setting a column to a value **equivalent** to the original is not dirty. Eloquent compares with casts in mind, so `'5'` against `5` on an integer cast, or the same moment in two date formats, counts as unchanged. ## After `save()` `save()` writes the dirty columns, then records and resets the state: 1. `syncChanges()` stores what was written: `getChanges()` returns `['price_cents' => 119900, 'updated_at' => ...]`, and `getPrevious()` returns the values they replaced. 2. `syncOriginal()` copies the current attributes into original. So after the save: | Call | Result | |---|---| | `isDirty('price_cents')` | `false` | | `wasChanged('price_cents')` | `true` | | `getOriginal('price_cents')` | `119900`, the new value | | `getPrevious()['price_cents']` | `129900` | That is the classic trap: **`getOriginal()` after `save()` returns the new value**, because "original" now means "as saved". Code that needs the old value once `save()` has returned, such as a service writing a price-history row, reads `getPrevious()` or captures `getOriginal()` before calling `save()`. Inside an `updated` observer the timing differs: original is synced only after the `saved` event, so `getOriginal()` there still returns the old value, while `getPrevious()` works in both places. ## `refresh()` versus `fresh()` Both reload from the database, ignoring global scopes and reading through the write connection, but they differ in what they give back: | | `$product->refresh()` | `$product->fresh()` | |---|---|---| | Returns | the **same** instance, reloaded | a **new** instance | | Current object | overwritten with database values | left untouched | | Loaded relations | reloaded | only those you pass, e.g. `fresh(['category'])` | | Unsaved model | returns it unchanged | returns `null` | Use `refresh()` when the object you hold must now match the database, for example after a query-level update touched the row. Use `fresh()` to compare what you hold with what is stored without losing your in-memory edits. ## `replicate()` `$copy = $product->replicate()` returns a **new, unsaved** model with the same attributes, minus the primary key, `created_at`, `updated_at` and any generated unique-id columns. Pass an array of further columns to leave out, such as `replicate(['sku'])` when the copy must get a new SKU. Loaded relations are copied onto the new instance, but nothing is written until you call `save()`, which then inserts a new row. ## Where it matters in the feed import - Counting "changed products" for the nightly report: `wasChanged()` after each save, or `wasRecentlyCreated` for new ones. - Skipping work: checking `isDirty()` before triggering an expensive reindex. - Price history: reading the old price with `getPrevious()` after the save, not `getOriginal()`. ## Timestamps and the change set Because `save()` sets `updated_at` before it computes the dirty columns, the recorded changes of any real update include `updated_at` as well as the columns you edited. `wasChanged()` with no argument is therefore true after any save that wrote something, and a report that counts "changed products" should check the business columns by name, such as `wasChanged(['price_cents', 'stock'])`, rather than the bare call. Conversely, a save that found nothing dirty records no changes at all, so `wasChanged()` stays false and `updated_at` is untouched.
- Once `$product->save()` has returned, how do you read the price the product had before?Use `$product->getPrevious()['price_cents']`: `syncChanges()` recorded the replaced values when the UPDATE ran. `getOriginal()` now returns the saved value, because original is synced after the `saved` event. Inside an `updated` observer `getOriginal()` still holds the old value, but `getPrevious()` works both there and after the save.
- What does `$product->replicate()` leave out of the copy?The primary key, the `created_at` and `updated_at` columns, any unique-id columns from `HasUuids` or `HasUlids`, and any extra columns you pass in the `$except` array. The result is an unsaved model, so `save()` inserts a new row rather than updating the original.
saying these in an interview costs you the question
- getOriginal() still returns the pre-save value after save()
- wasChanged() reports unsaved edits before save() is called
- Setting a column to an equal value makes the model dirty
- fresh() reloads the current instance in place
- replicate() inserts the copy into the database immediately