Skip to content

Inconsistent Accessor Attribute Name Conversion Solution#54793

Closed
ksaif534 wants to merge 1 commit intolaravel:11.xfrom
ksaif534:inconsistent-accessor-name-conversion
Closed

Inconsistent Accessor Attribute Name Conversion Solution#54793
ksaif534 wants to merge 1 commit intolaravel:11.xfrom
ksaif534:inconsistent-accessor-name-conversion

Conversation

@ksaif534
Copy link
Copy Markdown

Hello.

This PR is a solution to the issue https://github.com/laravel/framework/issues/54570 where a custom model attribute is converted into different types of snake cases from camel cases. For example, a custom model attribute foo1Bar is converted into both the normal foo1_bar & foo_1_bar where the number is between the two underscores(_).

Purpose

Developers very often use custom attribute accesors to access existing model attributes & their values. The custom attribute names(methods) are usually written in camel cases inside the Eloquent Model. However, they're usually accessed as snake cases because database table column names are usually accessed as snake cases. Hence it's quite convenient for developers if the camel case name in the Eloquent Model is converted into all types of snake cases when their values are accessed.

Usage

This is how we can use the functionality. First make a custom attribute in the Model:

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

Then I can append the snake case, convert it to array and access the snake case like this:

echo $model->foo1_bar; // "yay"
$model->append('foo1_bar');
$model->toArray(); // Works fine.
echo $model['foo1_bar']; //value

echo $model->foo_1_bar; // "yay"
$model->append('foo_1_bar');
$model->toArray(); // Works fine
echo $model['foo_1_bar']; //value

Code Changes Explanation

In the following, I show the file HasAttributes.php changes inside the Illuminate/Database/Eloquent/Concerns directory and the Integration test file DatabaseEloquentModelAttributeCastingTest.php inside the tests/Integration/Database directory:

HasAttributes.php

public static function cacheMutatedAttributes($classOrInstance)
    {
        $reflection = new ReflectionClass($classOrInstance);

        $class = $reflection->getName();

        static::$getAttributeMutatorCache[$class] = (new Collection($attributeMutatorMethods = static::getAttributeMarkedMutatorMethods($classOrInstance)))
            ->mapWithKeys(function ($match) {
                $standardSnake = lcfirst(static::$snakeAttributes ? Str::snake($match) : $match);
                $alternativeSnake = preg_replace('/(\d+)/', '_$1_', $standardSnake);
                $alternativeSnake = str_replace('__', '_', $alternativeSnake);

                return [
                    $standardSnake => true,
                    $alternativeSnake => true
                ];
            })
            ->all();

        static::$mutatorCache[$class] = (new Collection(static::getMutatorMethods($class)))
            ->merge($attributeMutatorMethods)
            ->map(fn ($match) => lcfirst(static::$snakeAttributes ? Str::snake($match) : $match))
            ->all();
    }

Here,

$standardSnake = lcfirst(static::$snakeAttributes ? Str::snake($match) : $match); converts the attribute name to snake_case if $snakeAttributes is true & lcfirst makes the first character lowercase.

$alternativeSnake = preg_replace('/(\d+)/', '_$1_', $standardSnake); Takes any numbers in the string and wraps them with underscores.

$alternativeSnake = str_replace('__', '_', $alternativeSnake); replaces any double underscores with single underscores.

This is the Integration Test File:

DatabaseEloquentModelAttributeCastingTest.php

public function testInconsistentAccessorAttributeNameConversion()
    {
        $model = new TestEloquentModelWithAttributeCast;

        $model->append('foo1_bar');

        $model->append('foo_1_bar');

        $model->append('foo12_bar');

        $model->append('foo_12_bar');

        $arr = $model->toArray();

        $this->assertTrue(isset($arr['foo_1_bar']));
        $this->assertTrue(isset($arr['foo_12_bar']));
        $this->assertTrue(isset($arr['foo1_bar']));
        $this->assertTrue(isset($arr['foo12_bar']));
        $this->assertEquals($arr['foo_1_bar'],'yay');
        $this->assertEquals($arr['foo_12_bar'],'another yay');
    }

Inside the TestEloquentModelWithAttributeCast Class:

public function foo1Bar(): Attribute
    {
        return new Attribute(
            function () {
                return "yay";
            }
        );
    }

    public function foo12Bar(): Attribute
    {
        return new Attribute(
            function () {
                return "another yay";
            }
        );
    }

This checks whether both type of snake case conversions from camel case are done when you run ./vendor/bin/phpunit tests/Integration/Database/DatabaseEloquentModelAttributeCastingTest.php --filter testInconsistentAccessorAttributeNameConversion

Wrap up:

Overall, I think it is very handy if all types of snake case names in custom model attributes can be used when their values are accessed. I'd like to hear your thoughts.

Regards,
Saif

@taylorotwell
Copy link
Copy Markdown
Member

I frankly don't understand what this is trying to solve. Sorry!

@rodrigopedra
Copy link
Copy Markdown
Contributor

rodrigopedra commented Feb 25, 2025

@taylorotwell I also struggled a bit to understand the explanation, and then I ran the test case locally and figured it out.

Currently, having the attribute defined as an accessor allows one to access it either by foo1_bar or foo_1_bar.

But, again currently, if one calls Model@append() so this accessor is serialized by Model@toArray() only works for foo1_bar, but fails for foo_1_bar.

This PR would allow both snake case forms to work when appending that accessor for serialization.

Here is some test code you can test on a current Laravel 12 application:

<?php // ./routes/console.php

use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Artisan;

Artisan::command('app:test', function () {
    $model = new class() extends Model {
        protected function foo1Bar(): Attribute
        {
            return Attribute::get(fn () => 'yay');
        }
    };

    // both `foo1_bar` and `foo_1_bar` work as accessors
    dump($model->foo1_bar, $model->foo_1_bar);

    // appending the attribute as `foo1_bar` works
    $model->append('foo1_bar');
    dump($model->toArray());

    // appending the attribute as `foo_1_bar` doesn't
    $model->append('foo_1_bar');
    dump($model->toArray()); // error!
});

By the way, already migrated 3 of my apps to Laravel 12 and all went smoothly! Thanks!


EDIT: adding the console output for convenience:

$ php artisan app:test
"yay" // routes/console.php:16
"yay" // routes/console.php:16
array:1 [
  "foo1_bar" => "yay"
] // routes/console.php:20

   BadMethodCallException 

  Call to undefined method Illuminate\Database\Eloquent\Model@anonymous\/home/rodrigo/code/playground/issue-54771/routes/console.php:8$9b::getFoo1BarAttribute()

  at vendor/laravel/framework/src/Illuminate/Support/Traits/ForwardsCalls.php:67
     63▕      * @throws \BadMethodCallException
     64▕      */
     65▕     protected static function throwBadMethodCallException($method)
     66▕     {
  ➜  67▕         throw new BadMethodCallException(sprintf(
     68▕             'Call to undefined method %s::%s()', static::class, $method
     69▕         ));
     70▕     }
     71▕ }

      +9 vendor frames 

  10  routes/console.php:24
      Illuminate\Database\Eloquent\Model::toArray()
      +13 vendor frames 

  24  artisan:16
      Illuminate\Foundation\Application::handleCommand()

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants