Speed up Eloquent : Replace Casts with Property Hooks Casting
The idea from this article was inspired by this thread:
and is a follow up after these articles:
- https://marius-ciclistu.medium.com/frozen-model-read-only-dto-with-laravel-crud-wizard-free-6bf274e341fa
- https://marius-ciclistu.medium.com/serializable-property-hooks-in-eloquent-model-without-conflicts-398271774cba3
- https://marius-ciclistu.medium.com/improve-eloquent-hydration-bypassing-casts-7a68bd25fa97
I never used the casts that Laravel offers, at least that is what I thought until this issue:
It seems that the primary key is automatically added to casts if the model has incrementing true. So I improved this behavior in laravel-crud-wizard-free and maravel-framework as described in this article.
You might wonder why I don’t use casts. The reason is simple. In MySql for example there are 3 types of data: int, string, null. The default behavior will return these from DB as they are so, in my code I always compare by casting the value to string like:
if (
(string)$model->is_enabled === '1'
|| (string)$model->status === 'ok'
|| (string)$model->nullable_column === 'ok'
|| (string)$model->id === '10'
) {
echo 'ok';
}
That generates API responses with integers, strings and nulls, booleans being integers 0 or 1.
If you mess with the PDO attributes like:
PDO::ATTR_STRINGIFY_FETCHES => true,
you will get only strings and nulls and you might want to cast at least the integers (besides the id of incrementing models which is auto-casted to int). You can do that of course by using the casts that Laravel offers but that will generate overhead because the values are stored in $attributes and $original properties of the model as they came from DB so, on each originalIsEquivalent call the original and actual attribute values are casted if they don’t match in type and value (true === 1 or true === ‘1’).
Starting from PHP 8.4 there is an alternative to these casts that is not exclusive so, they can be used together.
<?php
namespace App\Models\Attributes;
use MacropaySolutions\LaravelCrudWizard\Models\Attributes\BaseModelAttributes;
class UserAttributes extends BaseModelAttributes
{
public int $id {
get => $this->ownerBaseModel->getAttributeValue('id'); // this is autocasted by Eloquent if the model has incrementing id
set(int $value) {
$this->ownerBaseModel->setAttribute('failed_logins', $value);
}
}
public int $failed_logins {
get => (int)($this->ownerBaseModel->getAttributeValue('failed_logins'));
set(int $value) {
$this->ownerBaseModel->setAttribute('failed_logins', $value); // or (string)$value
}
}
public string $first_name {
get => $this->ownerBaseModel->getAttributeValue('first_name');
set(string $value) {
$this->ownerBaseModel->setAttribute('first_name', $value);
}
}
public string $last_name {
get => $this->ownerBaseModel->getAttributeValue('last_name');
set(string $value) {
$this->ownerBaseModel->setAttribute('last_name', $value);
}
}
public bool $is_active {
get => (bool)$this->ownerBaseModel->getAttributeValue('is_active');
set(bool $value) {
$this->ownerBaseModel->setAttribute('is_active', (int)$value); // or (string)(int)$value
}
}
public Carbon $created_at {
get => Carbon::parse($this->ownerBaseModel->getAttributeValue('created_at'));
set(Carbon $value) {
$this->ownerBaseModel->setAttribute('created_at', $value->toDateTimeString());
}
}
// Pay attention with arrays as property hooks
// You can only set the whole array in this manner
// See more details in the PHP documentation under References
// https://www.php.net/manual/en/language.oop5.property-hooks.php
public array $some_json {
get => \json_decode($this->ownerBaseModel->getAttributeValue('some_json'), true);
set(array $value) {
$this->ownerBaseModel->setAttribute('some_json', \json_encode($value));
}
}
}
To make sure these are used also for $model->last_name not only for $model->a->last_name , override in your model:
public function __set($key, $value)
{
$this->a->{$key} = $value;
}
public function __get($key)
{
return $this->hasAttribute($key)
? $this->a->{$key}
: $this->r->{$key};
}
If getAttribute , getAttributeValue or setAttribute methods are used, the property hooks are not called!
For API responses the toArray is used which for casts will contain the casted values. To replicate the behavior the method addCastAttributesToArray must be overridden in the model:
protected function addCastAttributesToArray(array $attributes, array $mutatedAttributes)
{
$attributes = parent::addCastAttributesToArray($attributes, $mutatedAttributes);
foreach (\array_diff_key($attributes, $this->getCasts(), $mutatedAttributes) as $key => $value) {
$attributes[$key] = $this->a->{$key};
if ($attributes[$key] === null || \is_scalar($attributes[$key])) {
continue;
}
// change these as per your need:
$attributes[$key] = match (true) {
$attributes[$key]instanceof Enumerable => $attributes[$key]->all(),
$attributes[$key]instanceof Arrayable => $attributes[$key]->toArray(),
$attributes[$key]instanceof Traversable => \iterator_to_array($attributes[$key]),
$attributes[$key]instanceof Jsonable => \json_decode($attributes[$key]->toJson(), true),
$attributes[$key]instanceof JsonSerializable => $attributes[$key]->jsonSerialize(),
$attributes[$key]instanceof DateTimeInterface => $this->serializeDate($attributes[$key]),
$attributes[$key]instanceof UnitEnum => $attributes[$key]->name,
default => (array)$items,
};
}
return $attributes;
}
By doing these, you will:
- avoid casting the values on each isDirty, save etc. method calls and speed up your application while using typed scripting.
- keep the API responses with casted values
- keep the cast on each attribute get and set in both casts and property hooks scenarios
- keep using the casted values in your application if you want (or if you don’t because the auto-incrementing id is always casted to int)
- change how originalIsEquivalent will compare values if you transition from casts so, to avoid 1 === ‘1’ generating updates because the data is Dirty you should set the value in the format it comes from DB in the property hook. Remember that if $original === $attribute it will return true and will not do other things
- segregate the database columns (kept in $attributes PHP property of the model) from the model’s PHP properties and also from the relations names to avoid clashes
- avoid this overhead (meant to support property hooks directly in the model) t hat will slow down a bit your application (you can also override that function in your model to avoid it) and also can generate clashes.
- lose the casts if getAttribute, getAttributeValue or setAttribute methods are used because the property hooks are not called
- see the update from below regarding objects behavior
For more details about how to use the BaseModelAttributes you can read:
- https://marius-ciclistu.medium.com/frozen-model-read-only-dto-with-laravel-crud-wizard-free-6bf274e341fa
- https://marius-ciclistu.medium.com/serializable-property-hooks-in-eloquent-model-without-conflicts-398271774cba3
- Lazy Active Record Segregation Properties
- https://github.com/macropay-solutions/laravel-crud-wizard-free
Disclaimer: You should test this solution in a real world scenario because it might need tweaking for your own use case.
Update 01.11.2025
Object behavior with property hooks:
$carbon = $model->created_at;
$carbon->addDay(); // does not affect the model
$model->created_at = $carbon; // will sync it
Object behavior with eloquent casts:
$carbon = $model->created_at;
$carbon->addDay(); // does affect the model via mergeAttributesFromCachedCasts method
$model->created_at = $carbon; // no need for this unless you set public bool $withoutObjectCaching = true;
// see https://github.com/laravel/framework/discussions/31778#discussioncomment-12399465
This backward compatible PR from Maravel-Framework will improve Eloquent casts and save.
Update 02.11.2025
This backward compatible PR from laravel-crud-wizard-free will improve Eloquent casts and save.
Update 06.11.2025
Accidentally I found another way to freeze the model while improving the casts:
See commit
/**
* Prevent updates
* Note that relations can be loaded and updated during the lock
*/
public function lockUpdates(bool $checkDirty = true): bool
{
if (
!$this->exists
|| $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts !== null
|| ($checkDirty && $this->isDirty())
) {
return false;
}
$this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = [];
return true;
}
/**
* Unlock updates
*
* To reset the model's $attributes and get the changes from dirty applied during the lock use:
*
* if ($this->unlockUpdates()) {
* $dirty = $this->getDirty();
* $this->attributes = $this->original;
* $this->classCastCache = [];
* $this->attributeCastCache = [];
* }
*
* Note that relations can be loaded during the lock
*/
public function unlockUpdates(): bool
{
if ($this->hasUnlockedUpdates()) {
return false;
}
$this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = null;
return true;
}
public function hasUnlockedUpdates(): bool
{
return $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts !== [];
}

Top comments (0)