>Dudenkoff_
← Lab / Laravel · Eloquent

The date that is not a cast

Laravel 13 · PHP 8.5 · Postgres · July 23, 2026 · part 6 of 8

The last episode spent itself comparing two copies of a row, and in every copy a date sat as a plain string. Then I reached for created_at on the model and something with methods answered. Who does the turning, and where? Same bench as the last five episodes, the same 101 customers and 103 orders, order 1 at 160 where I left it:

tinker
> [App\Models\Customer::count(), App\Models\Order::count()];
= [
    101,
    103,
  ]
> App\Models\Order::find(1)->total;
= 160

Read order 1, ask the class of its created_at , then ask the raw copy underneath:

tinker
> $order = App\Models\Order::find(1); class_basename($order->created_at);
= "Carbon"
> $order->getRawOriginal('created_at');
= "2026-07-23 06:38:47"

The copy holds a bare string, "2026-07-23 06:38:47" . Reach through the property and a Carbon answers, an object with methods. Somewhere between the string and my hand a translation happens. My first bet: the translated object is kept somewhere and handed back, the way the first episode's relation cache built Alice once and returned the same instance on every read.

Nobody keeps the translation#

=== on two objects does not ask whether they are equal. It asks whether they are the same instance, and that is exactly the question a cache answers. Back in episode one the relation cache gave itself away like this, one #8145 coming back twice. Ask created_at against itself:

tinker
> $order->created_at === $order->created_at;
= false

False. Not the same object, so there is no stored Carbon handed back on the second read. Dump the property twice, one line apart, and watch the ids:

tinker
> $order->created_at;
= Illuminate\Support\Carbon @1784788727 {#8574
    date: 2026-07-23 06:38:47.0 UTC (+00:00),
  }
> $order->created_at;
= Illuminate\Support\Carbon @1784788727 {#7421
    date: 2026-07-23 06:38:47.0 UTC (+00:00),
  }

Same instant on both, @1784788727 , same wall-clock date. But #8574 then #7421 , two different objects. Where the relation cache built one thing and filed it, this builds a fresh Carbon on every read and files nothing. The translation is redone the moment I look, each time from scratch.

Extending the deadline#

Here is a chore any shop runs a hundred times. Push a date. Extend a deadline by a day, slide a due date forward. The obvious line is to take the date and add a day to it:

tinker
> $order->created_at->addDay();
= Illuminate\Support\Carbon @1784875127 {#8579
    date: 2026-07-24 06:38:47.0 UTC (+00:00),
  }
> $order->getDirty();
= []
> $order->getRawOriginal('created_at');
= "2026-07-23 06:38:47"

The object reads 07-24, one day on. Yet getDirty is empty and the raw copy underneath has not moved. If I saved right now, save would send nothing, and the last episode showed what an empty dirty means. I moved a date and the change fell on the floor. addDay mutated the fresh, throwaway object a read just built, and that object died on the same line it was born. The attribute never heard about it. Silent lost change, and it ships. The gesture that actually lands is to assign the result back:

tinker
> $order->created_at = $order->created_at->addDay();
= Illuminate\Support\Carbon @1784875127 {#7443
    date: 2026-07-24 06:38:47.0 UTC (+00:00),
  }
> $order->getDirty();
= [
    "created_at" => "2026-07-24 06:38:47",
  ]

I assigned an object, and dirty holds a string, "2026-07-24 06:38:47" , the same date one day on. So the translator stands at the entrance too: a Carbon goes in, a string lands in the attributes array. I do not call save, and I drop this copy the way I dropped the 999 last time; the row on disk stays where it was. Two doors now. Where is the machinery that runs them?

The list that leaves it out#

A model surely keeps a list of which columns get translated. Ask for it:

tinker
> $order->getCasts();
= [
    "id" => "int",
  ]

