BASEプロダクトチームブログ

ネットショップ作成サービス「BASE ( https://thebase.in )」、ショッピングアプリ「BASE ( https://thebase.in/sp )」のプロダクトチームによるブログです。

10年開発してきたPHPアプリケーションにPHPStanを導入した

Tech Dept. 基盤グループエンジニアの @tenkoma です。

BASEには50以上のPHPプロジェクトのプライベートリポジトリがあります。 (アプリケーションは十数個で、残りの多くが、アプリケーションが依存するライブラリです)

過去4年ほどの間に新規に作られたリポジトリにはほぼ最初からPHPStanが導入されていますが、それ以前から開発していたリポジトリには導入されていないものが多数ありました。

それらのリポジトリにPHPStanを導入していったので、なぜ導入したか、導入方法、得られた効果について紹介します。

PHPStanとは

PHPコードを実行せずに、実行時にエラーになりうる箇所を検出するツールです。PHPStanを利用しCIに組み込むと、テスト実行せずに検出できるバグの一部は、PHPStan解析で指摘してくれるので、コードレビューの負担が減ることが期待できます。

なぜPHPStanを既存のプロジェクトに導入したか

直接的なきっかけは、PHP 7.3で実行していたアプリケーションをPHP 8.0対応作業中、切り替え前の最終的な検証中にFatal Errorが発生したことです。

以下のようなコードでした。

<?php
class ToaruService {
    public function __construct() { /* ... */ }
    public function make(): self { return new self( /* ... */ ); }
}

class ToaruApiController {
    public $toaruService;
    public function endpoint() {
        $toaruService = $this->toaruService ?? ToaruService::make();

        $toaruService->apply( /* ... */ );
        //
    }
}

ToaruService クラスの make() はインスタンスメソッドですが、ToaruApiControllerからは static 呼び出ししています。 実行すると以下のようなエラーが発生します。

PHP Fatal error:  Uncaught Error: Non-static method ToaruService::make() cannot be called statically in /path/to/ToaruApiController.php:34

これは、PHP 7.0で非推奨になりPHP 8.0で削除された機能、「非staticメソッドのstatic呼び出し」で発生しています。

ToaruService クラス、 ToaruApiController::endpoint() ともにテストコードはありましたが、テストコードではPHPUnitのモック機能によりテストダブルに置き換えられていました。 また、Deprecated Errorを抑制していたため、将来的に使えなくなる機能を見過ごしてしまっていました。

PHP バージョンアップ手順は大きく分けて

  • (1) CircleCI でPHP 8.0でテスト実行や文法チェックを実行させる
  • (2) 検証環境で PHP 8.0で動かして、E2Eのリグレッションテストを行う
  • (3) ステージング環境・実運用環境を PHP 8.0に切り替える

という手順で行いましたが、(2) の、ある程度終えたと思っていたタイミングで、基本的なエラーが検出できなかったため、社内のリポジトリで導入実績のあったPHPStanを導入することにしました。

今までPHPStanを使っていなかったプロジェクトへの導入方法

今回は以下の手順で導入しました。

  1. なぜ導入するか、PHPStanとは何か、をドキュメントに書いて社内に共有する
  2. プロジェクトリポジトリに composer コマンドで、PHPStanを追加する
  3. PHPプロジェクトに合わせた設定ファイルを追加する
  4. level=0でベースラインファイルを生成する
  5. CIでも実行されるよう、設定する
  6. level=0でも無視できないエラーがある場合コードを修正する、もしくは問題ない場合は、解析対象から除外する
  7. これまでの変更をメインのブランチにマージする
  8. Slackに相談用チャンネルを用意し、PHPStanで相談できるようにする

今回重視したのは、プロダクションコードを網羅的に検査してPHPバージョンアップ作業の安全性を高めることでした。 そのため、level=0で導入し、既存のエラーはベースラインで一旦無視するようにしました。また相談用Slackチャンネルで導入直後のトラブルを素早く解決できる環境を用意しました。

なぜ導入するか、PHPStanとは何か、をドキュメントに書いて社内に共有した

PHPStan導入は事前に計画していなかったため、当然社内への情報共有が0でした。 作業を始める前に、導入目的の言語化や、情報共有も兼ねて社内Wikiにドキュメントを追加して、なぜやっているのかわかってもらえるようにしました。

プロジェクトリポジトリに composer コマンドで、PHPStanを追加する

Getting Started | PHPStanを見ながら、プロジェクトにPHPStanを追加しました。

$ composer require --dev phpstan/phpstan

PHPStanはComposerパッケージとして配布されていて、いくつか別のComposerパッケージに依存しています。しかし、それらの依存パッケージは、phpstan.pharファイルにまとめられているので、composer require --dev で追加したときに問題が起きにくくなっているようです。

参考: PHPStanをどうやってインストールするか

この後、First runのように、まず解析を実行してみて、PHPStanインストールが成功しているか確認しました。

$ Vendor/bin/phpstan analyse Controller --memory-limit=1536M

# ...(中略)...

[ERROR] Found 4078 errors

なかなかの数のエラーが出ました。これらが全てアプリケーションの潜在的問題とは限りません。解析対象を /path/to/project/Controller のみで実行しましたので。 今回導入したプロジェクトには、他にも社内で開発したコードを含むディレクトリがあり、それらをPHPStanで解析してもらうために設定ファイルを書きました。

また、--memory-limit=1536M で、使用メモリの上限を指定しています。プロジェクト全体(テストコードは除く)を解析すると、1GBでもメモリ確保に失敗するため、1.5GBを指定する必要があありました。

# memory_limit=128M で実行した時のエラー
Child process error (exit code 255): PHP Fatal error:  Allowed memory size of 134217728 bytes exhausted (tried to allocate 20480 bytes) in ...

PHPプロジェクトに合わせて設定ファイルを追加する

プロジェクトのルートディレクトリに設定ファイル phpstan.neon.distを用意します。 今回導入したリポジトリでは以下のような設定になりました。

# phpstan.neon.dist

parameters:
  level: 0
  phpVersion: 80000
  parallel:
    processTimeout: 1200.0
  paths:
    - Config
    - Controller
    - Model
    - Vendor/foo
    - Vendor/bar
    - View
    - webroot
    # - Test # プロダクション・コードの非互換コード検出を優先して、テストコードは解析除外
  scanDirectories:
    - Vendor

設定ファイルの要点をまとめると

  • paths には、解析対象(PHPStanに問題を指摘してほしいPHPコード)のあるディレクトリを列挙します
    • Vendor ディレクトリにも、リポジトリで管理しているファイルがある場合は、ここで明示します
    • PHPアップグレード作業の安定性を高める目的で導入するため、まずはテストコードを除外しているので、コメントアウトで意図を明示しています
  • scanDirectories には、Vendor を指定しました。Composer でインストールしていて、自分たちで開発していないコードは scanDirectoriesscanFilesに指定します。

となります。設定ファイルを書くことで、PHPStanがプロジェクトの構成を理解しますので、設定ファイルを少し付け足しては Vendor/bin/phpstan analyse --memory-limit=1536M を実行し、その結果を見て、pathsscanDirectories を調整して、リポジトリに合った設定にして行きました。

ベースラインファイルを生成する

設定ファイルもある程度形ができたら解析を実行してみます。

$ Vendor/bin/phpstan analyse --memory-limit=1536M

 ------ -----------------------------------------------------------------------------
  Line   SomeApi/ApiClient.php
 ------ -----------------------------------------------------------------------------
  15     Access to an undefined property BaseInc\\WebApp\\ApiClient::$client.
 ------ -----------------------------------------------------------------------------

 ------ ------------------------------------------------------------------------------------------------------------------
  Line   Services/SomeService.php
 ------ ------------------------------------------------------------------------------------------------------------------
  128    Access to an undefined property BaseInc\\WebApp\\Servises\\SomeService::$User.
 ------ ------------------------------------------------------------------------------------------------------------------

... (以下、とてもたくさん省略) ...

 [ERROR] Found 2596 errors

なかなかの数、エラーが出ました。中身をざっとみてみると、動的プロパティ(未定義のプロパティ)や、@propertyアノテーションの記述ミスが大半でした。しかし、それ以外の指摘も多くあるようです。有用な指摘かもしれませんが、全て解消するとなると骨の折れる仕事になりそうです。

エラーがたくさん指摘されていてもアプリケーションが実運用できているなら、指摘されるエラーの多くは不具合につながらない指摘と推定できます。そのようなエラーを一括で除外してPHPStanを導入しやすくしてくれる機能がベースラインです。今回は静的解析の導入を優先するため、ベースラインで既存のエラーの多くを一旦無視するようにしました。

ベースラインファイルを生成するには、--generate-baselineオプションを付けて解析実行します。

$ Vendor/bin/phpstan analyse --memory-limit=1536M --generate-baseline phpstan-baseline.php
Note: Using configuration file /path/to/project/phpstan.neon.dist.
 4182/4182 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%

 [WARNING] Baseline generated with 2381 errors.
           Some errors could not be put into baseline. Re-run PHPStan and fix them.

不穏な警告が表示されましたが、phpstan-baseline.php が生成されました。 生成されたファイルは、以下のようになっています。

<?php declare(strict_types = 1);

$ignoreErrors = [];
$ignoreErrors[] = [
    'message' => '#^Unsafe usage of new static\\\\(\\\\)\\\\.$#',
    'count' => 1,
    'path' => __DIR__ . '/SomeApi/Request.php',
];

// ... (以下、とてもたくさん省略) ...

'path''message' のエラーが 'count' 個ある、と読めます。 'path' が指定されているので、このPHPファイル以外で同じエラーは無視されませんし 'count' があるので、'/SomeApi/Request.php' で同じエラーが増えた場合は、エラーが出るようになります。

設定ファイルで、生成したベースラインファイルを読み込むようにして、解析を再実行します。

# phpstan.neon.dist に以下の行を追加する

includes:
    - phpstan-baseline.php
% Vendor/bin/phpstan analyse --memory-limit=1536M
Note: Using configuration file /Volumes/src/github.com/baseinc/web/phpstan.neon.dist.
 4182/4182 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%

 ------ ---------------------------------------------------------------------
  Line   BaseInc/App/Services/SomeService.php
 ------ ---------------------------------------------------------------------
  7      Class SomeService extends unknown class AbstractService.
         💡 Learn more at <https://phpstan.org/user-guide/discovering-symbols>
 ------ ---------------------------------------------------------------------

... (以下、たくさん省略) ...

 [ERROR] Found 212 errors

エラーが1/10以下に減りましたが、まだまだエラーが出ています。これらのエラーはベースラインでは無視できないようです。しかしベースラインは生成できたので、phpstan-baseline.php, phpstan.neon.dist をコミットします。

level=0でも無視できないエラーがある場合コードを修正する、もしくは問題ない場合は、解析対象から除外する

200件あまりのエラーは、ベースラインで無視できないものでした。 これらのエラーに対して取れる選択肢は、以下の2つです。

  • エラーを修正する
  • 解析対象から除外する

エラー出力をながめてみると、その多くが Class SomeService extends unknown class AbstractService. というエラーでした。 以下のように定義している AbstractServiceuse を使わずに読み込んだ場合にエラーになりました。

<?php
namespace BaseInc\\Services;
class_alias(AbstractService::class, 'AbstractService');

abstract class AbstractService
{
    // 略
}

これらは、PhpStormで簡単に修正でき、修正結果を他ならぬPHPStanで保証できるので、修正することにしました。class_alias(...) も削除できました。

このエラーを修正した結果、10個程度に減りました。 残りのエラーについて、簡単なものは修正しましたが、難しいものは、除外することにしました。

  excludePaths:
    analyse:
      # ベースラインで無視できず、修正困難なため解析対象外にする
      - Too/Dificalt/Fix/Error/Module.php

CIでも実行されるよう、設定する

CIでPHPStanを実行すると、テストコードを同時にコミットしていなくても、変更したプロダクションコードの問題を指摘してくれて大変便利です。 また、継続的に実行されないと、コード変更でエラーが増えてしまうので、CIで実行し、エラーは修正してもらうようお願いしています。 CircleCIでのjob設定は以下のようになります。

  phpstan:
    docker:
      - image: cimg/php:8.0
    resource_class: xlarge
    steps:
      - checkout
      - run:
          name: composer install
          command: |
            composer config --global github-oauth.github.com ${GITHUB_ACCESS_TOKEN}
            composer install --prefer-dist --no-interaction --no-scripts
      - run:
          name: create reports directory
          command: mkdir ~/reports/
      - run:
          name: Run phpstan analyse
          command: Vendor/bin/phpstan analyse --no-interaction --error-format=junit --memory-limit=1536M > ~/reports/phpstan-junit.xml
      - store_test_results:
          path: ~/reports/
      - store_artifacts:
          path: ~/reports/

エラー情報を JUnit フォーマットで出力し、 store_test_results でテスト結果として認識されるよう設定すると、PHPUnitテスト同様に、エラーが一覧できます。

PHPStan解析エラーがCircleCI結果に表示される

PHPStanを実行するインスタンスのスペックを指定するresource_class: xlarge としています。 CircleCIは、resource_classで、CPUコア数やメモリを増減できます。 PHPStanは、解析に使う論理CPUコア数が増えると解析時間が短くなるので、resource_classを増やした時の効果が高いです。また使える論理CPUコア数が増えれば、1プロセスあたりに使うメモリ量は減ります。 PHPStan を実行してみて、メモリ不足で失敗せず、解析時間が長くならないような resource_class として、このリポジトリでは xlarge にしました。 他のリポジトリでは、 small で十分なリポジトリもあります。

これまでの変更をメインのブランチにマージする

CIでPHPStan解析ができるようになりましたので、メインのブランチにマージします。

Slackに相談用チャンネルを用意し、PHPStanで相談できるようにする

メインのブランチにマージ後すぐは、指摘されるエラーへの対応で困ることが発生するかもしれない、と思い、Slackに相談窓口としてチャンネル #help-dev-phpstan を作りました。 「新しくPHPStanでエラーになった場合の対応」について相談を想定してましたが、ほぼありませんでした。かわりに

  • スローする例外の名前のtypoが見つかった、という情報共有
  • phpstan-baseline.php をコマンドで更新するには
  • CI のログには、エラー内容が表示されなくて困っている
    • TESTSタブに表示されるので、そちらを見てもらうよう案内した(CircleCIで、JUnit形式のレポートを指定するとわかりやすく表示してくれる機能)

といった相談がありました。

得られた効果・まとめ

PHPバージョンアップがしやすくなった

前述のような後方互換性のない振る舞い変更を、一部検知してくれるようになり「非staticメソッドのstatic呼び出し」も指摘されるので、まとめて修正することができました。

テストで実行されないコードの問題が見つけられるようになった

既存コードに存在した問題がいくつか見つかりました。 例えば、異常系処理でスローされる例外クラスが見つからない(存在しなかったり、クラス名にtypoがあったり)という指摘が数件ありました(実運用環境でスローされたことがなかったためか見過ごされていた)

導入前の懸念についてPHPStan導入後に思ったこと

以下のような懸念を抱いてました。

  • namespace のないPHPコードでは解析がうまくできないのではないか
  • 多数のエラーを指摘され、導入までに長い時間がかかってしまうのではないか

実際作業してみると、namespaceのないコードも解析できますし、ベースラインを使うことで既存のエラーを除外しつつ新規の潜在的問題を指摘する能力を素早く導入できました。

参考資料