🕸️

PHPStanクイックガイド2023

2023/09/17に公開

PHPStan (PHP Static Analysis Tool)はコードを実行せずに検査できるツールです。本稿では業務アプリケーションにPHPStanを導入するまでに押さえておきたい事柄を記述します。

導入

PHPStanは本稿記述時点の1.9.x系において、PHP 7.2以降で実行できます。PHPStanは composer require --dev phpstan/phpstan でのインストールが基本です。

プロジェクトルートの phpstan.dist.neon に、以下のように記述してください。

parameters:
	level: 6
	paths:
		- src
		- tests

pathsには実際にPHPファイルが格納されているディレクトリを指定しましょう。たとえばフレームワークによっては app inc public のようなディレクトリに配置されているかもしれません。

levelは現在のところ1〜9が定義されており、文字列の"max"を指定することで将来にわたってPHPStanの提供する最高レベルのルールが適用されます。本稿ではmax想定で型を付けられるように解説しますが、プロジェクトの状態に合わせて5〜6から調整するのがよいでしょう。

PHPStanの設定ファイルとして採用されているNEONフォーマットはYAMLに似ています。インデントに意味がある言語なので、PHPStanがうまく動作しないと思ったらインデントにタブ文字とスペースが混在していないか疑いましょう。

まずはPHPStanの動作確認がてらベースラインファイルを作成してみましょう。これは既知のエラーをファイルに保存しておくことで、プロジェクトを継続的に解析するための基準となるものです。たとえば、あなたのプロジェクトが既にリリースされてユーザーに価値を提供しているのであれば、PHPStanが出す警告というのはお節介な杞憂であるとも考えられるわけです。実際にはコード上の多くの良くない傾向が示唆されているだろうと思いますが、まずは現状をポジティブに捉えて、PHPStanはプロジェクトをさらに良くしていくための道具なのだと考えましょう。

以下のようなコマンドでPHPStanを起動できます。

./vendor/bin/phpstan analyze --generate-baseline --allow-empty-baseline

このコマンドを実行すると phpstan-baseline.neon というファイルが生成されます。先ほど「ポジティブに捉えよう」と言ったばかりなのですが、「Function xxx not found.」や「Constant XXX not found.」といったエラーは最初に解決しておくべきです。

PHPStanは特別な設定なしでコードを解析できるように設計されています。特にcomposer.jsonautoloadが設定されていれば自動的に解析されるようになっています。スクリプト実行時に関数や定数が動的に定義される場合やクラス名が動的にエイリアスされる場合は、初期化ファイルをcomposer.jsonautoload.filesか、phpstan.dist.neonbootstrapFilesに追加してください。たとえばCodeIgniterプロジェクトであればvendor/codeigniter4/framework/system/Test/bootstrap.phpを追加するのがよいでしょう。

さて、ここで作ったベースラインファイルはincludeすることで検査時にエラーを抑制できます。その状態でプロジェクトを再検査すると [OK] No Errors になるはずです。これがPHPStanによる改善の出発地点です!

phpstan.neon.distphpstan-baseline.neonはそのままgit addして、プロジェクトメンバー全員で共有するのがいいでしょう。PHPStan設定ファイルの構成については紙幅の都合上、Web上の補遺に譲ります。

IDEの有効化

インストールしたPHPStanを何に使えばいいのでしょうか。コードをちょっと変更する度にターミナルで手動実行して検査するのは実際かなりの手間です。PhpStormを使っているならば、PHPStanプラグインが最初から同梱されているので検証を有効化すれば編集中に非同期に検査され、エディタ上に検証結果が表示されます。

Vimではcoc.nvimやALE、EmacsではFlycheck + flycheck-phpstanを使うのがよいでしょう。VS Codeにもいくつも拡張がリリースされていますが、メンテナンスされていない古いものも混在しているので注意してください。

PhpStormは最新バージョンを使うことが非常に重要です。特に本稿執筆時点で次期メジャーバージョンのPhpStorm 2023.1では、本稿で取り上げるPHPStanとPsalmのタグや型のサポートを一層強化することが予告されています。これによってPHPStan向けに付けた型がエラーチェックだけでなく、入力補完にも活用されることが期待できます。

PHPと型

PHPはいわゆる動的型付け(dynamic typing)の言語です。動的型は型理論を踏まえた文脈で「型なし」と呼ばれることもありますが、ここでは細かいことは置いておきましょう。

実際のところPHPの関数やプロパティに型を付けることで多くのことはきちんと型を書けば静的に保証させられます。すべての値は実行時に型情報にアクセスできます。

