Laravel10.xのアクセサの記述がlaravel-ide-helperで補完可能なプロパティとして生えない件について備忘録

Laravelのモデルクラスに記述できるアクセサについて[IDE Helper Generator for Laravel(以後、laravel-ide-helper)](https://github.com/barryvdh/laravel-ide-helper)を使用したときに期待するPHPDocが生えないことがあり調査しました。

## アクセサについておさらい
- Laravel8.xの頃とLaravel9.x以降でドキュメントに書いてあるアクセサの書き方が異なる
  - 8.xの頃は`get〇〇Attribute`のルールに則ったメソッドを定義し、任意の値を戻り値として返す
  - 9.x以降は`\Illuminate\Database\Eloquent\Casts\Attribute`のインスタンスを返す
    - 9.x以降のLaravelで8.xの頃のアクセサを記述することは可能
    - `Attribute::make()`のgetパラメータにセットする関数でアクセサとしてアクセスさせたい任意の値を戻り値として返す

```php:8.xのアクセサ
use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    /**
     * ユーザーの名前の取得
     *
     * @param  string  $value
     * @return string
     */
    public function getFirstNameAttribute($value)
    {
        return ucfirst($value);
    }
}
```

```php:9.x以降のアクセサ
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    /**
     * ユーザーの名前の取得
     */
    protected function firstName(): Attribute
    {
        return Attribute::make(
            get: fn (string $value) => ucfirst($value),
        );
    }
}
```

## 問題点
- Laravel10.xにおいて現形式のアクセサの記述を行った場合、PHPDocに期待する`@property-read`のプロパティが生えない
- Laravel8.xの頃のget〇〇Attributeの記述を行った場合、PHPDocに期待する`@property-read`のプロパティが生える

## 検証
### 検証内容について
`users`テーブルを使用しnameとemailの文字列を連結して返すようなアクセサについて`get〇〇Attribute`のアクセサと`Attribute`インスタンスを返すアクセサの両方で作成して検証を行いました。
```php:2014_10_12_000000_create_users_table.php
Schema::create('users', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->string('email')->unique();
    $table->timestamp('email_verified_at')->nullable();
    $table->string('password');
    $table->rememberToken();
    $table->timestamps();
});
```
```diff_php:User.php
<?php

namespace App\Models;

// use Illuminate\Contracts\Auth\MustVerifyEmail;

use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
    // use宣言、$fillable、$hidden、$castsは省略

+    /**
+     * @comment ユーザー名とパスワードを連結して取得1
+     *
+     * @return string
+     */
+    public function getNameAndEmail1Attribute(): string
+    {
+        return "{$this->name} {$this->email}";
+    }
+
+    /**
+     * @comment ユーザー名とパスワードを連結して取得2
+     *
+     * @return \Illuminate\Database\Eloquent\Casts\Attribute
+     */
+    protected function nameAndEmail2(): Attribute
+    {
+        return Attribute::make(
+            get: fn () => "{$this->name} {$this->email}",
+        );
+    }
}
```

### 検証1 laravel-ide-helperの最新のリリースで検証
まず初めに、2024/01/30現在の最新のリリース([v2.13](https://github.com/barryvdh/laravel-ide-helper/releases/tag/v2.13.0))で検証しました
```bash
$ composer require --dev barryvdh/laravel-ide-helper:^2.13
```

`php artisan ide-helper:models --write --reset`を実行したところUserモデルクラスの先頭に生えたPHPDocは以下のようになりました。
```diff_php:User.php
<?php

namespace App\Models;

// use Illuminate\Contracts\Auth\MustVerifyEmail;

use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;

+/**
+ * App\Models\User
+ *
+ * @property int $id
+ * @property string $name
+ * @property string $email
+ * @property \Illuminate\Support\Carbon|null $email_verified_at
+ * @property mixed $password
+ * @property string|null $remember_token
+ * @property \Illuminate\Support\Carbon|null $created_at
+ * @property \Illuminate\Support\Carbon|null $updated_at
+ * @property-read string $name_and_email1 ユーザー名とパスワードを連結して取得1
+ * @property-read \Illuminate\Notifications\DatabaseNotificationCollection<int, \Illuminate\Notifications\DatabaseNotification> $notifications
+ * @property-read int|null $notifications_count
+ * @property-read \Illuminate\Database\Eloquent\Collection<int, \Laravel\Sanctum\PersonalAccessToken> $tokens
+ * @property-read int|null $tokens_count
+ * @method static \Database\Factories\UserFactory factory($count = null, $state = [])
+ * @method static \Illuminate\Database\Eloquent\Builder|User newModelQuery()
+ * @method static \Illuminate\Database\Eloquent\Builder|User newQuery()
+ * @method static \Illuminate\Database\Eloquent\Builder|User query()
+ * @method static \Illuminate\Database\Eloquent\Builder|User whereCreatedAt($value)
+ * @method static \Illuminate\Database\Eloquent\Builder|User whereEmail($value)
+ * @method static \Illuminate\Database\Eloquent\Builder|User whereEmailVerifiedAt($value)
+ * @method static \Illuminate\Database\Eloquent\Builder|User whereId($value)
+ * @method static \Illuminate\Database\Eloquent\Builder|User whereName($value)
+ * @method static \Illuminate\Database\Eloquent\Builder|User wherePassword($value)
+ * @method static \Illuminate\Database\Eloquent\Builder|User whereRememberToken($value)
+ * @method static \Illuminate\Database\Eloquent\Builder|User whereUpdatedAt($value)
+ * @mixin \Eloquent
+ */
class User extends Authenticatable
{
    // use宣言、$fillable、$hidden、$castsは省略

    /**
     * @comment ユーザー名とパスワードを連結して取得1
     *
     * @return string
     */
    public function getNameAndEmail1Attribute(): string
    {
        return "{$this->name} {$this->email}";
    }

    /**
     * @comment ユーザー名とパスワードを連結して取得2
     *
     * @return \Illuminate\Database\Eloquent\Casts\Attribute
     */
    protected function nameAndEmail2(): Attribute
    {
        return Attribute::make(
            get: fn () => "{$this->name} {$this->email}",
        );
    }
}
```
- 8.xの頃の形式のアクセサを元にした`@property-read string $name_and_email1 ユーザー名とパスワードを連結して取得1`というPHPDocが生成されていることがわかります。
- また、9.x以降の形式のアクセサを元にしたPHPDocが生成されていないこともわかります。

### 検証2 最新のmasterブランチのコミットIDで検証
2024/01/30現在の最新のmasterのコミットID([726e5955786969b7c650d989657b55c12d4521e1](https://github.com/barryvdh/laravel-ide-helper/tree/726e5955786969b7c650d989657b55c12d4521e1)の状態をcomposer経由でインストール、検証しました。
```bash
$ composer require --dev barryvdh/laravel-ide-helper:dev-master#726e5955786969b7c650d989657b55c12d4521e1

単純に最新のmasterをインストールしたいときは以下(↑は今回の記事用)
$ composer require --dev barryvdh/laravel-ide-helper:dev-master
```

`php artisan ide-helper:models --write --reset`を実行したところUserモデルクラスの先頭に生えたPHPDocは以下のようになりました。
```diff_php:User.php
<?php

namespace App\Models;

// use Illuminate\Contracts\Auth\MustVerifyEmail;

use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;

/**
 * App\Models\User
 *
 * @property int $id
 * @property string $name
 * @property string $email
 * @property \Illuminate\Support\Carbon|null $email_verified_at
 * @property mixed $password
 * @property string|null $remember_token
 * @property \Illuminate\Support\Carbon|null $created_at
 * @property \Illuminate\Support\Carbon|null $updated_at
 * @property-read string $name_and_email1 ユーザー名とパスワードを連結して取得1
 * @property-read \Illuminate\Notifications\DatabaseNotificationCollection<int, \Illuminate\Notifications\DatabaseNotification> $notifications
 * @property-read int|null $notifications_count
 * @property-read \Illuminate\Database\Eloquent\Collection<int, \Laravel\Sanctum\PersonalAccessToken> $tokens
 * @property-read int|null $tokens_count
 * @method static \Database\Factories\UserFactory factory($count = null, $state = [])
 * @method static \Illuminate\Database\Eloquent\Builder|User newModelQuery()
 * @method static \Illuminate\Database\Eloquent\Builder|User newQuery()
 * @method static \Illuminate\Database\Eloquent\Builder|User query()
 * @method static \Illuminate\Database\Eloquent\Builder|User whereCreatedAt($value)
 * @method static \Illuminate\Database\Eloquent\Builder|User whereEmail($value)
 * @method static \Illuminate\Database\Eloquent\Builder|User whereEmailVerifiedAt($value)
 * @method static \Illuminate\Database\Eloquent\Builder|User whereId($value)
 * @method static \Illuminate\Database\Eloquent\Builder|User whereName($value)
 * @method static \Illuminate\Database\Eloquent\Builder|User wherePassword($value)
 * @method static \Illuminate\Database\Eloquent\Builder|User whereRememberToken($value)
 * @method static \Illuminate\Database\Eloquent\Builder|User whereUpdatedAt($value)
 * @mixin \Eloquent
 */
class User extends Authenticatable
{
    // use宣言、$fillable、$hidden、$castsは省略

    /**
     * @comment ユーザー名とパスワードを連結して取得1
     *
     * @return string
     */
    public function getNameAndEmail1Attribute(): string
    {
        return "{$this->name} {$this->email}";
    }

    /**
     * @comment ユーザー名とパスワードを連結して取得2
     *
     * @return \Illuminate\Database\Eloquent\Casts\Attribute
     */
    protected function nameAndEmail2(): Attribute
    {
        return Attribute::make(
            get: fn () => "{$this->name} {$this->email}",
        );
    }
}
```
- 検証1の頃と比べて変化なし

### 検証3 `Attribute::make()`のgetパラメータにセットする関数に戻り値の型を明示して検証
Userモデルに追加した現形式のアクセサについて以下のように修正
```diff_php:User.php
class User extends Authenticatable
{
    /**
     * @comment ユーザー名とパスワードを連結して取得2
     *
     * @return \Illuminate\Database\Eloquent\Casts\Attribute
     */
    protected function nameAndEmail2(): Attribute
    {
        return Attribute::make(
-            get: fn () => "{$this->name} {$this->email}",
+            get: fn (): string => "{$this->name} {$this->email}",
        );
    }
}
```

`php artisan ide-helper:models --write --reset`を実行したところUserモデルクラスの先頭に生えたPHPDocは以下のようになりました。
```diff_php:User.php
<?php

namespace App\Models;

// use Illuminate\Contracts\Auth\MustVerifyEmail;

use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;

/**
 * App\Models\User
 *
 * @property int $id
 * @property string $name
 * @property string $email
 * @property \Illuminate\Support\Carbon|null $email_verified_at
 * @property mixed $password
 * @property string|null $remember_token
 * @property \Illuminate\Support\Carbon|null $created_at
 * @property \Illuminate\Support\Carbon|null $updated_at
 * @property-read string $name_and_email1 ユーザー名とパスワードを連結して取得1
+ * @property-read string $name_and_email2 ユーザー名とパスワードを連結して取得2
 * @property-read \Illuminate\Notifications\DatabaseNotificationCollection<int, \Illuminate\Notifications\DatabaseNotification> $notifications
 * @property-read int|null $notifications_count
 * @property-read \Illuminate\Database\Eloquent\Collection<int, \Laravel\Sanctum\PersonalAccessToken> $tokens
 * @property-read int|null $tokens_count
 * @method static \Database\Factories\UserFactory factory($count = null, $state = [])
 * @method static \Illuminate\Database\Eloquent\Builder|User newModelQuery()
 * @method static \Illuminate\Database\Eloquent\Builder|User newQuery()
 * @method static \Illuminate\Database\Eloquent\Builder|User query()
 * @method static \Illuminate\Database\Eloquent\Builder|User whereCreatedAt($value)
 * @method static \Illuminate\Database\Eloquent\Builder|User whereEmail($value)
 * @method static \Illuminate\Database\Eloquent\Builder|User whereEmailVerifiedAt($value)
 * @method static \Illuminate\Database\Eloquent\Builder|User whereId($value)
 * @method static \Illuminate\Database\Eloquent\Builder|User whereName($value)
 * @method static \Illuminate\Database\Eloquent\Builder|User wherePassword($value)
 * @method static \Illuminate\Database\Eloquent\Builder|User whereRememberToken($value)
 * @method static \Illuminate\Database\Eloquent\Builder|User whereUpdatedAt($value)
 * @mixin \Eloquent
 */
class User extends Authenticatable
{
    // use宣言、$fillable、$hidden、$castsは省略

    /**
     * @comment ユーザー名とパスワードを連結して取得1
     *
     * @return string
     */
    public function getNameAndEmail1Attribute(): string
    {
        return "{$this->name} {$this->email}";
    }

    /**
     * @comment ユーザー名とパスワードを連結して取得2
     *
     * @return \Illuminate\Database\Eloquent\Casts\Attribute
     */
    protected function nameAndEmail2(): Attribute
    {
        return Attribute::make(
-            get: fn () => "{$this->name} {$this->email}",
+            get: fn (): string => "{$this->name} {$this->email}",
        );
    }
}
```
- 10.xのアクセサを元にした`@property-read string $name_and_email2 ユーザー名とパスワードを連結して取得2`というPHPDocが生成されていることがわかります。

---
なぜ戻り値の型を明示することで解決するのか、そもそもなんでこういう修正をするに至ったかについてはソースの調査が必要でした。
今回は`php artisan ide-helper:models`よりlaravel-ide-helperのmodelsコマンドを使用していることまでは既にわかっているためライブラリ内のConsole/ModelsCommandを調べました。
調べた結果としてModelsCommandについて以下のようなことがわかりました。
- `setProperty`というメソッドを使用して1行のPHPDocコメントに関する情報を作成していること
- `getPropertiesFromMethods`というメソッドの中の処理によってアクセサに関するPHPDocコメントを作成していること
  1. リフレクションによってモデルクラス内の全メソッドを取得(privateなメソッドは除く) 
  2. foreachでループし1件ずつ検証
  3. メソッド名が`get`で始まり`Attribute`で終わるときLaravel8.xの頃のアクセサとして解釈しPHPDocコメントを追加
  4. メソッドの戻り値の型が`\Illuminate\Database\Eloquent\Casts\Attribute`のとき現形式のアクセサとして解釈(`$isAttribute`が`true`)
  5. `$this->getAttributeReturnType($model, $reflection);`によって得られる`$types`についてgetというキーを含むときPHPDocコメントを追加
  
  ※ちなみに↑の`$types`は`\Illuminate\Support\Collection`のコレクション


[vendor/barryvdh/laravel-ide-helper/src/Console/ModelsCommand.phpの抜粋](https://github.com/barryvdh/laravel-ide-helper/blob/726e5955786969b7c650d989657b55c12d4521e1/src/Console/ModelsCommand.php#L645-L652)
```diff_php:vendor/barryvdh/laravel-ide-helper/src/Console/ModelsCommand.php
~~~省略~~~
    public function getPropertiesFromMethods($model)
    {
~~~省略~~~
                $type = $this->getReturnTypeFromReflection($reflection);
                $isAttribute = is_a($type, '\Illuminate\Database\Eloquent\Casts\Attribute', true);
                $method = $reflection->getName();
                if (
                    Str::startsWith($method, 'get') && Str::endsWith(
                        $method,
                        'Attribute'
                    ) && $method !== 'getAttribute'
                ) {
                    //Magic get<name>Attribute
                    $name = Str::snake(substr($method, 3, -9));
                    if (!empty($name)) {
                        $type = $this->getReturnType($reflection);
                        $type = $this->getTypeInModel($model, $type);
                        $comment = $this->getCommentFromDocBlock($reflection);
                        $this->setProperty($name, $type, true, null, $comment);
                    }
                } elseif ($isAttribute) {
                    $name = Str::snake($method);
                    $types = $this->getAttributeReturnType($model, $reflection);
                    $comment = $this->getCommentFromDocBlock($reflection);
+                   info($name);
+                   info($types);
+                   info($comment);

                    if ($types->has('get')) {
                        $type = $this->getTypeInModel($model, $types['get']);
                        $this->setProperty($name, $type, true, null, $comment);
                    }
                } elseif (
~~~省略~~~
```
↑の`info()`を埋め込んだ状態で`php artisan ide-helper:models --write --reset`を実行し、以下のようなログが得られました。
```text:laravel.log(検証3の修正後)
[2024-01-30 05:49:29] production.INFO: name_and_email2  
[2024-01-30 05:49:29] production.INFO: {"get":"string"}  
[2024-01-30 05:49:29] production.INFO: ユーザー名とパスワードを連結して取得2  
```
一方、検証2の状態にコードを戻した状態で`php artisan ide-helper:models --write --reset`を実行したところ以下のようなログが得られました。
```text:laravel.log(検証2の状態に戻したときのログ)
[2024-01-30 05:52:48] production.INFO: name_and_email2  
[2024-01-30 05:52:48] production.INFO: []  
[2024-01-30 05:52:48] production.INFO: ユーザー名とパスワードを連結して取得2  
```
- 見てわかる通り検証2の状態のときに`info($types)`の出力が空になっていました。
- これが原因で`$this->setProperty()`が実行されず、結果として10.xの形式のアクセサを元にしたPHPDocが生成できていませんでした

## 結論
- laravel-ide-helperの最新を導入しつつLaravel10.xでアクセサを使う場合は`Attribute::make()`のgetパラメータにセットする関数に戻り値の型を明示することで入力の補完が効いた開発が行える。

## 備考
- Laravel8.xの頃のアクセサの書き方をすれば良いのでは？ 
  - それもいいけど別途[Laravel用のrector定義(driftingly/rector-laravel)](https://github.com/driftingly/rector-laravel)を導入している場合に[MigrateToSimplifiedAttributeRector](https://github.com/driftingly/rector-laravel/blob/main/docs/rector_rules_overview.md#migratetosimplifiedattributerector)のルールを有効化できなくなってしまう問題が起きてしまいます（有効化するとLaravel8.xの形式のアクセサについて現形式への整形対象として挙がってしまうため）。
- laravel-ide-helperについて正式のリリースではないリポジトリのmasterをインストールすることを良しとするかは要確認

## その他
この記事に書いてある内容について検証したときのリポジトリ↓

https://github.com/imo-tikuwa/laravel-ide-helper-use-memo2

## 参考サイト
- https://readouble.com/laravel/8.x/ja/eloquent-mutators.html
- https://readouble.com/laravel/9.x/ja/eloquent-mutators.html
- https://readouble.com/laravel/10.x/ja/eloquent-mutators.html

その他、今回の問題についてのissueの存在も確認できました
- https://github.com/barryvdh/laravel-ide-helper/issues/1378