logo

When new syntax is not actually equivalent functionality

New syntax, same behavior. Usually.

Joel Clermont
Joel Clermont
2026-08-31

Laravel continues to evolve, and part of that is adding newer ways to configure the same functionality.

For example, in Eloquent, the newer casts() method is a replacement for the original $casts property. And more recently, PHP attributes like #[Fillable] and #[Table] can replace their matching properties.

I always thought of these new syntax choices as different ways of expressing the same configuration, but recently one of these conversions introduced a bug.

For a little behind the scenes, the tips you read here are currently stored as markdown files and loaded through Orbit, a file-based Eloquent driver.

During our Laravel 13 upgrade, we converted the $casts property on our Tip model to the newer casts() method. It seemed like a mechanical change that just updated syntax to the newer, preferred way shown in the Laravel docs.

But after doing this, our tests failed because every publish_date was now being stored as an integer timestamp instead of a date. (Hooray for a robust test suite, right?)

Let's dig into Eloquent a little and see why this happened:

// Illuminate\Database\Eloquent\Concerns\HasAttributes
protected function initializeHasAttributes()
{
    $this->casts = $this->ensureCastsAreStringValues(
        array_merge($this->casts, $this->casts()),
    );
    // ...
}

Eloquent merges the casts() method into the $casts property inside a trait initializer. Just looking at this, it sure seems like the end result is that our model has a unified set of casts.

If they were defined in the method, they get merged into the property. And everything downstream, including getCasts(), reads only the property.

That's normally fine, but Orbit builds its SQLite cache from a model instance it creates through reflection.

// ryangjchandler/orbit, in the Orbital trait
$instance = (new ReflectionClass(static::class))->newInstanceWithoutConstructor();

And the catch is that the Eloquent trait-based initializers only run when the model's constructor runs.

With no call to the constructor, the initializers don't run, so the casts() method never gets merged.

Maybe Orbit will cover this newer syntax more robustly in the future, but for now, we reverted to the $casts property and left a comment warning us not to change it unless we verified it works.

None of this means you should avoid the newer syntax. Most applications only ever build models through the constructor, so you might not run into these kinds of issues.

But this is a good example that demonstrates we can't always assume new syntax is just a different way of doing the exact same thing.

Here to help,

Joel

P.S. Our test suite caught this before it ever hit production. If you're not sure yours would, our testing workshop shows you how to build that safety net.

Toss a coin in the jar if you found this helpful.
Want a tip like this in your inbox every weekday? Sign up below 👇🏼
email
No spam. Only real-world advice.