PHPStanによる静的解析をLaravelアプリケーションに導入するためにやったこと

続きがあります。

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

----

これは[WHITEPLUS Advent Calendar 2018](https://qiita.com/advent-calendar/2018/whiteplus)の24日目の記事です。

----

# はじめに

先日、無事弊社サービス「リネット[^1]」のアプリケーションをPHP 5.6 + Laravel 4.2というレガシーな環境から、PHP 7.2 + Laravel 5.5というモダンな環境にアップグレードすることができました。めでたい:confetti_ball::confetti_ball:

この際、一緒にPHPStanを導入し、静的解析を行うようにしました。
レベル0（最も緩いレベル）ですが、すべてのエラーを修正しています。

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

今日は、リネットのLaravelアプリケーションにPHPStanによる静的解析を導入するためにやったことについて書きます。

# パッケージのバージョン

　本記事で使用している各パッケージのバージョンは下記の通りです。

| パッケージ | バージョン |
| --- | --- |
| PHP | 7.2.12 |
| Laravel | 5.5.44 |
| PHPStan | 0.10.5 |
| Laravel 5 IDE Helper Generator | 2.5.1 |

# PHPStanとは？

[PHPStan](https://github.com/phpstan/phpstan)とはバグ検出を目的とした静的解析ツールす。
テストとは違い、実際にプログラムを実行することなくバグを検出することができます[^2]。
PHP 7.0以降で利用可能です。

同じような目的を持ったツールに[Phan](https://github.com/phan/phan)があります。

[^2]: 仕様を満たしていないバグとか仕様自体のバグは当然ですが検出できませんので、然るべきテストをしましょう。

リネットではCircleCIで静的解析を実行したかったので、実行速度が速いPHPStanの方を採用しました[^3]。

[^3]: PHPStanとPhanの比較はコネヒトさんの記事がとてもわかりやすいです。 http://tech.connehito.com/entry/phan-or-phpstan

# インストール方法

Composerでインストール可能です。

```sh
composer require --dev phpstan/phpstan
```

# 使い方

`analyse`コマンド使って静的解析を実行できます。
下記のコマンドでは、`src`ディレクトリに対して静的解析を実行しています。

```sh
vendor/bin/phpstan analyse src
```

`-l`オプションで静的解析のレベルを指定できます。指定可能なレベルは0〜7の8段階で、0が最も緩く、7が最も厳しいです。

```sh
vendor/bin/phpstan analyse -l 0 src
```

また、`-c`オプションで設定ファイル（`phpstan.neon`）を指定して静的解析を実行できます。PHPUnitにおける`phpunit.xml`のようなものです。

```sh
vendor/bin/phpstan analyse -c phpstan.neon
```

```yaml:phpstan.neon
parameters:
  level: 0
  paths:
    - src
```

# Laravelに導入するためにやったこと

Laravelは内部でマジックメソッドを使って色々やっているため、単純にPHPStanを導入しただけでは大量にエラーが出てしまうため、泥臭く対応していく必要がありました[^4]。（特にファサードとEloquentまわり）

[^4]: Laravel 5.6以上であればLarastanというパッケージを利用できます。このパッケージを利用すれば、この辺りの泥臭い対応を自分でやらずに済むかもしれません。しかし、このパッケージはLaravel 5.5（LTS）には未対応です:cry:  https://github.com/nunomaduro/larastan

ここでは、LaravelにPHPStanのレベル0を導入するためにやったことについて書きます。レベルを上げるとまた別の問題が出てくるかもしれません。

## ファサードのエラーの対応

何もせずPHPStanを実行すると、ファサードを使用している箇所で下記のようなエラーが出ます。

```sh
Call to static method view() on an unknown class Response.
```

ファサードは実在するクラスではないため、PHPStanが理解できないためです。

この類のエラーは、[Laravel 5 IDE Helper Generator](https://github.com/barryvdh/laravel-ide-helper)を利用することで解決できます。
このパッケージはLaravelのIDE補完を可能にするためのもので、IntelliJ IDEAやPhpStormを使って開発しているプロジェクトであれば、すでにインストールしてあるかもしれません。

まだの場合はComposerを使ってインストールできます。

```sh
composer require --dev barryvdh/laravel-ide-helper
```

インストール後、下記のコマンドを実行して、IDE補完用のヘルパーファイルを生成します。

```sh
artisan ide-helper:generate
```

生成されたヘルパーファイル（`_ide_helper.php`）の中を見るとわかりますが、Laravel本体のクラスがずらっと定義されており、またファサードも実在のクラスとして定義されています。

このヘルパーファイル（`_ide_helper.php`）の本来の用途はLaravelのIDE補完を可能にするためのものですが、これを利用することで、PHPStanを実行した際のファサードのエラーに対応できます。

`_ide_helper.php`を`phpstan.neon`の`autoload_files`オプションに指定して読み込むようにすると、PHPStanがファサードを理解するようになり、エラーが出なくなります。

```yaml:phpstan.neon
parameters:
  autoload_files:
    - _ide_helper.php
```

## Eloquentのエラーの対応

`where()`や`find()`などのEloquentのメソッドもファサードと同様エラーになります。
これらのメソッドもマジックメソッドだからです。

先ほど生成した`_ide_helper.php`の中に`Eloquent`クラスが定義されていて、そのクラスのメソッドとして`where()`や`find()`などが実在のメソッドとして定義されているので、それを利用するのが一番手取り早いです。

普通は`Illuminate\Database\Eloquent\Model`を継承してEloquentモデルを作っていると思いますが、代わりに`Eloquent`を継承するように変更します。
実コードを変更するのは気持ち悪いですが、`Eloquent`は`Illuminate\Database\Eloquent\Model`のエイリアスなので、プログラムの実行に差は出ないはずです。

```php
<?php

abstract class BaseModel extends \Eloquent
{
}
```

これで最初に出てきたEloquentのメソッドに関するエラーは出なくなります。

ただ、代わりに`where()`メソッドや`orWhere()`メソッドで下記のようなエラーが出るようになります。

```
Parameter #2 $operator of method Eloquent::where() expects string|null, int given.
```

これは、Laravel 5.5の`Illuminate\Database\Eloquent\Builder::where()`と`Illuminate\Database\Eloquent\Builder::orWhere()`のDocコメントの第2引数の型が、`mixed`が正しいのに`string|null`と間違って記述されているためです[^5]。そのせいで`_ide_helper.php`が間違った型情報で生成されてしまっています。

[^5]: Laravel 5.6では修正されているようでした。 https://github.com/laravel/framework/pull/24173

`_ide_helper.php`を編集してもいいんですが、自動生成ファイルを編集するのは気持ち悪かったので、`phpstan.neon`の`ignoreErrors`オプションでまるっと無視するようにしました。（もっといい方法ないかな…。）

```yaml:phpstan.neon
parameters:
  ignoreErrors:
    - '#Parameter \#2 \$operator of method Eloquent::where\(\) expects string\|null, [a-zA-Z0-9\\_]+ given#'
    - '#Parameter \#2 \$operator of method Eloquent::orWhere\(\) expects string\|null, [a-zA-Z0-9\\_]+ given#'
    - '#Parameter \#2 \$operator of static method Eloquent::where\(\) expects string\|null, [a-zA-Z0-9\\_]+ given#'
    - '#Parameter \#2 \$operator of static method Eloquent::orWhere\(\) expects string\|null, [a-zA-Z0-9\\_]+ given#'
  reportUnmatchedIgnoredErrors: false
```

`ignoreErrors`オプションには無視したいエラーメッセージを正規表現で指定することができます。

`reportUnmatchedIgnoredErrors`オプションを`false`にしておかないと、`ignoreErrors`オプションに指定した正規表現にマッチするエラーが発生しなかった場合に解析失敗になってしまうので注意が必要です。

## Eloquentのマジックwhereメソッドのエラーの対応

マジックwhereメソッドとは下記のようなものです。

```php
// 通常のwhereメソッド
User::where('email', 'example@example.com');

// マジックwhereメソッド
User::whereEmail('example@example.com');
```

マジックwhereメソッドもマジックメソッドなので、PHPStanは理解できません。

これを解決するために、またもやLaravel 5 IDE Helper Generatorを利用します。

まず、Laravel 5 IDE Helper Generatorの設定ファイルを編集し、`write_model_magic_where`オプションに`true`を指定して、`model_locations`オプションにEloquentモデルが置いてあるディレクトリを指定します。

```php:config/ide-helper.php 
'write_model_magic_where' => true,
```

```php:config/ide-helper.php
'model_locations' => array(
    'path/to/models',
),
```

その後、下記のコマンドを実行します。

```
artisan ide-helper:models -W
```

実行すると、各Eloquentモデルのクラスに、マジックwhereメソッドの定義情報が記載されたDocコメントが付与されます。

PHPStanがこのコメントを理解して、マジックwhereメソッドが存在するものとして静的解析を実行してくれるようになり、エラーが出なくなります。

## ファサードクラスに直接定義されたメソッドのエラーの対応

ここで言うファサードクラスとは`Illuminate\Support\Facades\Facade`を継承したクラスのことです。

ファサードクラスには`getFacadeAccessor()`メソッドしか定義されていないかと思いきやそうではなく、それ以外にもメソッドが定義されている場合があります。

例えば`Cookie::get()`メソッドがそうです。
`Cookie::get()`メソッドは`Illuminate\Support\Facades\Cookie`に直接定義されています。

こういった類のメソッドは`_ide_helper.php`に含まれないので、PHPStanが理解してくれません。

これを解決するために、Laravel 5 IDE Helper Generatorの設定ファイルの`extra`オプションに設定を追加し、`Cookie`に関しては`Illuminate\Support\Facades\Cookie`も見て`_ide_helper.php`を生成するようにします。

```php:config/ide-helpers.php
'extra' => array( 
    'Eloquent' => array('Illuminate\Database\Eloquent\Builder', 'Illuminate\Database\Query\Builder'),
    'Session' => array('Illuminate\Session\Store'),

    // 追加
    'Cookie' => array('Illuminate\Support\Facades\Cookie'),
),
```

これで再度`_ide_helper.php`を生成すると、`Illuminate\Support\Facades\Cookie`に直接定義されたメソッドも`Cookie`クラスのメソッドとして定義されて、PHPStanのエラーが出なくなります。
この方法がベストなのかはわからないですが、現状これで対処しています。

## カスタムファサードのエラーの対応

また、カスタムファサードを定義している場合、そのカスタムファサードクラスを解析すると下記のエラーが出てしまいます。

```
Method getFacadeAccessor() was not found in reflection of class
```

これは`phpstan.neon`の`excludes_analyse`オプションで解析対象外とすることで対応しています。

```yaml:phpstan.neon
parameters:
  excludes_analyse:
    - path/to/CustomFacade.php
```

## その他、どうしようもない系のエラーの対応

どうしようもない系のエラーは無視するように`phpstan.neon`で設定できます。

リネットではRedisを使用するために`predis/predis`パッケージを利用していますが、このパッケージを利用した場合`Redis::get()`などのメソッドは実在せず静的解析できません。
Laravel本体のファイルではないので`_ide_helper.php`を利用することもできません。

仕方がないのでエラーが出ても無視するようにしてます。

```yaml:phpstan.neon
parameters:
  ignoreErrors:
    - '#Call to an undefined static method Redis::[a-zA-Z0-9\\_]+\(\)#'
  reportUnmatchedIgnoredErrors: false
```

# require（require_once）への対応

すべてのファイルがオートロードされている世界が理想ですが、現実は厳しいです。
解析対象ファイルの中で`require`や`require_once`が使われていても、PHPStanはそのファイルを読み込みません。
例えば、関数が定義されたファイルがあり、それを`require`して使用している場合、PHPStanはその関数を見るけることができずエラーとして検出します。

これを回避するために、設定ファイルの`autoload_files`オプションを使用して、静的解析の実行前に読み込んでおきたいファイルを指定できます。

```yaml:phpstan.neon
parameters:
  autoload_files:
    - functions.php
```

# ブートストラップ処理

PHPStanの実行前にブートストラップ処理をしたいこともあります。

設定ファイルの`bootstrap`オプションを使用してブートストラップファイルを指定することができます。

リネットの場合、`phpstan.neon`と同じ階層に`phpstan-bootstrap.php`という名前でブートストラップファイルを作成しています。
リネットのプログラムに必要な`$_SERVER`変数や定数を定義したり、Laravelの`bootstrap/app.php`を読み込んだりしています。

```yaml:phpstan.neon
parameters:
  bootstrap: phpstan-bootstrap.php
```

```phpstan-bootstrap.php
<?php

// 必要な$_SERVER変数や定数の定義

// Laravelのブートストラップファイルの読み込み
require_once __DIR__ . '/bootstrap/app.php';
```

# 解析可能な範囲

（当然と言えば当然ですが）PHPStanはビューの解析には使えません。
Laravelの場合で言うと、ビューを解析しようとしても、コントローラから渡される変数がことごとく未定義変数扱いになってしまいます。
これに関しては、GitHubで`/** @var */`コメントを使った回避方法が提案されていましたが[^6]、今のところ対応していないので諦めるしかないです。

[^6]: 実装される見込みは薄いと思います。 https://github.com/phpstan/phpstan/issues/351

リネットでは、PHPStanの対象を「ビジネスロジック」「コントローラ」「ORM」「Laravelの`app`ディレクトリ」に限定しています。

# ラッパーコマンド

簡単にPHPStanを実行できるように、Composerの`scripts`を使ってラッパーコマンドを定義しています。

```composer.json
{
    "scripts": {
        "phpstan": [
            "@php vendor/bin/phpstan analyse -c phpstan.neon"
        ]
   }
}
```

```sh
composer phpstan
```

```sh
composer phpstan SomeClass.php
```

# 実行速度

参考として、リネットの場合のPHPStanの実行時間を記載します。
リネットには大きく分けて「リネット」「リネット保管」「ふとんリネット」「くつリネット」「CMS（内部向け管理画面）」「共通ライブラリ」という6つのプロジェクトがあり、各プロジェクト毎にPHPStanを実行していますが、CircleCI（2CPU、4GBメモリ）での実行時間は次の通りです。

| プロジェクト | 解析対象ファイル数 | 実行時間 |
| --- | ---: | ---: |
| リネット | 65 | 6秒 |
| リネット保管 | 28| 1秒 |
| ふとんリネット | 17 | 1秒|
| くつリネット | 11 | 3秒 |
| CMS | 268 | 21秒 |
| 共通ライブラリ | 1,395 | 34秒 |

CIで実行しても問題ないくらい速いと思います。

# 既存プロジェクトへの導入ポイント

既存のプロジェクトにPHPStanを導入する上で守ったほうがいいと思うポイントは下記の2点です。

1. 最も緩いレベル0から始める
2. すべてのエラーを修正してからCIで回す

最初から厳しいレベルで始めると絶対に心が折れます（笑）。また、すべてのエラーを修正する前にCIで回し始めると、割れ窓理論が発動して、誰もエラーを気にしなくなる可能性大です。
最も緩いレベルから始めて、エラーがない状態をしばらく維持できるようであれば、段階的に1ずつレベルを厳しくしていくのがいいと思っています。

新規プロジェクトの場合は、逆に最初から最も厳しいレベルにしておいた方があとあと楽になるのでいいと思います。

# 設定ファイル例

リネットの`phpstan.neon`は下記のような雰囲気になっています。

```yaml:phpstan.neon
# vim: set ft=yaml:

parameters:
  level: 0
  paths:
    - %rootDir%/../../../app
    - %rootDir%/../../../lib
  bootstrap: %rootDir%/../../../phpstan-bootstrap.php
  autoload_files:
    - %rootDir%/../../../vendor/autoload.php
    - %rootDir%/../../../_ide_helper.php
  fileExtensions:
    - php
  ignoreErrors:
    - '#Call to an undefined static method Redis::[a-zA-Z0-9\\_]+\(\)#'
    - '#Parameter \#2 \$operator of method Eloquent::where\(\) expects string\|null, [a-zA-Z0-9\\_]+ given#'
    - '#Parameter \#2 \$operator of method Eloquent::orWhere\(\) expects string\|null, [a-zA-Z0-9\\_]+ given#'
    - '#Parameter \#2 \$operator of static method Eloquent::where\(\) expects string\|null, [a-zA-Z0-9\\_]+ given#'
    - '#Parameter \#2 \$operator of static method Eloquent::orWhere\(\) expects string\|null, [a-zA-Z0-9\\_]+ given#'
  reportUnmatchedIgnoredErrors: false
```

# まとめ

PHPStanによる静的解析をLaravelアプリケーションに導入するためにやったことについて書きました。

Laravelはマジックメソッドを多用しているため、PHPStanを導入するのはかなり大変でした。おかげでマジックメソッドが嫌いになりました（笑）。

静的解析を導入したことで、テストコードがない部分に関してもある程度安心してリファクタリングできるようになったと感じています。

静的解析を活用して、安全かつ大胆にPHPアプリケーションを開発・運用していきたいと思います。

# 明日の担当

明日は弊社CTO @exmeat の担当です。よろしくお願いします。

# 参考サイト

- PHPコードの解析をPhanからPHPStanに移行しようか検討しています
    - http://tech.connehito.com/entry/phan-or-phpstan
- PHPStanで始めるPHPのための静的解析
    - https://tech.mercari.com/entry/phpstan