まずはPHPで型宣言できる組み込みの型を確実に押さえておきましょう。比較のためにJSON(JavaScript = TypeScript)の基本的な型と並べておきます。

型名 キーワード JSONの対応する型
整数 int number
浮動小数点数 float number
文字列(バイト列) string string
真理値 bool boolean
配列 (リスト) array array
連想配列 array (list) object
オブジェクト object (object)
ヌル値 null null

PHPの型は小文字は基本的に小文字で表します。 int bool が略語なのに対して、文字列は str ではないので気を付けてください。JSONと異なりintfloatは型レベルで分かれています。逆にJSONで区別されるリストと連想配列の区別は型の上ではありません。

これらの型名は型宣言として関数・メソッドのパラメータ(仮引数リスト)と戻り値、プロパティに記述できます。型の振る舞いについてはファイルごとに変化します。デフォルトではcoercive(押し付け)モードと呼ばれる挙動で、int型宣言された箇所に'1'のような数値文字列を渡すと1にキャストされます。コードの冒頭にdeclare(strict_types=1);と書くことでファイル単位でstrict(厳密)モードに変化して、厳密に一致する型以外を受け付けなくなります。

重要なのは、どちらのモードで動作しているとしても常に定義通りの型が渡ってくることは保証されるということです。両モードの差分はキャストの有無ですが、デフォルトのcoerciveモードでも不適切な値に対してTypeErrorを発生させるので、一般的なキャスト機能(intval()関数や(int))を用いるよりもはるかに堅牢になります。

型宣言においては型名のほかにクラス名とインターフェイス名が記述できます。クラス名は名前空間の影響を受けます。namespace App;と宣言されたファイルでException $eと型宣言すると、グローバル名前空間のExceptionではなく、ファイルの名前空間からの相対参照と解釈されてApp\Exceptionを指します。型名は名前空間に影響されることはありません。

特定の型だけを指さない型宣言として、callable(呼び出し可能型)とiterable(反復可能型)という2種類の特殊型も利用可能です。callableは関数として呼び出し可能な値を指し、Closure、関数名やメソッド名を指す文字列と配列、__invoke()マジックメソッドを実装したクラスのインスタンスを含みます。iterableforeachできる値、つまりarray|Traversableを指します。

そのほかに型宣言ではA|B(ユニオン型/union types、AまたはB)、A&B(交叉型/pure intersection types、AおよびB)という表記もできます。PHP 8.2ではこれらの記法を複雑に組み合わせたA|(B&C)のような選言標準形型(Disjunctive Normal Form Types)も型宣言に記述できるようになりました。ただしこれは使いたい場面を探そうとしてもなかなか見付からないでしょう。

A|null?A(ヌル許容型/nullable)としても記述できます。?A|Bのように記述することはできず、A|B|nullと書かなければいけません。PHPはJavaとは異なり、明示的にnullableとして宣言しない限りは任意のオブジェクト型に対してnullがサブタイプになることはありません。言い方を変えると、PHPの型システムではぬるぽは起こりません。

戻り値にだけ書ける型もあります。voidは関数・メソッドが有効な型を返さないことを示します。neverは関数・メソッドが正常終了せず、例外を送出するかexitなどでプロセスが強制中断されることを示唆します。

さらには型宣言に書けないresource型もあります。これはファイルポインタや外部への通信コネクションのようなPHPレベルでは直接操作できない特殊な値を指します。

型付けの基礎

プログラムを解析する際に、変数や関数などに型を指定する方法は「型宣言やPHPDocに型を明記する」と「プログラムから型を認識させる」の二種類に大別できます。

PHPの言語仕様で型宣言できるのはパラメータ、戻り値、プロパティの三か所です。これらの箇所に宣言として明記された型は、静的解析でも実行時にも確実に保証される、実効力のある強力な型として機能します。

確実に型付けされたものをベースに、理想的な型をPHPDocに表現し、適切に型を絞り込むようなプログラムを記述するというステップの繰り返しが基本です。

ここからは具体的に実アプリケーションを静的解析するために必要な知識を紹介します。

型宣言

まず基本になる型宣言(type declaration)について触れておきましょう。これはPHP 7.0からの用語で、PHP 5の時代には型ヒント(type hinting)と呼ばれていました。この時期に型ヒントとして記述できたのはarray、クラス名とインターフェイス名、そしてcallableだけでした。

