こんにちは。エンジニアの薮 (@tyabu12) です。
PHPStan を大規模な既存コードベースに後から導入するとき、真っ先にぶつかるのが 「既存コードに対して生まれる、大量の PHPStan のエラーをどう扱うか」 という問題です。
コロプラでも稼働期間の長いプロジェクトに PHPStan を導入していて、その導入の経緯はこちらの記事で紹介されています。本記事はその先の話で、導入後の運用で見えてきた課題と、それに対して作った CLI ツールの話になります。
公式が提供する baseline という仕組みを使えば既存エラーをまとめて無視できます。ただし、実際に baseline を使って導入後に運用しているといくつか悩みが出てきました。
そこで社内では、baseline とは違うアプローチでこの問題を解決する CLI ツール phpstan-ignore-commenter を作りました。この記事では、このツールがどんな課題から生まれ、どういう設計方針になっているのかをご紹介したいと思います。
PHPStan の baseline と課題
まず PHPStan の baseline について簡単に説明します。
PHPStan には phpstan analyse --generate-baseline というコマンドおよびオプションがあり、実行すると現在のエラーをまとめた phpstan-baseline.neon ファイルが生成されます。この生成された baseline ファイルを PHPStan のプロジェクト設定で includes することにより、該当のエラーは以後の解析から除外されます。これで既存コードの大量エラーを無視しつつ、新規に書いたコードのエラーだけを検出できるようになります。この baseline は PHPStan を段階的に導入するときの定番手法として広く知られています。
baseline の生成機能はとても便利なのですが、実際に既存のプロジェクトで運用してみると2つの問題が見えてきました。
1つ目は、baseline に入ったエラーはほぼ誰も触らなくなることです。まとめて1つの巨大ファイルに退避されている都合上、該当クラスやメソッドを触る場合や、コードレビューでは視界に入りません。「いつか直す」が永遠に先送りされ、巨大な baseline は誰も触りたがらない技術負債として塩漬けになりがちです。
2つ目は、baseline の再生成コストです。baseline に載っているコードへ少し手を入れると、ファイルパスがずれたりエラーが変わったりして baseline との不整合が生まれます。そのたびにコマンド実行による baseline の再生成が必要になり、チリツモですが時間が取られてしまいます。
どちらの問題も根本的な原因としては1つで、エラー除外設定と実際のコードの物理的な距離が遠すぎるのが原因だと感じていました。
解決策: @phpstan-ignore コメントをコードに直接置く
PHPStan には baseline とは別に、@phpstan-ignore というアノテーション が用意されています。エラーが出る行の直前にコメントとして書くことで、その行限定で特定のエラーを抑制できます。
<?php declare(strict_types = 1); namespace App\Services; class ScoreService { /** * @param array<int|float> $scores */ public function totalScore(array $scores): int { // array_sum は int|float を返すため、戻り値の型が合わず return.type エラーになる // @phpstan-ignore return.type return array_sum($scores); } }
baseline との一番の違いは、エラーの抑制がコードと一体になっている点です。何を無視しているかが対象行のすぐ上に書かれているので、その箇所を触ったときに 「ついでに直しておくか」 と自然に気づけます。修正の心理的なハードルが下がるわけです。
両者の違いを整理すると次のようになります。
| 観点 | baseline | @phpstan-ignore |
|---|---|---|
| PHPStan での生成 | ✓ --generate-baseline で自動生成 |
✗ 標準機能になく手作業 |
| 既存ファイルへの変更 | ✓ なし(設定ファイルに集約) | ✗ 各ファイルにコメントを追加 |
| エラー抑制の場所 | ✗ 巨大な単一ファイル | ✓ エラー行のすぐ上 |
| コードを触ったときの気づき | ✗ 視界に入らない | ✓ 「ついでに直すか」と気づける |
| 負債の総量の把握 | ✓ 一覧で量を把握しやすい | ✗ 各所に散らばり総量は見えにくい |
| コード変更への追従 | ✗ 再生成が必要 | ✓ 基本は不要 |
とはいえ、何百、何千とある baseline のエラーに対して手作業で既存コードにコメントを足していくのは、さすがに現実的ではありません。ここを自動化したいというのが、今回ツールを作った発端でした。
phpstan-ignore-commenter の設計
設計の方針は2つに絞りました。構成をできるだけ小さく保つことと、既存のコードをできる限り壊さないことです。
たとえ追加するのがコメントとはいえ、既存の PHP コードに手を入れる以上、コメント追加によって PHP が壊れてしまい、アプリケーションが動かなくなったり、挙動が変わってしまうリスクを伴います。静的解析でエラーを未然に検出する仕組みを導入したいのに、導入で壊してしまっては本末転倒です。
構成については、有名な UNIX 哲学にもある通り 「複雑なことはせずに単一の目的に特化した小さなプログラムを作り、それらを組み合わせて大きな問題を解決する」 を意識して小さく保ちました。「壊さない」ほうは、フォーマットを崩さない仕組みと、判断に迷ったらスキップするフェイルセーフの2段構えで担保しています。
この方針を、次の3つの設計ポイント 「最小構成」、「フォーマット保持」、「フェイルセーフ」 に落とし込みました。
設計ポイント① PHPStan の JSON 出力を入力にした最小構成
PHPStan は --error-format=json を付けて実行すると、エラーを構造化された JSON として出力できます。たとえば先ほどの ScoreService から @phpstan-ignore のコメントを取り除いた状態(12 行目の return でエラーが出る)を --error-format=json で解析すると、次のような JSON が返ってきます。
{ "totals": { "errors": 0, "file_errors": 1 }, "files": { "src/Services/ScoreService.php": { "errors": 1, "messages": [ { "message": "Method App\\Services\\ScoreService::totalScore() should return int but returns float|int.", "line": 12, "ignorable": true, "identifier": "return.type" } ] } }, "errors": [] }
phpstan-ignore-commenter が見るのは、対象のファイルパスと、各エラーの line(エラーが出ている行)と identifier(return.type のようなエラーの種別)です。この JSON を入力として受け取り、line が指す行の直前に @phpstan-ignore return.type を挿入します。こうして出来上がるのが、先ほどの解決策で示したコメント付きのコードです。
flowchart LR
A([phpstan analyse]) --> B[/errors.json/]
B --> C([phpstan-ignore-commenter])
C --> D[/修正済みの PHP ファイル群/]
PHPStan の解析結果をそのまま受け取る形にしたことで、ツール自身がファイル間の依存関係を解析する必要がなくなりました。これにより1ファイルずつ独立して処理でき、対象が大きなコードベースでもツール側のメモリ消費を抑えやすくなります。実際、数千ファイル規模の PHP プロジェクトに対して、ローカル PC 上の実行でも数分程度で @phpstan-ignore コメントを挿入できることを確認できています。
設計ポイント② フォーマットを崩さない Hybrid AST 方式
コメントを挿入する位置の特定には nikic/php-parser を使い、PHP を AST(抽象構文木)として解析しています。文字列を雑に検索して書き換えるのではなく、PHP の構文構造から「この文の直前」という安全な挿入位置を正確に割り出します。
ただし、AST は挿入位置の判断にだけ使い、ファイルへの書き戻しは AST の pretty print に頼らず、テキストベースで行っています。AST から再生成してしまうと、プロジェクト独自のインデントや空行のスタイルが勝手に整形されて、差分がノイズだらけになってしまうからです。解析は AST、書き込みはテキストという役割分担で、フォーマットを極力崩さずにコメントだけを差し込めるようにしました。
内部の責務は、データを加工しながら次の層へ渡していく3段のパイプラインに分かれています。実際の実装を簡略化した概念コードで示すと下記の形になります。
<?php // 1. PHPStan の JSON を、ファイル/行単位のエラーの固まりにまとめる $groups = $errorGrouper->groupFromJson($json); // => ErrorGroup{filePath, line, errors}[] // 2. AST を解析し「どのファイルの何行目に、どの identifier を、どんな形式で挿入するか」という挿入指示のみ組み立てる。この層は組み立てのみで書き込みはしない $analysis = $astContextAnalyzer->analyze($groups); // => InjectionAction{filePath, targetLine, kind, identifiers}[] // 3. 組み立てられた挿入指示を、テキストベースで実ファイルへ書き込む $commentInjector->inject($analysis->actions);
ポイントは、AST を扱う AstContextAnalyzer が後段へ渡すのは「どの行に何を入れるか」という指示データだけ、ということです。AST のノードそのものは渡しません。だからこそ最後の CommentInjector は AST を意識せず、行番号を頼りにテキストを差し込むだけのシンプルな処理に保てます。「解析は AST、書き込みはテキスト」という役割分担で、層の境界をまたぐデータの形で担保しているわけです。
設計ポイント③ 迷ったらスキップするフェイルセーフ
もうひとつの軸がフェイルセーフです。InlineHTML や heredoc / nowdoc の内部、そもそもパースに失敗するファイルなど、安全に挿入位置を判断できない曖昧なケースでは、無理に書き換えません。警告を出してそのまま飛ばします。
「全部のエラーにコメントを付けきること」よりも「コードを壊さないこと」を優先しています。多少スキップが残っても、壊れた差分を生むよりは安全だと考えました。
このスキップ判断がとくに効くのが、PHPDoc 自体が構文的に壊れているケースです。たとえば次のように、@throws に例外型が書かれていなかったり、@var の値そのものが不正だったりするケースです。
<?php /** * @throws */ public function riskyMethod(): void {}
こうした PHPDoc の構文エラーは、@phpstan-ignore を足してもエラーが消えません。それどころか PHPStan が壊れたタグ行に対して phpDoc.parseError を出し続けるため、追加した ignore が今度は ignore.unmatchedIdentifier として新たなエラーになり、かえって状況を悪化させてしまいます。
そのため、このケースもツール側で警告を出してスキップする判断にしました。壊れた PHPDoc はまず Rector やエディタで直してから、必要であれば改めてツールを実行する流れになります。なお、PHPDoc の意味がコードと食い違っているだけのケース(構文は壊れていない)は、これまでどおり自動挿入の対象です。あくまで構文そのものが壊れているものだけをスキップしています。
挿入されるコメントの形式
実際に挿入されるコメントは、デフォルトでは PHPStan のエラーメッセージを人間/LLM向けの補助情報として添えた形 になります。たとえば型宣言のない既存メソッドに対しては、次のように各エラー行へコメントを挿入します。同じ行に複数のエラーがある場合は、1つの @phpstan-ignore にまとめて集約します。
<?php declare(strict_types = 1); namespace App\Http\Controllers; use App\Models\User; class UserController extends Controller { /** * PHPStan[missingType.return]: Method App\Http\Controllers\UserController::destroy() has no return type specified. * PHPStan[missingType.parameter]: Method App\Http\Controllers\UserController::destroy() has parameter $id with no type specified. * @phpstan-ignore missingType.return, missingType.parameter */ public function destroy($id) { /** * PHPStan[argument.type]: Parameter #1 $ids of static method App\Models\User::destroy() expects array|Illuminate\Support\Collection|int|string, mixed given. * @phpstan-ignore argument.type */ User::destroy($id); return redirect()->route('users.index'); } }
何を無視しているのかがコメントから一目で分かるので、後から直すときの手がかりになります。もし補助メッセージが不要なら、実行時のオプション --no-error-message で省略したり、--reason で無視の理由を添えることもできるようにしておきました。
既存ツール phpstan-ignore-inliner との比較
正直に書いておくと、このツールは作り始める前に競合を十分に調べていませんでした。社内の課題から「まず作ってみよう」と手を動かし、この記事の執筆中に競合調査をした際に、似た発想の OSS として shipmonk/phpstan-ignore-inliner を知ったというのが実際の経緯です。
shipmonk/phpstan-ignore-inliner は2025年6月に公開されたツールで、phpstan analyse --error-format=json の出力をパイプで受け取り、エラーの出ている箇所に @phpstan-ignore を挿入します。
vendor/bin/phpstan --error-format=json | vendor/bin/inline-phpstan-ignores
baseline のように設定ファイルへ集約するのではなく、抑制をエラーの近くに置くという狙いまで本ツールと共通していて、同じような課題を感じている人は他にもいて、最終的には同じ結論にたどり着くんだなと感じました。
発想が重なっている一方で、コメントの入れ方には違いがあります。phpstan-ignore-inliner はエラーと同じ行の末尾にコメントを足します。
<?php if (empty($surname)) { // @phpstan-ignore empty.notAllowed
一方で今回作った phpstan-ignore-commenter は、設計ポイントで述べたとおり、エラー行の直前に PHPDoc コメントとして挿入し、PHPStan のエラーメッセージも一緒に添えます。主な違いを整理すると次のようになります。
| 観点 | phpstan-ignore-inliner |
phpstan-ignore-commenter |
|---|---|---|
| 入力 | PHPStan の JSON 出力 | PHPStan の JSON 出力 |
| 挿入位置 | エラーと同じ行の末尾 | エラー行の直前(PHPDoc) |
| 補助情報 | --comment で任意の理由を付与 |
デフォルトでエラーメッセージを添付。--reason で理由を付与、--no-error-message で省略 |
| 公開状況 | Packagist で公開済み | 社内向けリポジトリのみ |
どちらが優れているという話ではなく、行末に短く添えたいか、直前にエラー内容まで残したいかという好みやチームの方針で選ぶものだと思います。すでに phpstan-ignore-inliner で問題なく運用できているなら、無理に乗り換える必要はありません。本ツールは、設計ポイントで挙げた AST によるフォーマット保持と、迷ったらスキップするフェイルセーフを特に重視して作っています。
実際のプロダクトコードでの検証
この CLI ツールは、もともと運用期間が長くなった社内プロダクトで baseline をどう扱うかという課題感から生まれました。
とはいえ既存コード全てに一括適用するのは既存コードへの影響が読みきれなかったため、PHPStan の新規カスタムルール導入時にまずは試しに適用してみました。新規ルールを入れると当然ながら既存コードが大量にカスタムルールに引っかかるため、そのエラーを baseline ではなく @phpstan-ignore で抑制することにしました。
ちなみに PHPStan のカスタムルールそのものについては、過去の記事で紹介しています。1本目はカスタムルールの作り方を解説したもの、2本目は実際に作ったカスタムルールの事例です。あわせてお読みいただくと、このツールが必要になった背景が伝わりやすいかと思います。
実際にプロダクトコードへ適用したところ、classConstant.phpDocType(定数の代入値は文字列なのに PHPDoc が int になっている)や staticClassAccess.privateConstant(trait をまたいでプライベート定数を参照している)といったエラーに対して、@phpstan-ignore を問題なく挿入できました。設計ポイント②で狙った「フォーマットを崩さない」も実際のコードで成立し、差分は @phpstan-ignore の追加だけに収まって、既存のインデントや空行、すでに書かれている PHPDoc とも衝突しませんでした。
「既存の PHPDoc とぶつからず、エラーの出ている箇所のすぐ上に入るので直しやすい」という声をもらえたのは、物理的な距離を近づけるという当初の狙いがそのまま効いた手応えでした。安全に判断できないケースではフェイルセーフが想定どおり働き、無理に書き換えずスキップしています。
一方で、改善点も見えました。trait の定義そのものが原因になっているエラーは、その trait を use しているクラスの数だけコメントが出力されてしまいます。多くのクラスから使われている trait ほどコメントが増えるため、trait の場合はエラー文をまとめるなど視認性を改善予定です。
使うときのコツ
全エラーを一気に処理すると、何千もの @phpstan-ignore が一度に追加されて差分の確認に手間がかかります。最初は範囲を絞って小さく試すのが導入しやすかったです。既存のコードベースに適用する際は、phpstan analyse を実行するときにディレクトリを指定して小さめの JSON を作り、まず狭い範囲で適用して少しずつ対象を広げていくと安心して進められます。
また書き換える前に内容だけ確認したいときは --dry-run を使えば、対象ファイルには書き込まずに想定される @phpstan-ignore の挿入数や、フェールセーフにより @phpstan-ignore を挿入できない警告が出るコード箇所を確認できます。
おわりに
PHPStan を既存のコードベースに後から入れると、baseline を使っても「見えないから触らない」エラーが積み重なっていきがちです。 @phpstan-ignore 方式でエラーの除外設定をコードの隣に置くことで、コードを触るたびに「ついでに直す」きっかけが生まれやすくなると感じています。
phpstan-ignore-commenter はまだ開発したてホヤホヤで、社内向けに使い始めたばかりです。今後フィードバックを受けて、より使いやすい形に育てていくつもりです。
比較の節で触れたとおり、コードの隣に @phpstan-ignore を置くという発想そのものは先行ツールがあり、そこまで目新しいものではありません。そのうえで、フォーマットを崩さないことや、迷ったら書き換えずにスキップする安全側の設計を重視したというのが本ツールの設計思想です。もし同じように baseline の肥大化や、棚卸しできない負債に悩んでいる方がいた場合、本記事が少しでも参考になれば嬉しいです。
社内での運用が落ち着いて磨きがかかり、また他にも baseline の棚卸しに関して悩んでいる方がいたら、いずれ外に出せる形にしたいと考えています。そして、エラーの抑制をコードの近くで扱うこうした流れが、PHPStan のエコシステムでもっと広がっていけばいいなと思っています。
ColoplTechについて
コロプラでは、勉強会やブログを通じてエンジニアの方々に役立つ技術や取り組みを幅広く発信していきます。
connpass および X で情報発信していますので、是非メンバー登録とフォローをよろしくお願いいたします。
また、コロプラではインフラエンジニアを積極採用中です!
興味を持っていただいた方はぜひお気軽にご連絡ください。