>Dudenkoff_
← Lab / Laravel · Eloquent

The copy you never asked for

Laravel 13 · PHP 8.5 · Postgres · July 22, 2026 · part 5 of 8

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:

tinker
> [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:

tinker
> $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:

tinker
> $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:

tinker
> $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:

tinker
> 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:

app/Naive/TouchedOrder.php
<?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:

tinker
> $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:

tinker
> 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() :

tinker
> 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:

tinker
> 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):

vendor · Illuminate/Database/Eloquent/Concerns/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):

vendor · Illuminate/Database/Eloquent/Concerns/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):

vendor · Illuminate/Database/Eloquent/Concerns/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):

vendor · Illuminate/Database/Eloquent/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):

vendor · Illuminate/Database/Eloquent/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):

vendor · Illuminate/Database/Eloquent/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:

tinker
> $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:

tinker
> 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.


Next
The date that is not a cast
The series
  1. 01 One name, two things
  2. 02 The list before the question
  3. 03 Who picks up the phone
  4. 04 The ten that vanished
  5. 05 The copy you never asked for
  6. 06 The date that is not a cast
  7. 07 The id you never asked for
  8. 08 The row that would not stay dead