From 21c2479439687a0e778c8867e65fe79cf8de4eb7 Mon Sep 17 00:00:00 2001 From: Josh Salway Date: Thu, 19 Mar 2026 05:14:38 +1000 Subject: [PATCH 1/3] fix: normalize attribute mutator cache keys for consistent name resolution When using the Attribute syntax (e.g. foo1Bar(): Attribute), multiple snake_case forms like "foo_1_bar" and "foo1_bar" both map to the same camelCase method "foo1Bar". However, only "foo1_bar" was stored in the mutator cache (from Str::snake('foo1Bar')). This caused append('foo_1_bar')->toArray() to fail with "Call to undefined method getFoo1BarAttribute()" because the cache lookup for "foo_1_bar" missed, and it fell through to the legacy accessor path. The fix normalizes cache keys by round-tripping through camel->snake conversion, ensuring that alternate snake_case forms resolve to the same cache entry. Fixes #54570 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Eloquent/Concerns/HasAttributes.php | 49 +++++++++++++------ 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php index a71fcc416cf3..88aa19b30e8f 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php +++ b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php @@ -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; } @@ -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); } /** @@ -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 @@ -786,6 +792,20 @@ 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) + { + return lcfirst(static::$snakeAttributes ? Str::snake(Str::camel($key)) : Str::camel($key)); + } + /** * Merge new casts with existing casts on the model. * @@ -1153,18 +1173,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); From 1d325746e834ebcef77f1a2e7e0417214ce03f0e Mon Sep 17 00:00:00 2001 From: Josh Salway Date: Thu, 19 Mar 2026 05:19:05 +1000 Subject: [PATCH 2/3] test: add test for accessor attribute name conversion with numeric segments Verifies that both "foo1_bar" and "foo_1_bar" forms correctly resolve to the same Attribute accessor when used with append() and toArray(). Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/Database/DatabaseEloquentModelTest.php | 30 ++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/Database/DatabaseEloquentModelTest.php b/tests/Database/DatabaseEloquentModelTest.php index 2ccf8f74b4e9..ac25daf51b87 100755 --- a/tests/Database/DatabaseEloquentModelTest.php +++ b/tests/Database/DatabaseEloquentModelTest.php @@ -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 @@ -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; From 9817c9c4a5ae857cc51a4d08d86d73bc727afaf3 Mon Sep 17 00:00:00 2001 From: Josh Salway Date: Sat, 28 Mar 2026 14:43:47 +1000 Subject: [PATCH 3/3] Add fast-path short-circuit for attribute keys without digits Skip expensive Str::camel/Str::snake normalization for the 99% of attribute keys that contain no digits, since the ambiguity this method resolves (e.g. foo_1_bar vs foo1_bar) only exists when digits are present. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php index 88aa19b30e8f..1c9e9ac0df1f 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php +++ b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php @@ -803,6 +803,10 @@ protected function mutateAttributeForArray($key, $value) */ protected function normalizeAttributeMutatorKey($key) { + if (! preg_match('/\d/', $key)) { + return $key; + } + return lcfirst(static::$snakeAttributes ? Str::snake(Str::camel($key)) : Str::camel($key)); }