One entry, and created_at is not it. (The id => "int" is the primary key translating itself; getCasts splices that in on its own when the key auto-increments, HasAttributes.php.) The list exists, the date is nowhere on it. So who translates a column the list has never heard of? Open the box.

Open the box#

Follow the way out first, from the property to where the string turns.

From property to the array#

Reading $order->created_at lands in __get (Model.php), which forwards straight to getAttribute (HasAttributes.php). Episode one stopped in a different branch of that same method, the relational one that fetched Alice. A date is a real column, so it takes the earlier branch: hasAttribute is true, and getAttributeValue (HasAttributes.php) runs. That method is one line, handing the raw array value to transformModelValue .

Three doors#

transformModelValue is where the value gets turned (HasAttributes.php), and here it is in full:

vendor · Illuminate/Database/Eloquent/Concerns/HasAttributes.php
protected function transformModelValue($key, $value)
{
    // If the attribute has a get mutator, we will call that then return what
    // it returns as the value, which is useful for transforming values on
    // retrieval from the model to a form that is more useful for usage.
    if ($this->hasGetMutator($key)) {
        return $this->mutateAttribute($key, $value);
    } elseif ($this->hasAttributeGetMutator($key)) {
        return $this->mutateAttributeMarkedAttribute($key, $value);
    }

    // If the attribute exists within the cast array, we will convert it to
    // an appropriate native PHP type dependent upon the associated value
    // given with the key in the pair. Dayle made this comment line up.
    if ($this->hasCast($key)) {
        if (static::preventsAccessingMissingAttributes() &&
            ! array_key_exists($key, $this->attributes) &&
            ($this->isEnumCastable($key) ||
             in_array($this->getCastType($key), static::$primitiveCastTypes))) {
            $this->throwMissingAttributeExceptionIfApplicable($key);
        }

        return $this->castAttribute($key, $value);
    }

    // If the attribute is listed as a date, we will convert it to a DateTime
    // instance on retrieval, which makes it quite convenient to work with
    // date fields without having to create a mutator for each property.
    if ($value !== null
        && \in_array($key, $this->getDates(), false)) {
        return $this->asDateTime($value);
    }

    return $value;
}

Three doors for the string "2026-07-23 06:38:47" under the key 'created_at' . Door one is hasGetMutator and its sibling; Order defines neither, so both branches at the top miss. Door two is hasCast , and the byte from getCasts already told me created_at is not in that list, so the block is skipped, missing-attributes guard and all. Door three is the one that fires, $value !== null && in_array($key, $this->getDates(), false) , and then asDateTime($value) . The date is not null, and it is in getDates . Even the comment above it lines up, as Dayle intended.

The gift that comes with timestamps#

getDates is not a list I ever wrote (HasAttributes.php):

vendor · Illuminate/Database/Eloquent/Concerns/HasAttributes.php
public function getDates()
{
    return $this->usesTimestamps() ? [
        $this->getCreatedAtColumn(),
        $this->getUpdatedAtColumn(),
    ] : [];
}

When the model uses timestamps, this returns its two timestamp columns, resolved through getCreatedAtColumn and getUpdatedAtColumn to created_at and updated_at . There is the answer to the last scene. The date's translation was never in the list I asked for; it rides door three, a gift bundled in with $timestamps .

Where the Carbon is built#

Door three calls asDateTime , the maker (HasAttributes.php). Its head is a run of short-circuits, elided here, for values that are already objects, a unix integer, or a bare date with no time on it. Our value is none of those, so it drops to the last branch:

vendor · Illuminate/Database/Eloquent/Concerns/HasAttributes.php
protected function asDateTime($value)
{
    // ...

    $format = $this->getDateFormat();

    // Finally, we will just assume this date is in the format used by default on
    // the database connection and use that format to create the Carbon object
    // that is returned back out to the developers after we convert it here.
    try {
        $date = Date::createFromFormat($format, $value);
    } catch (InvalidArgumentException) {
        $date = false;
    }

    return $date ?: Date::parse($value);
}

