Skip to content
32 changes: 25 additions & 7 deletions src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
use ReflectionNamedType;
use RuntimeException;
use Stringable;
use Throwable;
use ValueError;

use function Illuminate\Support\enum_value;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
}

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)))
Expand Down
138 changes: 138 additions & 0 deletions tests/Database/DatabaseEloquentModelTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down