diff --git a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php index 9f266da74881..978b29262f02 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php +++ b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php @@ -42,6 +42,7 @@ use ReflectionNamedType; use RuntimeException; use Stringable; +use Throwable; use ValueError; use function Illuminate\Support\enum_value; @@ -221,15 +222,23 @@ public function attributesToArray() $attributes = $this->getArrayableAttributes() ); + $mutatedAttributes = array_values( + array_filter(array_keys($attributes), function ($key) { + return $this->hasAnyGetMutator($key); + }) + ); + $attributes = $this->addMutatedAttributesToArray( - $attributes, $mutatedAttributes = $this->getMutatedAttributes() + $attributes, + $mutatedAttributes, ); // Next we will handle any casts that have been setup for this model and cast // the values to their appropriate type. If the attribute has a mutator we // will not perform the cast on those attributes to avoid any confusion. $attributes = $this->addCastAttributesToArray( - $attributes, $mutatedAttributes + $attributes, + $mutatedAttributes ); // Here we will grab all of the appended, calculated attributes to this model @@ -687,7 +696,13 @@ public function hasAttributeGetMutator($key) return static::$getAttributeMutatorCache[get_class($this)][$key] = false; } - return static::$getAttributeMutatorCache[get_class($this)][$key] = is_callable($this->{Str::camel($key)}()->get); + try { + $attribute = $this->{Str::camel($key)}(); + + return static::$getAttributeMutatorCache[get_class($this)][$key] = is_callable($attribute->get); + } catch (Throwable $e) { + return static::$getAttributeMutatorCache[get_class($this)][$key] = false; + } } /** @@ -752,8 +767,7 @@ protected function mutateAttributeForArray($key, $value) { if ($this->isClassCastable($key)) { $value = $this->getClassCastableAttributeValue($key, $value); - } elseif (isset(static::$getAttributeMutatorCache[get_class($this)][$key]) && - static::$getAttributeMutatorCache[get_class($this)][$key] === true) { + } elseif ($this->hasAttributeGetMutator($key)) { $value = $this->mutateAttributeMarkedAttribute($key, $value); $value = $value instanceof DateTimeInterface @@ -2449,8 +2463,12 @@ public static function cacheMutatedAttributes($classOrInstance) $class = $reflection->getName(); - static::$getAttributeMutatorCache[$class] = (new Collection($attributeMutatorMethods = static::getAttributeMarkedMutatorMethods($classOrInstance))) - ->mapWithKeys(fn ($match) => [lcfirst(static::$snakeAttributes ? Str::snake($match) : $match) => true]) + $attributeMutatorMethods = static::getAttributeMarkedMutatorMethods($classOrInstance); + + static::$getAttributeMutatorCache[$class] = (new Collection($attributeMutatorMethods)) + ->mapWithKeys(fn ($match) => [ + lcfirst(static::$snakeAttributes ? Str::snake($match) : $match) => true, + ]) ->all(); static::$mutatorCache[$class] = (new Collection(static::getMutatorMethods($class))) diff --git a/tests/Database/DatabaseEloquentModelTest.php b/tests/Database/DatabaseEloquentModelTest.php index dd724ac2e389..8ca73d515b07 100755 --- a/tests/Database/DatabaseEloquentModelTest.php +++ b/tests/Database/DatabaseEloquentModelTest.php @@ -3546,6 +3546,144 @@ public function testDefaultBuilderIsUsedWhenUseEloquentBuilderAttributeIsNotPres $this->assertNotInstanceOf(CustomBuilder::class, $eloquentBuilder); } + + public function testAccessorsNotCalledForNonVisibleAttributes() + { + $model = new class extends Model + { + protected $visible = ['id', 'name']; + protected $attributes = ['id' => 1, 'name' => 'Test']; + + public $locationAccessorCalled = false; + public $timezoneAccessorCalled = false; + + protected function location(): Attribute + { + $this->locationAccessorCalled = true; + + return Attribute::make( + get: fn () => 'Paris', + ); + } + + protected function timezone(): Attribute + { + $this->timezoneAccessorCalled = true; + + return Attribute::make( + get: fn () => 'Europe/Paris', + ); + } + }; + + $array = $model->toArray(); + + $this->assertFalse($model->locationAccessorCalled, 'Location accessor should not be called for non-visible attributes'); + $this->assertFalse($model->timezoneAccessorCalled, 'Timezone accessor should not be called for non-visible attributes'); + $this->assertArrayNotHasKey('location', $array); + $this->assertArrayNotHasKey('timezone', $array); + $this->assertEquals(['id' => 1, 'name' => 'Test'], $array); + } + + public function testAccessorsCalledForVisibleAttributes() + { + $model = new class extends Model + { + protected $visible = ['id', 'name', 'location']; + protected $attributes = ['id' => 1, 'name' => 'Test', 'location' => 'original']; + + public $locationAccessorCalled = false; + + protected function location(): Attribute + { + $this->locationAccessorCalled = true; + + return Attribute::make( + get: fn ($value) => 'Paris', + ); + } + }; + + $array = $model->toArray(); + + $this->assertTrue($model->locationAccessorCalled, 'Location accessor should be called for visible attributes'); + $this->assertArrayHasKey('location', $array); + $this->assertEquals('Paris', $array['location']); + $this->assertEquals(['id' => 1, 'name' => 'Test', 'location' => 'Paris'], $array); + } + + public function testAccessorsNotCalledForHiddenAttributes() + { + $model = new class extends Model + { + protected $hidden = ['location']; + protected $attributes = ['id' => 1, 'name' => 'Test', 'location' => 'original']; + + public $locationAccessorCalled = false; + + protected function location(): Attribute + { + $this->locationAccessorCalled = true; + + return Attribute::make( + get: fn ($value) => 'Paris', + ); + } + }; + + $array = $model->toArray(); + + $this->assertFalse($model->locationAccessorCalled, 'Location accessor should not be called for hidden attributes'); + $this->assertArrayNotHasKey('location', $array); + $this->assertEquals(['id' => 1, 'name' => 'Test'], $array); + } + + public function testSetOnlyAttributeMutatorDoesNotBreakSerialization() + { + $model = new class extends Model + { + protected $visible = ['id', 'name', 'foo']; + protected $attributes = ['id' => 1, 'name' => 'Test', 'foo' => 'ORIGINAL']; + + protected function foo(): Attribute + { + return Attribute::make( + set: function ($value) { + return ['foo' => strtolower($value)]; + } + ); + } + }; + + // Set the attribute using the set mutator + $model->foo = 'BAR'; + + $array = $model->toArray(); + + // Should not crash and should still include the "foo" attribute. + $this->assertArrayHasKey('foo', $array); + } + + public function testHiddenAttributeAccessorIsNotInvokedDuringSerialization() + { + $model = new class extends Model + { + protected $hidden = ['dangerous']; + + protected function dangerous(): Attribute + { + return Attribute::make( + get: function () { + throw new \RuntimeException('Hidden attribute accessor should not be invoked'); + } + ); + } + }; + + $array = $model->toArray(); + + $this->assertArrayNotHasKey('dangerous', $array); + } } class CustomBuilder extends Builder