diff --git a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php index 7591252b5148..2cb6eb1c8da3 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php +++ b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php @@ -537,7 +537,9 @@ public function getAttributeValue($key) */ protected function getAttributeFromArray($key) { - return $this->getAttributes()[$key] ?? null; + $this->mergeAttributeFromCachedCasts($key); + + return $this->attributes[$key] ?? null; } /** @@ -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) + { + $this->mergeAttributeFromClassCasts($key); + $this->mergeAttributeFromAttributeCasts($key); + } + /** * Merge the cast class attributes back into the model. * @@ -1904,15 +1917,26 @@ protected function mergeAttributesFromCachedCasts() protected function mergeAttributesFromClassCasts() { foreach ($this->classCastCache as $key => $value) { - $caster = $this->resolveCasterClass($key); + $this->mergeAttributeFromClassCasts($key); + } + } - $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)) + ); } /** @@ -1923,23 +1947,34 @@ protected function mergeAttributesFromClassCasts() protected function mergeAttributesFromAttributeCasts() { foreach ($this->attributeCastCache as $key => $value) { - $attribute = $this->{Str::camel($key)}(); + $this->mergeAttributeFromAttributeCasts($key); + } + } - 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) + ) + ); } /** diff --git a/tests/Integration/Database/DatabaseEloquentModelCustomCastingTest.php b/tests/Integration/Database/DatabaseEloquentModelCustomCastingTest.php index e9a4fbab68e4..4a7825f62d86 100644 --- a/tests/Integration/Database/DatabaseEloquentModelCustomCastingTest.php +++ b/tests/Integration/Database/DatabaseEloquentModelCustomCastingTest.php @@ -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 @@ -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 diff --git a/tests/Integration/Database/EloquentModelEncryptedCastingTest.php b/tests/Integration/Database/EloquentModelEncryptedCastingTest.php index 0c87c7440022..7c637a756cba 100644 --- a/tests/Integration/Database/EloquentModelEncryptedCastingTest.php +++ b/tests/Integration/Database/EloquentModelEncryptedCastingTest.php @@ -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') @@ -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') @@ -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')