The date that is not a cast
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:
> [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:
> $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:
> $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:
> $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:
> $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:
> $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:
> $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:
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):
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:
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):
// 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):
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:
> $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:
> (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.
- 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