The copy you never asked for
The last episode set down a question and walked off. I read an order fresh, added ten in PHP, and asked what was dirty. The model answered total
, and only total
, though it never re-read the row. How does it know? Same bench as the last four episodes, the same 101 customers and 103 orders, order 1 at 150 where I left it:
> [App\Models\Customer::count(), App\Models\Order::count()];
= [
101,
103,
]
> App\Models\Order::find(1)->total;
= 150
The exact frame the last episode closed on, question and all:
> $order = App\Models\Order::find(1); $order->total = $order->total + 10; $order->getDirty();
= [
"total" => 160,
]
I did not save, and I did not re-read the columns. So how does it know total
moved by exactly ten? Two bets before I dig. First: the moment I ask, the model checks the database, holding my number against the stored one. Second: it never compares at all, it just flags every column I assign to. One costs a query, the other costs memory. Which is it?
A value that does not move#
Start with the order still in hand, its dirty total
at 160 from that recap. Ask it not for what I set, but for what it started with:
> $order->getOriginal('total');
= 150
150. The number from the database, while I am holding 160. So the object carries two values for one column: the one I wrote, and the one it read. Push on it. Overwrite total
with a nonsense 999 and ask both questions again:
> $order->total = 999;
= 999
> $order->getDirty();
= [
"total" => 999,
]
> $order->getOriginal('total');
= 150
getDirty
follows my hand to 999. getOriginal
does not flinch, holding 150 through 160 and through 999. Whatever the model compares against, it carries with it. My first bet wobbles: nothing here reached for the database. And I never called save
, so the 999 goes nowhere; I drop that draft. A bet is not dead, though, until a byte kills it.
The save that sent nothing#
Kill it properly. Turn the query log on, read a fresh order, save it back without touching a thing, and look at what went out:
> DB::enableQueryLog();
= null
> $order = App\Models\Order::find(1); $order->total;
= 150
> DB::flushQueryLog();
= null
> $order->save();
= true
> DB::getQueryLog();
= []
The log is empty. No UPDATE, and here is the line that ends the first bet: no SELECT either. If save
compared my model against the stored row, it would have had to read it, and a query would sit here. There is none. It needed nothing from the database to compare against, and save
returned true
without touching the wire. So it does not ask the database; it trades on a difference it keeps in memory. Which raises my second bet: if it only tracks difference, why keep a whole second value? A flag per touched column would be cheaper. Let me build that.
Marking every touch#
Here is the flag idea as code. It reads a row into an array, and every assignment records which key I touched. On save
, it ships the touched columns and nothing else:
<?php
namespace App\Naive;
use Illuminate\Support\Facades\DB;
class TouchedOrder
{
public array $attributes = [];
public array $touched = [];
public static function find(int $id): static
{
$order = new static;
$order->attributes = (array) DB::table('orders')->find($id);
return $order;
}
public function __get($key)
{
return $this->attributes[$key];
}
public function __set($key, $value)
{
$this->attributes[$key] = $value;
$this->touched[] = $key;
}
public function save(): bool
{
$dirty = array_intersect_key($this->attributes, array_flip($this->touched));
if ($dirty !== []) {
DB::table('orders')->where('id', $this->attributes['id'])->update($dirty);
}
return true;
}
}
Read order 1, assign total
its own value straight back, and save. The assignment changes nothing; maybe I am re-normalizing a field, or re-saving a form that came back unedited. Watch what ships:
> $t = App\Naive\TouchedOrder::find(1); $t->total;
= 150
> $t->total = $t->total;
= 150
> DB::flushQueryLog();
= null
> $t->save();
= true
> array_column(DB::getQueryLog(), 'query');
= [
"update \"orders\" set \"total\" = ? where \"id\" = ?",
]
One UPDATE, where the real model sent zero. The flag knows I touched total
. It does not know I left it at 150. Touching is not changing, and a flag cannot tell the two apart. On one row that is one wasted write. Now the shape that makes it hurt. A nightly job sweeps the shop and re-saves every order, the loop you write without a second thought:
> DB::flushQueryLog();
= null
> foreach (range(1, 103) as $id) { $t = App\Naive\TouchedOrder::find($id); $t->total = $t->total; $t->save(); }
> count(array_filter(array_column(DB::getQueryLog(), 'query'), fn ($q) => str_starts_with($q, 'update')));
= 103
103 UPDATEs. One per order, every one writing a value identical to what already sat there. Now the real model, the same sweep, reading the shop with one all()
:
> DB::flushQueryLog();
= null
> foreach (App\Models\Order::all() as $o) { $o->save(); }
> count(array_filter(array_column(DB::getQueryLog(), 'query'), fn ($q) => str_starts_with($q, 'update')));
= 0
> array_column(DB::getQueryLog(), 'query');
= [
"select * from \"orders\"",
]
What dirty tracking buys#
Zero UPDATEs. One SELECT to read the shop, then silence. This is dirty tracking, and here is the trade. The naive flag knows what I touched; the model knows what I changed, and knowing that means comparing each column against how it stood when the row was read. To compare, it has to keep that. The second value is not waste. It is what lets the model keep quiet when there is nothing to send, and 103 clean saves produced no UPDATE because every order's two copies still agreed. The flag threw the old value out the moment I assigned, so it can never buy that silence. (The naive loop reads one order at a time, 103 selects; the real one reads all at once. Writes are the count.)
Only the changed columns#
That order has been clean through the sweep, still holding 150. Change it for real, add ten, save, and read the query back:
> DB::flushQueryLog();
= null
> $order->total = $order->total + 10;
= 160
> $order->save();
= true
> array_column(DB::getQueryLog(), 'query');
= [
"update \"orders\" set \"total\" = ?, \"updated_at\" = ? where \"id\" = ?",
]
> $order->getOriginal('total');
= 160
> $order->getDirty();
= []
The UPDATE sets only the changed columns: total
, plus updated_at
. Three other columns did not move, and none of them shows up. That is the same pair the last episode's UPDATE carried, reached a different way; the vendor shows the two paths. A second thing shifted under me. getOriginal('total')
now reads 160, the number I just saved. The value that would not move all through the first scene has moved, and getDirty
is empty. The point I measure change from was reset to where I landed. Open the box.
Open the box#
Six short bodies answer everything so far, and they read top to bottom.
Where the second value is born#
A model that comes off the database is built by newFromBuilder
(Model.php), which hands the row to setRawAttributes((array) $attributes, true)
(HasAttributes.php). That second argument, true
, is a sync flag, and it fires one line (HasAttributes.php):
public function syncOriginal()
{
$this->original = $this->getAttributes();
return $this;
}
At read time the model copies its own attributes into a second array named original
. There is the birth of the value that would not move. When I wrote 999, it landed in attributes
, while original
still held the 150 copied in at the read. Two arrays, one row. (The constructor also calls syncOriginal
once on an empty model; the read is the call that fills it.)
How it decides#
With two arrays in hand, getDirty
is a plain loop (HasAttributes.php):
public function getDirty()
{
$dirty = [];
foreach ($this->getAttributes() as $key => $value) {
if (! $this->originalIsEquivalent($key)) {
$dirty[$key] = $value;
}
}
return $dirty;
}
For every attribute, if it is not equivalent to its original
, it goes in the dirty list. So the whole question is what equivalent means, and that lives in originalIsEquivalent
(HasAttributes.php):
public function originalIsEquivalent($key)
{
if (! array_key_exists($key, $this->original)) {
return false;
}
$attribute = Arr::get($this->attributes, $key);
$original = Arr::get($this->original, $key);
if ($attribute === $original) {
return true;
}
// ...
return is_numeric($attribute) && is_numeric($original)
&& strcmp((string) $attribute, (string) $original) === 0;
}
Run our values through it. The guard bails if the key was never in original
. Then it fetches both sides and runs the first branch, a strict ===
. When I saved with nothing changed, 150 ===
150 is true, the column is equivalent, nothing is dirty; that is the empty log and the silent sweep. When I wrote 999, 999 ===
150 is false, and the column reports dirty. The middle branches, cut here with // ...
, handle columns whose values need translating on the way in and out; our plain integer never reaches them. The final return
is a scar I come back to.
The clean model's shortcut#
That accounts for what is dirty, not yet for zero queries. For that, save
itself, the branch that runs when the row already exists (Model.php):
if ($this->exists) {
$saved = $this->isDirty() ?
$this->performUpdate($query) : true;
}
A ternary. isDirty()
is getDirty
reduced to a yes or a no. If the model is clean, save
sets $saved
to true
and never calls performUpdate
. There is the empty log and the silent sweep: a clean model does not reach the update path, so no query exists to log. Not a suppressed query. A path not taken.
What a dirty save does#
When the model is dirty, the ternary calls performUpdate
(Model.php):
protected function performUpdate(Builder $query)
{
// If the updating event returns false, we will cancel the update operation so
// developers can hook Validation systems into their models and cancel this
// operation if the model does not pass validation. Otherwise, we update.
if ($this->fireModelEvent('updating') === false) {
return false;
}
// First we need to create a fresh query instance and touch the creation and
// update timestamp on the model which are maintained by us for developer
// convenience. Then we will just continue saving the model instances.
if ($this->usesTimestamps()) {
$this->updateTimestamps();
}
// Once we have run the update operation, we will fire the "updated" event for
// this model instance. This will allow developers to hook into these after
// models are updated, giving them a chance to do any special processing.
$dirty = $this->getDirtyForUpdate();
if (count($dirty) > 0) {
$this->setKeysForSaveQuery($query)->update($dirty);
$this->syncChanges();
$this->fireModelEvent('updated', false);
}
return true;
}
Trace the dirty save from the last scene. The updating
event, first, can cancel the operation; ours lets it through. Then, because the model uses timestamps, updateTimestamps
runs (HasTimestamps.php). This is the part to watch: it sets updated_at
by writing it as an attribute, through setUpdatedAt
(skipping that only if I had already dirtied the column by hand), so updated_at
turns dirty like any value I assign myself. A different road than the last episode, where the builder stitched updated_at
onto the UPDATE from outside; here it arrives as a plain attribute on one instance. Two roads, one pair of columns. Next, getDirtyForUpdate
, a thin wrapper over getDirty
(HasAttributes.php), collects total
and the freshly dirtied updated_at
, the SET list I saw, and update($dirty)
ships it. The syncChanges
and updated
lines beside it are the bookkeeping pair from the last episode's tap
closure. Last, finishSave
(Model.php):
protected function finishSave(array $options)
{
$this->fireModelEvent('saved', false);
if ($this->isDirty() && ($options['touch'] ?? true)) {
$this->touchOwners();
}
$this->syncOriginal();
}
Two housekeeping lines first, parked: the saved
event fires, and touchOwners
forwards the touch to related models this bench has not met. The line that answers the scene is the last one, $this->syncOriginal()
, the same call from the read. After a save that stuck, the model copies attributes
back into original
, which is why getOriginal('total')
read 160 just now: the mark moved to where I landed. So the model asks neither the database nor me. It carries two copies of the row and trades on their difference; save
means send the difference, then move the mark. The last episode's syncOriginalAttribute
was the single-column brother of this line.
The scar#
That final return
in originalIsEquivalent
I parked. Unpark it. total
is back to 160 in the row. Assign it the string "160"
and ask what is dirty, then the string "160.0"
, then the integer again:
> $order->total = "160";
= "160"
> $order->getDirty();
= []
> $order->total = "160.0";
= "160.0"
> $order->getDirty();
= [
"total" => "160.0",
]
> $order->total = 160;
= 160
> $order->getDirty();
= []
The string "160"
is not dirty. The string "160.0"
is, though it is the same amount of money; it might have arrived as text from a form, a CSV, a JSON body. original
holds an integer 160. When I assign the string "160"
, the strict ===
fails at once, because a string is not an integer. Control falls through the parked middle branches to that final return
: both sides are numeric, so it compares them as text. strcmp("160", "160")
is 0, equivalent, not dirty; strcmp("160.0", "160")
is not 0, so "160.0"
reports dirty, the same value in different bytes. The trade is a cheap, predictable text compare instead of a clever numeric one that would call "1e2"
equal to 100. I set total
back to 160, leaving the row where the next episode begins.
The next episode#
One last look before I close. Every comparison this episode ran was between two copies of a row, and in those copies a date is a plain string. Reach for it on the model, though, and something else answers:
> class_basename($order->created_at);
= "Carbon"
> $order->getRawOriginal('created_at');
= "2026-07-22 05:43:42"
created_at
answers class_basename
as Carbon, a date object with methods. Yet getRawOriginal
shows what actually sits in the copy: a bare string, "2026-07-22 05:43:42"
. The branches I kept parking in originalIsEquivalent
exist for columns like this one, the ones our integer sailed past. They are a clue, not the answer: they only compare, and something else turns the stored string into an object with methods, somewhere I have not opened yet. Who does the turning, and where? The date that is not a cast is the next episode.
- 01 One name, two things
- 02 The list before the question
- 03 Who picks up the phone
- 04 The ten that vanished
- 05 The copy you never asked for
- 06 The date that is not a cast
- 07 The id you never asked for
- 08 The row that would not stay dead