getDateFormat gives 'Y-m-d H:i:s' , and Date::createFromFormat builds a brand new Illuminate\Support\Carbon from the stored string and that format. (The catch and the Date::parse fallback below it are for strings that miss the format; ours matches on the first try.) The class comes back as Carbon, which is what class_basename read. And it is a new object on every read, because this function makes one and files it nowhere. The class and object casts that getCasts can list cache the object they build; this road never touches that cache, so there is nothing to hand back on the second read. There is why === came up false.

The door on the way in#

The entrance is the mirror image. setAttribute carries a date branch (HasAttributes.php):

vendor · Illuminate/Database/Eloquent/Concerns/HasAttributes.php
// If an attribute is listed as a "date", we'll convert it from a DateTime
// instance into a form proper for storage on the database tables using
// the connection grammar's date format. We will auto set the values.
elseif (! is_null($value) && $this->isDateAttribute($key)) {
    $value = $this->fromDateTime($value);
}

When I assign a date, isDateAttribute catches it and fromDateTime runs (HasAttributes.php):

vendor · Illuminate/Database/Eloquent/Concerns/HasAttributes.php
public function fromDateTime($value)
{
    return empty($value) ? $value : $this->asDateTime($value)->format(
        $this->getDateFormat()
    );
}

It runs the value through the same asDateTime to get a Carbon, then formats it right back to 'Y-m-d H:i:s' text. Whatever I hand in leaves as one canonical string before it ever reaches the attributes array. There is the "2026-07-24 06:38:47" I saw in dirty: I put an object in, and the door rendered it to text.

So the translator is not a store, it is two doors. On the way out the stored string becomes a fresh Carbon, minted new on each read. On the way in everything I assign becomes a string. Inside the model there are only strings, which is why the last episode could compare a date as text and never notice, and why moving a date past the assignment moved nothing. Now the words line up. Door one is Laravel's accessors, the get mutators. Door two is casts, the getCasts list, and id => "int" was a cast the whole time. created_at is neither. It rides the third door, the date list that comes free with timestamps, and the title holds: the date is not a cast, getCasts never once named it.

Two scars#

First scar. Read a clean order and hand created_at the same instant wearing different clothes, first a DateTime built from the raw string, then an ISO string:

tinker
> $order = App\Models\Order::find(1); $order->created_at = new DateTime($order->getRawOriginal('created_at'));
= DateTime @1784788727 {#8467
    date: 2026-07-23 06:38:47.0 UTC (+00:00),
  }
> $order->getDirty();
= []
> $order->created_at = '2026-07-23T06:38:47Z';
= "2026-07-23T06:38:47Z"
> $order->getDirty();
= []

Both land clean. A DateTime object and an ISO string, neither shows up as dirty, because the entrance door canonicalizes each one to the same 'Y-m-d H:i:s' text before a single comparison happens; the ISO shape is even the case that misses createFromFormat and rides the parked Date::parse fallback, landing in the same place. The last episode's strict === catches the match first. The date-aware comparison branch that episode kept eliding never even wakes for created_at ; the door on the way in already flattened the value. (I am not saying that branch fired. It never gets the chance to.)

Second scar, smaller. The price of building fresh is that every access mints a Carbon. In a tight loop, pull ->created_at into a variable once and read the variable, rather than paying for a new object each pass.

The next episode#

One tally before I close. Not a single save fired this whole episode, and the row on disk is the exact string it was when I walked in. Rows have been born on this bench before, back when the world grew to its 103 orders, but in five episodes of opening mechanisms I have never once opened that one. It starts here:

tinker
> (new App\Models\Order)->exists;
= false

A model with no row behind it, exists answering false. The save fragment the last episode printed was fenced by one condition, if ($this->exists) , and everything I opened lives inside that fence. What runs when exists is false, I have never seen. How does a row get born? The id you never asked for is the next episode.


Next
The id you never asked for
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