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
53 changes: 39 additions & 14 deletions src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php
Original file line number Diff line number Diff line change
Expand Up @@ -672,17 +672,19 @@ public function hasGetMutator($key)
*/
public function hasAttributeMutator($key)
{
if (isset(static::$attributeMutatorCache[get_class($this)][$key])) {
return static::$attributeMutatorCache[get_class($this)][$key];
$normalizedKey = $this->normalizeAttributeMutatorKey($key);

if (isset(static::$attributeMutatorCache[get_class($this)][$normalizedKey])) {
return static::$attributeMutatorCache[get_class($this)][$normalizedKey];
}

if (! method_exists($this, $method = Str::camel($key))) {
return static::$attributeMutatorCache[get_class($this)][$key] = false;
return static::$attributeMutatorCache[get_class($this)][$normalizedKey] = false;
}

$returnType = (new ReflectionMethod($this, $method))->getReturnType();

return static::$attributeMutatorCache[get_class($this)][$key] =
return static::$attributeMutatorCache[get_class($this)][$normalizedKey] =
$returnType instanceof ReflectionNamedType &&
$returnType->getName() === Attribute::class;
}
Expand All @@ -695,15 +697,17 @@ public function hasAttributeMutator($key)
*/
public function hasAttributeGetMutator($key)
{
if (isset(static::$getAttributeMutatorCache[get_class($this)][$key])) {
return static::$getAttributeMutatorCache[get_class($this)][$key];
$normalizedKey = $this->normalizeAttributeMutatorKey($key);

if (isset(static::$getAttributeMutatorCache[get_class($this)][$normalizedKey])) {
return static::$getAttributeMutatorCache[get_class($this)][$normalizedKey];
}

if (! $this->hasAttributeMutator($key)) {
return static::$getAttributeMutatorCache[get_class($this)][$key] = false;
return static::$getAttributeMutatorCache[get_class($this)][$normalizedKey] = false;
}

return static::$getAttributeMutatorCache[get_class($this)][$key] = is_callable($this->{Str::camel($key)}()->get);
return static::$getAttributeMutatorCache[get_class($this)][$normalizedKey] = is_callable($this->{Str::camel($key)}()->get);
}

/**
Expand Down Expand Up @@ -770,10 +774,12 @@ protected function mutateAttributeMarkedAttribute($key, $value)
*/
protected function mutateAttributeForArray($key, $value)
{
$normalizedKey = $this->normalizeAttributeMutatorKey($key);

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 (isset(static::$getAttributeMutatorCache[get_class($this)][$normalizedKey]) &&
static::$getAttributeMutatorCache[get_class($this)][$normalizedKey] === true) {
$value = $this->mutateAttributeMarkedAttribute($key, $value);

$value = $value instanceof DateTimeInterface
Expand All @@ -786,6 +792,24 @@ protected function mutateAttributeForArray($key, $value)
return $value instanceof Arrayable ? $value->toArray() : $value;
}

/**
* Normalize an attribute key to match the format used in the mutator cache.
*
* This handles cases where multiple snake_case forms map to the same camelCase
* method (e.g., both "foo_1_bar" and "foo1_bar" map to "foo1Bar").
*
* @param string $key
* @return string
*/
protected function normalizeAttributeMutatorKey($key)
{
if (! preg_match('/\d/', $key)) {
return $key;
}

return lcfirst(static::$snakeAttributes ? Str::snake(Str::camel($key)) : Str::camel($key));
}

/**
* Merge new casts with existing casts on the model.
*
Expand Down Expand Up @@ -1153,18 +1177,19 @@ public function hasSetMutator($key)
public function hasAttributeSetMutator($key)
{
$class = get_class($this);
$normalizedKey = $this->normalizeAttributeMutatorKey($key);

if (isset(static::$setAttributeMutatorCache[$class][$key])) {
return static::$setAttributeMutatorCache[$class][$key];
if (isset(static::$setAttributeMutatorCache[$class][$normalizedKey])) {
return static::$setAttributeMutatorCache[$class][$normalizedKey];
}

if (! method_exists($this, $method = Str::camel($key))) {
return static::$setAttributeMutatorCache[$class][$key] = false;
return static::$setAttributeMutatorCache[$class][$normalizedKey] = false;
}

$returnType = (new ReflectionMethod($this, $method))->getReturnType();

return static::$setAttributeMutatorCache[$class][$key] =
return static::$setAttributeMutatorCache[$class][$normalizedKey] =
$returnType instanceof ReflectionNamedType &&
$returnType->getName() === Attribute::class &&
is_callable($this->{$method}()->set);
Expand Down
30 changes: 30 additions & 0 deletions tests/Database/DatabaseEloquentModelTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3818,6 +3818,26 @@ public function testDefaultBuilderIsUsedWhenUseEloquentBuilderAttributeIsNotPres

$this->assertNotInstanceOf(CustomBuilder::class, $eloquentBuilder);
}

public function testAccessorAttributeNameConversionWithAlternateSnakeCaseForm()
{
$model = new EloquentModelWithNumericAccessorStub;

// Both snake_case forms should resolve to the same camelCase method
$this->assertSame('yay', $model->foo1_bar);
$this->assertSame('yay', $model->foo_1_bar);

// Appending via the canonical snake form should work
$model->append('foo1_bar');
$array = $model->toArray();
$this->assertSame('yay', $array['foo1_bar']);

// Appending via the alternate snake form should also work
$model2 = new EloquentModelWithNumericAccessorStub;
$model2->append('foo_1_bar');
$array2 = $model2->toArray();
$this->assertSame('yay', $array2['foo_1_bar']);
}
}

class CustomBuilder extends Builder
Expand Down Expand Up @@ -4733,6 +4753,16 @@ public function __toString()
}
}

class EloquentModelWithNumericAccessorStub extends Model
{
protected function foo1Bar(): Attribute
{
return Attribute::make(
get: fn () => 'yay',
);
}
}

enum ConnectionName
{
case Foo;
Expand Down
Loading