PHP 7.xではスカラー型と総称されるboolintfloatstringの型宣言が許可されました。これら4つの型宣言が当初から実装されていなかったのは、ひとえに$_GET['id']のように外部から数値IDを取得したり、PDO(エミュレーションモード)から取得した値をキャストなしでarray_sum(array_column($rows, 'score'))のように呼び出すようなPHPのユースケースとそぐわないことが明白だったからです。

このように幾度と議論と提案が繰り返されたスカラー型宣言でしたが、先述のdeclare(strict_types=1);によって2種類のモード(+ 宣言しない)という選択肢が用意されたことでようやく採用に至りました。

PHPStanで静的解析にチャレンジするということで、まずプロジェクト全体に型宣言を追加したり、declare(strict_types=1);を付けて回りたいという欲求に駆られるかもしれません。しかし、十分にテストすることなく型宣言を追加することはアプリケーションの挙動に影響を及ぼすため、PHPStanでコード改善に着手する際のファーストステップとしてはおすすめしません。後回しにしましょう。

PHPDocの書き方

クラス・関数・メソッド・定数などの宣言の直前に書いた /** ... */ 形式のブロックはDocCommentと呼ばれ、実行時に読み取り可能な特殊な形式のコメントとして取り扱われます。

Docという名前通り、本来は上記のような定義情報に対してソースコード上に文書化する場所として用意された場所です。今日PHPDocとして知られているのは、phpDocumenterというドキュメント生成ツールが読み取るためのメタデータ記述形式です。

DocCommentの開始は /* ではなく /** なので気を付けてください。/* で書かれたコメントにPHPDocを書いても期待通りに読み取られません。

ドキュメントとしての標準的なDocブロックは基本的に以下のような構造をとります。

/**
 * Summary: クラスや関数の説明を一行で要約する
 *
 * Description: クラスや関数の仕様について、
 * 複数行で詳細に記述する。
 *
 * @tag-1 ...
 * @tag-2 ...
 */
function f(): void {}

@から始まるものはタグと呼ばれます。特にphpDocumenterや静的解析ツールから読み取られるタグは基本的にスペース区切りになっていて、どのような記述ができるかはタグの種類に依存します。タグ名は慣習的に @api @var @property-readのようにすべて小文字で、単語は-区切りになっています。また@phpstan-template@psalm-traceのように、互換性目的のタグやツール固有の機能には@phpstan-@psalm-いったベンダープレフィクスが前置されます。

メソッドやプロパティに自然言語で説明するまでもない十分にわかりやすい名前がついているならば、Docブロックには単にタグだけを書いても構いません。

/**
 * @tag-1 ...
 * @tag-2 ...
 */
function f(): void {}

情報量が1行で事足りる場合は、以下のように1行で書くこともできます。

class Book
{
    /** @var non-empty-string 書名 */
    private string $title;
    /** @var non-empty-array<Author> 著者一覧 */
    private array $authors;
}

タグの開始位置は基本的に独立した行に書く必要があります。同じ行に /** @tag1 @tag2 */ のように書いたとしても、ツールに適切に読み取られることは期待できません。複数のタグを書く必要があれば複数行に展開してください。

class Book
{
    /**
     * 書名
     *
     * @phpstan-readonly-allow-private-mutation
     * @var string
     * @phpstan-var non-empty-string
     */
    public string $title;

    /**
     * 著者一覧
     * 
     * @phpstan-readonly-allow-private-mutation
     * @var Author[]
     * @phpstan-var non-empty-array<Author>
     */
    private array $authors;
}

PHPStanは@var@phpstan-varのようにベンダープレフィクス違いのタグが同時に指定されているとき、@phpstan-varのみを読み取ります。一方でPHPStan以外のツールは@varを優先するため、PHPStan独自の型を問題なく共存できます。

ただし前述の通りPhpStormやIntelephenseの相互運用性も向上しているので、基本的には@phpstan-プレフィクスは書かずともnon-empty-arrayのような型を書いても大きな問題は起こりにくくなっています。

PsalmとPHPStanは完全に互換性があるわけではありませんが機能性の多くが共通しており、これらのツールは@phpstan- @psalm- プレフィクスのタグを相互に参照するように作られています。PHPStanはPsalmのlowercase-string型を実装していませんが、単なるstringにフォールバックすることで最低限機能します。

ところで、PHPDocタグに似たものにアノテーションと呼ばれるものもあります。

/**
 * @MyAnnotation
 * @MyAnnotation(myProperty="value")
 */

