Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 56 additions & 21 deletions src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php
Original file line number Diff line number Diff line change
Expand Up @@ -537,7 +537,9 @@ public function getAttributeValue($key)
*/
protected function getAttributeFromArray($key)
Copy link
Author

@ug-christoph ug-christoph Nov 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of calling

return $this->getAttributes()[$key] ?? null;

I do exactly what getAttributes does, but instead of calling mergeAttributesFromCachedCasts
I call the new
mergeAttributeFromCachedCasts($key) so it only does it for the attribute I am currently trying to get

{
return $this->getAttributes()[$key] ?? null;
$this->mergeAttributeFromCachedCasts($key);

return $this->attributes[$key] ?? null;
}

/**
Expand Down Expand Up @@ -1896,6 +1898,17 @@ protected function mergeAttributesFromCachedCasts()
$this->mergeAttributesFromAttributeCasts();
}

/**
* Merge the a cast class and attribute cast attribute back into the model.
*
* @return void
*/
protected function mergeAttributeFromCachedCasts(string $key)
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the same as mergeAttributesFromCachedCasts, but it uses the new extracted methods mergeAttributeFromClassCasts and mergeAttributeFromAttributeCasts that allow to do it for just one attribute

{
$this->mergeAttributeFromClassCasts($key);
$this->mergeAttributeFromAttributeCasts($key);
}

/**
* Merge the cast class attributes back into the model.
*
Expand All @@ -1904,15 +1917,26 @@ protected function mergeAttributesFromCachedCasts()
protected function mergeAttributesFromClassCasts()
{
foreach ($this->classCastCache as $key => $value) {
$caster = $this->resolveCasterClass($key);
$this->mergeAttributeFromClassCasts($key);
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I extracted the code that was inside this loop to a separate method, so I can use it here and also independently for getAttribute

}
}

$this->attributes = array_merge(
$this->attributes,
$caster instanceof CastsInboundAttributes
? [$key => $value]
: $this->normalizeCastClassResponse($key, $caster->set($this, $key, $value, $this->attributes))
);
private function mergeAttributeFromClassCasts(string $key): void
{
if (! isset($this->classCastCache[$key])) {
return;
}

$value = $this->classCastCache[$key];

$caster = $this->resolveCasterClass($key);

$this->attributes = array_merge(
$this->attributes,
$caster instanceof CastsInboundAttributes
? [$key => $value]
: $this->normalizeCastClassResponse($key, $caster->set($this, $key, $value, $this->attributes))
);
}

/**
Expand All @@ -1923,23 +1947,34 @@ protected function mergeAttributesFromClassCasts()
protected function mergeAttributesFromAttributeCasts()
{
foreach ($this->attributeCastCache as $key => $value) {
$attribute = $this->{Str::camel($key)}();
$this->mergeAttributeFromAttributeCasts($key);
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I extracted the code that was inside this loop to a separate method, so I can use it here and also independently for getAttribute

}
}

if ($attribute->get && ! $attribute->set) {
continue;
}
private function mergeAttributeFromAttributeCasts(string $key): void
{
if (! isset($this->attributeCastCache[$key])) {
return;
}

$callback = $attribute->set ?: function ($value) use ($key) {
$this->attributes[$key] = $value;
};
$value = $this->attributeCastCache[$key];

$this->attributes = array_merge(
$this->attributes,
$this->normalizeCastClassResponse(
$key, $callback($value, $this->attributes)
)
);
$attribute = $this->{Str::camel($key)}();

if ($attribute->get && ! $attribute->set) {
return;
}

$callback = $attribute->set ?: function ($value) use ($key) {
$this->attributes[$key] = $value;
};

$this->attributes = array_merge(
$this->attributes,
$this->normalizeCastClassResponse(
$key, $callback($value, $this->attributes)
)
);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,16 @@ public function testSetToUndefinedCast()

$model->undefined_cast_column = 'Glāžšķūņu rūķīši';
}

public function testMutatorCanDependOnAnotherCastedAttribute()
{
$model = new TestEloquentModelWithCustomCast([
'address_line_one' => '110 Kingsbrook St.',
'address_line_two' => 'My Childhood House',
]);
$model->address->lineOne = 'Changed St.';
$this->assertSame('Changed St. (My Childhood House)', $model->address_string);
}
}

class TestEloquentModelWithCustomCast extends Model
Expand Down Expand Up @@ -319,6 +329,26 @@ class TestEloquentModelWithCustomCast extends Model
'anniversary_on_with_object_caching' => DateTimezoneCasterWithObjectCaching::class.':America/New_York',
'anniversary_on_without_object_caching' => DateTimezoneCasterWithoutObjectCaching::class.':America/New_York',
];

/**
* A computed attribute that depends on another casted attribute.
*
* This simulates a mutator that uses the value of a casted property.
*/
protected function addressString(): \Illuminate\Database\Eloquent\Casts\Attribute
{
return \Illuminate\Database\Eloquent\Casts\Attribute::get(function () {
$address = $this->address;

// If mergeAttributesFromClassCasts() hasn't prepared casts properly,
// this could be an array instead of an Address instance.
if (! $address instanceof Address) {
throw new \RuntimeException('Address was not cast before mutator access.');
}

return "{$address->lineOne} ({$address->lineTwo})";
});
}
}

class HashCaster implements CastsInboundAttributes
Expand Down
Copy link
Author

@ug-christoph ug-christoph Nov 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The affected tests are quite brittle (they do an assertions on the exact call count to a method). The PRs goal is to directly reduce those calls. The test is a good example of where the optimization helps. In these tests $subject->id is called which causes mergeAttributesFromCachedCasts to run for the encrypted/casted columns (secret_array, secret_collection). This is now expectedly not happening anymore and I have therefore just reduced the call count in the assertion by one to reflect this improvement

Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ public function testAsEncryptedCollection()
->with('{"key1":"value1"}')
->andReturn('encrypted-secret-collection-string-1');
$this->encrypter->expects('encryptString')
->times(10)
->times(9)
->with('{"key1":"value1","key2":"value2"}')
->andReturn('encrypted-secret-collection-string-2');
$this->encrypter->expects('decryptString')
Expand Down Expand Up @@ -239,7 +239,7 @@ public function testAsEncryptedCollectionMap()
->with('[{"key1":"value1"}]')
->andReturn('encrypted-secret-collection-string-1');
$this->encrypter->expects('encryptString')
->times(12)
->times(11)
->with('[{"key1":"value1"},{"key2":"value2"}]')
->andReturn('encrypted-secret-collection-string-2');
$this->encrypter->expects('decryptString')
Expand Down Expand Up @@ -295,7 +295,7 @@ public function testAsEncryptedArrayObject()
->with('encrypted-secret-array-string-1')
->andReturn('{"key1":"value1"}');
$this->encrypter->expects('encryptString')
->times(10)
->times(9)
->with('{"key1":"value1","key2":"value2"}')
->andReturn('encrypted-secret-array-string-2');
$this->encrypter->expects('decryptString')
Expand Down
Loading