PHPStanによる静的解析をLaravelアプリケーションに導入するためにやったこと（レベル1編）

この記事は[Laravel #2 Advent Calendar 2019](https://qiita.com/advent-calendar/2019/laravel-2)の19日目です。
そして去年のアドベントカレンダーの記事の続きです。

[PHPStanによる静的解析をLaravelアプリケーションに導入するためにやったこと](https://qiita.com/ngmy/items/1a8ef21c76d74c1d4dfe)

----

## はじめに

弊社サービス「リネット[^1]」のLaravelアプリケーションのPHPStanをレベル1に上げました。
そのためにやったことについて書きます。

[^1]: 「[宅配クリーニングの『リネット』](https://www.lenet.jp/)」「[クリーニング×保管の『リネット保管](https://www.lenet-hokan.jp/)」「[布団クリーニングの『ふとんリネット』](https://www.futonlenet.jp/)」「[靴クリーニングの『くつリネット』](https://www.kutsulenet.jp/)」の4サービス。

## 使用しているソフトウェアのバージョン

この記事で使用しているソフトウェアのバージョンは下記の通りです。

| ソフトウェア | バージョン |
| --- | --- |
| PHP | 7.2.12 |
| Laravel | 5.5.44 |
| PHPStan | 0.10.5 |
| Laravel 5 IDE Helper Generator | 2.5.1 |

Laravel 5.6以上を使っている人は、この記事を読まず[Larastan](https://github.com/nunomaduro/larastan)を使うのが良いと思います。
Larastanを使えばこの記事でやっているようなことを自分でやらなくて良いはずです。多分。

Laravel 5.5を使っている人には参考になると思います。

## PHPStanのレベル1で検知できること

PHPStanをレベル1にすると、下記のことが検知可能になります。[^2]

[^2]: [ソースコード](https://github.com/phpstan/phpstan-src/blob/0.10.5/conf/config.level1.neon)を読む限りそう見えますが、間違っているかもしれません。ドキュメントがほしい。

### 1. 存在しないマジックメソッドとマジックプロパティの呼び出し

### 2. 未定義の可能性がある変数の使用

```php
if ($flag) {
    $hoge = 'hoge';
}
echo $hoge; // $flagの値次第で未定義の可能性がある
```

```
Variable $hoge might not be defined.
```

### 3. 存在しない定数の使用

```php
date(w); // date('w')の間違い
```

```
Constant w not found.
```

### 4. 無駄なisset関数の使用

```php
$hoge = 'hoge';
isset($hoge); // 常にtrueになるので無駄
```

```
Variable $hoge in isset() always exists and is not nullable.
```

1を除いては、検知されたら粛々と修正すればOKです。

しかし、1は誤検知が多いと思います。
Eloquentのマジックメソッド（`find`や`where`）が誤検知されてしまいますし、自分でマジックメソッドやマジックプロパティを使っている箇所も同様です。

## マジックメソッドやマジックプロパティの存在をPHPStanに教える方法
### 1. エクステンションを書く

PHPStanにはエクステンションという機構があります。[^3]
エクステンションを書くことで、マジックメソッドやマジックプロパティの存在をPHPStanに教えることができます。

[^3]: https://github.com/phpstan/phpstan#extensibility

リネットでは次のようなエクステンションを書いてみました。
これがベストな書き方なのかはまったく自信がありませんので参考程度でお願いします。

```php:Eloquentモデルのメソッド・リフレクション用のエクステンション
<?php
declare(strict_types=1);

use Eloquent;
use Illuminate\Database\Eloquent\Model;
use PHPStan\Analyser\OutOfClassScope;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Reflection\MethodsClassReflectionExtension;
use PHPStan\Type\ObjectType;

class EloquentModelMethodsClassReflectionExtension implements MethodsClassReflectionExtension
{
    public function hasMethod(ClassReflection $classReflection, string $methodName): bool
    {
        // 無限ループ防止
        if ($classReflection->getName() == Eloquent::class) {
            return false;
        }

        if (!$this->isEloquentModel($classReflection)) {
            return false;
        }

        return $this->findMethod($methodName) !== null;
    }

    public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection
    {
        assert($this->isEloquentModel($classReflection));
        $method = $this->findMethod($methodName);
        assert(null !== $method);

        return $method;
    }

    private function findMethod(string $methodName): ?MethodReflection
    {
        $type = new ObjectType(Eloquent::class);

        if (!$type->hasMethod($methodName)) {
            return null;
        }

        return $type->getMethod($methodName, new OutOfClassScope());
    }

    private function isEloquentModel(ClassReflection $classReflection): bool
    {
        $parents = $classReflection->getParents();

        foreach ($parents as $parent) {
            // \Illuminate\Database\Eloquent\ModelのサブクラスであればEloquentモデル
            if ($parent->getName() == Model::class) {
                return true;
            }
        }

        return false;
    }
}
```

```php:リネットのクラスのメソッド・リフレクション用のエクステンション
<?php
declare(strict_types=1);

use PHPStan\Analyser\OutOfClassScope;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Reflection\MethodsClassReflectionExtension;
use PHPStan\Type\ObjectType;

class LenetMethodsClassReflectionExtension implements MethodsClassReflectionExtension
{
    private $reflect = [
        // リフレクション元 => リフレクション先
        SomePresenter::class => SomeModel::class,
    ];

    public function hasMethod(ClassReflection $classReflection, string $methodName): bool
    {
        $reflectFrom = $classReflection->getName();
        $reflectTo = $this->reflect[$reflectFrom] ?? null;

        if (is_null($reflectTo)) {
            return false;
        }

        return $this->findMethod($methodName, $reflectTo) !== null;
    }

    public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection
    {
        $reflectFrom = $classReflection->getName();
        assert(isset($this->reflect[$reflectFrom]));
        $reflectTo = $this->reflect[$reflectFrom];

        $method = $this->findMethod($methodName, $reflectTo);
        assert(null !== $method);

        return $method;
    }

    private function findMethod(string $methodName, string $reflectTo): ?MethodReflection
    {
        $type = new ObjectType($reflectTo);

        if (!$type->hasMethod($methodName)) {
            return null;
        }

        return $type->getMethod($methodName, new OutOfClassScope());
    }
}
```

### 2. PHPDocコメントを書く

PHPStanはPHPDocコメントを理解します。
エクステンションではなくPHPDocコメントでマジックメソッドやマジックプロパティの存在を教えることもできます。

リネットでは[PHPで列挙型(enum)を作る](https://qiita.com/Hiraku/items/71e385b56dcaa37629fe)で紹介されている列挙型を利用させてもらっていますが、列挙型のファクトリメソッドについては列挙可能なのでPHPDocコメントを書いています。

```php
<?php
declare(strict_types=1);

/**
 * @method static self irui()
 * @method static self futon()
 * @method static self hokan()
 * @method static self kutsu()
 */
class ServiceCode
{
    use EnumTrait;

    private const ENUM = [
        'irui'  => '1',
        'futon' => '2',
        'hokan' => '3',
        'kutsu' => '5',
    ];
}
```

こちらのブログにはエクステンションで対応する方法が紹介されていました。
https://medium.com/@hatajoe/how-to-use-phpstan-940ba1de6832

## まとめ

Laravel 5.5のアプリケーションのPHPStanをレベル1に上げるためにやったことを書きました。
レベル2に上げる頃にはリネットもLaravel 6になっていると思うので、Larastanを使っていると思います。多分。