これらのアノテーションは慣習的にクラス名に対応しており、スペース区切りではなく()で区切られる関数呼び出し風の構文でした。この用途のものはPHP 8.0で追加されたAttributesに置き換えられるため、基本的に非推奨になりつつあります。

AttributeとPHPDocを両方とも記述したいときは、#[Attributes]を間に挟むようにしてみてください。

/** PHPDoc */
#[\ReturnTypeWillChange]
function count() { return 1; }

PHPDocで使える型

PHPDocでは以下のものを型として利用できます。

  • PHP組み込みの型名
  • クラス名・インターフェイス名
  • 定数名
  • リテラル名
  • array shapes (list shapes)
  • PHPStanが提供する型キーワード
  • @phpstan-type で宣言したローカル型名
  • 条件付き戻り値型
  • これらの型を組み合わせたユニオン型・交叉型・選言標準形型

PHPStanが提供する擬似的な型キーワードには以下の表のものを使用できます。これらの型名はクラスインポートの影響を受けず、先頭に\を付ける必要もありません。

型名 意味
resource リソース型
int<min, max> 整数範囲型
positive-int 1以上の整数
negative-int -1以下の整数
non-positive-int 0以下の整数
non-negative-int 0以上の整数
class-string<T> Tおよびそれを継承/実装したクラス名の文字列
array-key 配列のインデックス (int/string)
scalar スカラー値 (int/float/string/bool)
number 数値 (int/float)
numeric-string 数値を表わす文字列 ('12345')
numeric 数値的な値 (int/float/numeric-string)
non-empty-string 長さ1以上の文字列
non-falsy-string '''0' 以外の文字列
non-empty-array 長さ1以上の配列
list リスト(キーが0からの連番の配列)
non-empty-list 長さ1以上のリスト
list<T>, non-empty-list<T> 要素がTのリスト
empty 偽の値 (emptyでtrueと判定される値)
value-of<配列定数> 配列定数の要素のユニオン型
key-of<配列定数> 配列定数のキーのユニオン型
int-mask<定数1, 定数2, ...> 任意個の定数値のビット和とのビット積が0にならないもの
int-mask-of<定数1/定数2/...> ユニオン型のビット和とのビット積が0にならないもの
Closure(type $arg): type パラメータと戻り値の型付きクロージャ
callable(type $arg): type パラメータと戻り値の型付きの呼び出し可能型

Tは任意の型を与えられます。ただしclass-string<T>がサポートするのはクラスまたはインターフェイス名の文字列だけで、'int''string'のような文字列を与えることは不適切な用法なので気をつけてください。

これらの型名をすべて覚える必要はまったくありませんが、positive-intlist<T>non-empty-*系は覚えておくと意外に使いでがあるのではないでしょうか。class-string<T>も概念的に重要なものです。

PHPDocタグ

ここでは書ける箇所ごとに分類したPHPDocタグを列挙します。表内で @phpstan- を明記しているタグはベンダープレフィクスを省略できません。@tagだけが書かれているタグは独立した行に書くだけで機能を発揮します。[type]と書かれている箇所には型を、[$name]にはパラメータ名を記述してください。

関数・メソッド

タグ 用途
@param [type] [$name] <[description]> パラメータ(仮引数)の型
@param-out [type] [$param] 呼び出し後のパラメータのリファレンス
@phpstan-this-out self<T>
@phpstan-self-out self<T>
呼び出し後に$this (self)のテンプレート型をTに変化させる
@return [type] <[description]> 戻り値の型を記述する
@template [type] <of [TBound]> 型パラメータを宣言する
@throws [TException] <[description]> 呼び出し中に送出する可能性がある例外を列挙する
@pure / @impure メソッドを純粋関数だと宣言 (impureは非純粋関数)
@phpstan-assert [assertion] [subject] 呼び出しが正常終了したら[subject]がアサーション式を満たすことを表明
@phpstan-assert-if-true [assertion] [subject] 結果としてtrueを返したら[subject]がアサーション式を満たすことを表明
@phpstan-assert-if-false [assertion] [subject] 結果としてfalseを返したら[subject]がアサーション式を満たすことを表明

プロパティ

タグ 用途
@var [type] [<description>] プロパティの型を記述する
@readonly 読み込み専用のプロパティだと宣言
@phpstan-allow-private-mutation PHPDocで読み込み専用と宣言したプロパティにクラス内での変更を許可する
@phpstan-readonly-allow-private-mutation @readonly@phpstan-allow-private-mutation を同時に宣言する

クラス

タグ 用途
@phpstan-type [name] [type] クラス内で使えるローカルな型名を宣言
@phpstan-import-type [name] from [class] 指定したクラスからローカルな型名をクラスにインポート
@property [type] [$property] 動的プロパティを宣言
@method [type] [method(type $arg, ...)] 動的メソッドを宣言
@final クラスを擬似的にfinal宣言
@consistent-constructor 子クラスでコンストラクタが変更されないことを宣言
@template 型パラメータを宣言
@template-covariant 共変型パラメータを宣言
@template-contravariant 反変型パラメータを宣言
@extends [ParentClass<T>] 継承する親クラスの型パラメータに適用
@implements [Interface<T>] 実装するインターフェイス型パラメータに適用
@use [Trait<T>] 追加されたトレイトの型パラメータに適用

@useタグのみクラスの冒頭ではなく、クラス内のuseに追加します。

class Foo
{
    /** @use BarTrait<Buz> */
    use BarTrait;
}

戻り値の型付け

送信は厳密に、受信は寛容に」という格言があります。これはインターネット標準(RFC)でも言及されている「ロバストネス原則」あるいは「ポステルの法則」と呼ばれる考えで、通信プロトコルをアプリケーションで実装する上で、データを送信する側は仕様を厳密に満たさないことを見越して遊びをもって受け入れ、こちらからデータを送り出す際は厳密に仕様に則った方がうまくいくという経験則的なベストプラクティスです。

この考えは、型付けが曖昧な既存のPHPプロジェクトにおいても有効です。メソッドのパラメータは可能な限り柔軟に受け入れ、戻り値の型は曖昧さがなく厳密に記述した方がメソッドの呼び出し側を混乱させず利用できます。

呼び出した結果が明確、戻り値が明快なのは間違いなく良いメソッドであり、開発者体験の向上に寄与できます。よって、初めに着手すべきは戻り値の型をできるだけ適切に型付けすることです。

ただし寛容な入力の許容は、飽くまで型の曖昧さが絞り切れないアプリケーションのための過渡期的な措置であり、必要以上に緩める必要もありません。将来的にも必要十分な型付けを目指すべきでしょう。

配列の型付け

PHPに型を付ける上でもっとも気を付けるべき存在はarrayです。arrayは単一の型でありながら、arrayから取り出した値の型は不定、つまり推論できずmixed扱いになってしまいます。PHPの型宣言は配列の内容を表現する能力を持たないため、これを補うためにPHPDocを積極的に使うことが求められます。

配列には複数の用法があることを押さえましょう。

キーごとに型の決まった別のデータが格納されるパターン

  • ['id' => 1, 'name' => 'Miku']
    • array{id: int, name: string}
  • [2, 'Rin', 14]
    • array{0: int, 1: string, 2: int}
    • array{int, string, int} (キー省略可)

同じ性質のデータが繰り返されるパターン

  • ['apple', 'banana', 'orange']
    • list<string>

キーと値で同じ性質のデータが繰り返されるパターン

  • [ 'CV01' => 'Miku', 'CV02' => 'Rin', 'CV03' => 'Luka', ]
    • array<string, string>

同じ性質のデータが繰り返されるパターン

  • ['apple', 'banana', 'orange']
    • list<string>
  • [ [2, 'Rin', 14], [2, 'Len', 14], [3, 'Luka', 20], ]
    • list<array{int, string, int}>

空の配列

  • []
    • array{}

PHPでは、ほかの言語でのリスト(list, ArrayList)や連想配列辞書(dictionary, associative array, HashMap)が単一の array という型宣言でしか記述できません。そのため、PHPDocでは内部構造まで含めて詳細に記述することが重要です。

記憶しておくべきことは、同じデータが繰り返される場合はarray<...>、キーによって別の意味のデータが含まれる場合はarray{...} になることです。また、中に何も含まれない配列はarray{}で表現できます。

複雑な形状の配列で何度も利用する場合は、極力コピー&ペーストを避けたいところです。そのような場合に使えるのが@phpstan-typeです。筆者は以下のようにローカルな型名はクラス名と混同されないように小文字とsnake_case形式で表現することにしています。

/**
 * @phpstan-type book_record
 */
interface BookRepository {
    /**
     * @return list<book_record>
     */
    public function search(string word): array;

    /**
     * @return book_record
     */
    public function findById(int $id): array;
}

要素を繰り返す配列

配列の内容として決まったパターンの型が繰り返される配列は array<T> のように記述できます。

  • array<TKey, TValue>
  • array<TValue>
  • TValue[]

TKeyが省略されたとき、array-key = int|string という型がつきます。

「決まった型」と書きましたが、この型は具体的な一つの型・クラスではなくユニオン型や後述のarray shapesでも構いません。ただし、['name' => 'Miku', 'age' => 16] に対して array<string, string|int> のような型を付けたり、['name' => 'Luka', 'birthday' => '2009-01-30']string[]として型を付けるのは賢明と言えません。

配列の内容が0個の配列[]は、すべてのarray<TValue>型を満たします。空であることを許容しないnon-empty-array<TValue>という型も利用できます。

リスト型

ここまで述べたようにPHPではリストと連想配列は型宣言で区別できません。従来TValue[]という記法はarray<TValue>という記法が普及するよりも以前からphpDocumenterで仕様化されて利用されてきましたが、キーの型が指定できないという。構造的な問題があります。

[1, 2, 3]のようなキーが指定されていない連番の配列は、静的解析上ではlist型という擬似的な型で検査できます。PHPStan 1.x系ではリスト型がデフォルトで有効化されていないので、bleedingEdgeを有効化する必要があります。

TypeScriptではTValue[]Array<TValue>に違いはありませんが、PHPは連想配列が同じ型arrayでを共有しているため、list[]を区別することには意義があります。特にPHPでリスト型を区別して考えない場合、json_encode()などでエンコードすると意図しない結果になることがあります。

<?php declare(strict_types = 1);

$a = ['a', 'b', 'c', '', 'f', '0', '1'];

echo json_encode($a, JSON_PRETTY_PRINT), PHP_EOL, PHP_EOL;
// ["a", "b", "c", "", "f", "0", "1"]

$b = array_filter($a);
echo json_encode($b, JSON_PRETTY_PRINT), PHP_EOL, PHP_EOL;
// {
//     "0": "a",
//     "1": "b",
//     "2": "c",
//     "4": "f",
//     "6": "1"
// }

array_values()関数を使うと、任意の配列をリスト型を変換できます。

キーが固定されている配列

array{...} 形式はarray shapes記法と呼ばれます。

  • 基本形 array{key: Type}
  • キーが省略可能 array{key?: Type}
  • キーに空白や記号が含まれる array{'This Key?': Type}
  • キーが0から連番 array{T1, T2, T3}

Typeの部分は任意の型が書けます。PHPStan 1.9では、array shapeで定義された型に対して配列に余分なキーが存在していても警告しません。

一方、Psalmは2022年11月末にリリースされた5.0で、array shapesをデフォルトでsealed、つまり余分なキーを許さないことになりました。 array{key: Type, ...} と最後のキーの後に ... を付けることで余分なキーを許容できるunsealed array shapesになります。

本稿執筆時点ではPHPStanはPsalm 5の挙動に追従していませんが、Psalmとの互換性のためにunsealed記法に対応しています。Psalmとの相互運用を意識したライブラリなどでは、必要に応じて採用しても良いでしょう。

PHPDoc配列型の制約

型宣言でこれらの型を扱う場合は極力mixed型が紛れ込まないようにすることが望ましいですが、これらの記法には制約もあります。特に現時点でのPHPStanでは再帰的な型、つまり型定義の内容に自身の型を含むような型はサポートされていません。

この制約のために再帰的な構造にはPHPDocの配列型だけでmixedを避けて静的な型を付けることはできません。次のような深さが未確定な配列がネストするものが該当します。

  • $_GET, $_POST, $_REQUEST
  • evalの結果
  • json_decode(associative: true)の結果
  • unserialize()の結果

これらの値に厳密な型が付かないことを納得できなければ「ディレクトリのパスを指定すると、ディレクトリ内のファイルとディレクトリの内容を配列で返す関数」を実装し、型を書いてみるとよいでしょう。

一方でクラス定義では型付きプロパティとしてそのクラスを指定することで再帰的な構造が実現できます。

再帰的な構造を見出したときはarray<mixed>で型を付けておき、後述する方法で型を絞り込んでいくことが有効です。

/**
 * @return list<string>
 */
function getFruits(): array {
    return ['apple', 'banana', 'orange'];
}

/**
 * @return array{
 *     id: positive-int,
 *     name: string,
 *     age: positive-int
 * }
 */
function getUser(): array {
    return [
        'id' => 1,
        'name' => 'Miku',
        'age' => 16,
    ];
}

/**
 * @return list<array{int, string, int}>
 */
function getUsers(): array {
    return [
        [2, 'Rin', 14],
        [2, 'Len', 14],
        [3, 'Luka', 20],
    ];
}

/**
 * @return array<string, non-empty-string>
 */
function getUserIdNameMap(): array {
    return [
        'CV01' => 'Miku',
        'CV02' => 'Rin',
        'CV03' => 'Luka',
    ];
}

何も返さないメソッド

もっとも安全に付けられる型は void です。これは常に return; のように何も返さない関数・メソッドを表します。PHPではreturn;で終了した関数の結果はnull扱いですが、voidで定義されたメソッドは呼び出し結果を受け取っても無意味だと示します。

何も返さない中でも、絶対に正常に終了しないものはneverで型付けできます。

Webフレームワークによって、URLリダイレクトを行うための関数は「その場で処理を中断して強制的にリダイレクトするもの」と、「リダイレクトのためのHTTPレスポンスを作成して返すもの」に分かれます。neverは前者の処理を中断するタイプの実装に使えます。

たとえば次の関数を考えてみましょう。

/**
 * @param 201|301|302|303|307|308 $status
 * @param non-empty-string $url
 */
function redirect_to(int $status, string $url): never
{
    throw new RedirectException($status, $url);
}

// フレームワークを使わないコードではこのような実装も考えられます
function redirect_to(int $status, string $url): never
{
    header("Location: {$url}", true, $status);
    exit;
}

このようにneverで型付けすることで以下のように型の絞り込みにも利用できます。

$word = filter_var($_GET['word'] ?? null);
\PHPStan\dumpType($word);
if ($word === false) {
    redirect_to(303, '/portal');
}
\PHPStan\dumpType($word);

PHPStanがどのように型を認識しているかは\PHPStan\dumpType($word)のようにコードに書いてPHPStanで検査することで簡単に可視化できます。この際に毎回コマンドラインから検査を実行すると時間効率がよくないため、テキストエディタの画面上で自動的に実行されるようにすることは開発体験の改善に寄与します。

もうひとつの「リダイレクトのためのHTTPレスポンスを作成して返すもの」というパターンの方を考えてみましょう。

/**
 * @param 201|301|302|303|307|308 $status
 * @param non-empty-string $url
 * @pure
 */
function make_redirect(int $status, string $url): Response
{
    return (new Response($status))
        ->withHeader('Location', $url);
}

if ($word === false) {
    // リダイレクトしているつもりで、結果を使っていない
    make_redirect(303, '/portal');
}
// $wordが不正な値でも処理が続行されてしまう

今回定義した関数は@pureというタグでマークしています。これは純粋という特徴を持つ関数であることを示しています。たとえば$add = fn(int $a, $int $b) => $a + $b;という関数があったとして、$result = $add(1, 2);という呼び出しがあるとすると、$result = 3; に書き換えたとしても同じ結果になることがわかるでしょうか。これは言い換えると「$add関数は結果を作る以外の仕事をしていない」さらに言い換えると「副作用がない」と言えます。

このような純粋関数呼び出しの結果を受けとらないということは「受け取るべき結果を無視してしまっている」「必要がない無駄な関数呼び出しをしている」ということです。実際にPHPStanでこのコードを検査すると「Call to function make_redirect() on a separate line has no effect.」というエラーが出力されるはずです。このように結果を漏れなく使ってほしい関数・メソッドに@pureタグを付与することは非常に有用です。

型推論と絞り込み

PHPStanの非常に優れた特徴は、非常に柔軟な型の絞り込みができる点です。

たとえば書籍を検索するページを設計してみましょう。要件は次の通り。

  • 検索ワードは1文字以上必要
  • 検索モードは'partial', 'perfect', 'fuzzy'の三種
  • 検索結果は最大1000件まで公開

そのような書籍を検索するリポジトリをインターフェイスとして用意します。

/** 本 */
class Book {}

/**
 * @phpstan-type book_search_mode value-of<self::MODES>
 */
interface BooksRepository {
   public const MODES = ['partial', 'perfect', 'fuzzy'];

  /**
   * @param non-empty-string $word
   * @param book_search_mode $mode
   * @param 0|positive-int $offset
   * @param positive-int $limit
   * @return list<Book>
   */
  function search(string $word, string $mode, int $offset, int $limit);
}

せっかくなので、DIコンテナも用意しておきましょう。インターフェイスで。

interface ContainerInterface {
    /**
     * @template T
     * @param string|class-string<T> $id
     * @return ($id is class-string<T> ? T : mixed)
     */
    public function get(string $id);
}

($id is class-string<T> ? T : mixed)は条件付き戻り値型で、クラス名文字列を渡すと、それに対応するインスタンスが取得できるということになります。

DIコンテナとリポジトリとかいう、アプリケーションに必要っぽい枠組みができたので、道具をお膳立てし(たつもりになっ)て使ってみましょう。

// $container が定義済みだと信じ込ませる
assert(isset($container) && $container instanceof ContainerInterface);

$repo = $container->get(BooksRepository::class);
$per_page = 50;

$offset = (($_GET['page'] ?? 1) - 1) * $per_page;
$result = $repo->search($_GET['word'], $_GET['mode'], $offset, $per_page);

途中まではしっかりと型がついているのですが、厳密に検査すると$repo->search()に渡している値の型が全然だめだめだと指摘されます。

Parameter #1 $word of method BooksRepository::search() expects non-empty-string, mixed given.
Parameter #2 $mode of method BooksRepository::search() expects 'fuzzy'|'partial'|'perfect', mixed given.	
Parameter #3 $offset of method BooksRepository::search() expects int<0, max>, (float|int) given.

つまり、$_GETから取り出した値はすべてmixed、つまり型なしだと叱られます。

$word = filter_var($_GET['word'] ?? '');
$mode = filter_var($_GET['mode'] ?? 'partial');
$page = filter_var($_GET['page'] ?? 1, FILTER_VALIDATE_INT, [
    'options' => ['min_range' => 1, 'max_range' => 50],
]);

if ($page === false ||
    in_array($word, ['', false], true) ||
    !in_array($mode, BooksRepository::MODES, true)
) {
    echo '再検索してください';
    return;
}
$offset = ($page - 1) * $per_page;
\PHPStan\dumpType(compact('word', 'mode', 'page', 'offset'));
$result = $repo->search($word, $mode, $offset, $per_page);

このコードは完全に机上の空論なのですが、理想的なパラメータと安全な入力値チェックが実現できているのではないでしょうか。

https://phpstan.org/r/ba99b343-7cf0-4070-a5d1-2bf4bd312144

どうしても付かない型に

PHPに型を付けるということは、ぼんやりとした輪郭線しかない関数に明確なAPIを定め、外部入力やデータベースから湧き出す型のないデータに適切な型を与えることです。データベースが検索用のlDとして数値を要求するからと言って(int)$_GET['id']のような乱暴なキャストをするのは非常にリスキーです。結局のところは適切なバリデーションを経て使いやすいデータ構造にマッピングすることが肝要です。これは、人間が持てあまして手の付けられなくなったプログラムから人類の支配権を回復させるための営みだとも言えます。

PHPStanやPsalmでは is_int()is_string() などの関数でチェックすることで変数にその型を認識させられますが、 @phpstan-assert@phpstan-assert-if-trueタグを持つメソッドを定義することで、パラメータとして渡された変数や $this のプロパティにの型を具体化するメソッドを簡単に自作できるようになっています。本稿においては紙幅の都合で活用方法を掲載できませんでしたが、慣れれば非常に簡単に利用できます。

PHPStanには組み込み関数 filter_var() 関数の定数とオプションによって適切な型が付けられる拡張が内蔵されています。外部ライブラリやフレームワークの機能についてはExtension Libraryに掲載されている拡張をインストールすることで適切な型がつくようになると期待できます。

https://phpstan.org/user-guide/extension-library

PHPStanは拡張機能タイプで一覧されているように多様な機能拡張ができるように設計されています。検査時にエラーが起こらないように慎重に実装することが求められるので初心者向きではありませんが、PHPに慣れた人は気軽にチャレンジしてみましょう。

https://phpstan.org/developing-extensions/extension-types

型付けはDynamic return type extensionsという種類の拡張で実現できます。近年はジェネリクスや条件付き戻り値型、@phpstan-assertといったPHPDocベースの“ノーコード”型付け機能が充実してきたので簡単な用途では拡張不要で型付けが可能になりましたが、アプリケーションの文脈に沿った適切な型を付けることは、まだまだ有意義な技術です。

最後になりますが、コードの途中に /** @var type $var */ のように書くことで、局所的に変数に型を認識させることもできます。通常の@varタグはプロパティ宣言に対する記述であるのに対して、ローカル変数はコードの途中に記述するインライン@varと呼びます。アサーションなしで型を付けられる、PHPDocが必要な型を付けやすいという特徴もありますが、あらゆる保証なしで型を信じ込ませる力があるので無批判に使えるものではありません。ありていに言ってバッドノウハウです。

Discussion