開発ブログ

株式会社Nextatのスタッフがお送りする技術コラムメインのブログ。

電話でのお問合わせ 075-744-6842 ([月]-[金] 10:00〜17:00)

  1. top >
  2. 開発ブログ >
  3. 【静的解析】PHPStanのstubファイルを書いてみよう
no-image

【静的解析】PHPStanのstubファイルを書いてみよう

こんにちは、でぃーほりです。

PHPerのみなさま、静的解析されていますか?
筆者はもっぱらPHPStan、LaravelプロジェクトではLarastanを使用しています。
最近は、不具合修正の合間に静的解析エラーを潰してコツコツレベル上げするのが日課になっています。
現在使用しているバージョンでは解析レベル8(max)でようやくnull絡みのエラーを検出できます。
レベル5からスタートし、死んだ魚の眼をしながら7->8のレベル上げ作業中です…


さて、LaravelのCollectionまわりでこんな解析エラーが出て困ったことはありませんか?

/** @var \Illuminate\Support\Collection<int, User> $collection */
$collection = collect(
    [
        $user1,
        $user2,
        $user3,
    ]
);

$collection->groupBy('status')
    ->map(
        function (\Illuminate\Support\Collection $group) {
            // do stuff...
        }
    );
 ------ ----------------------------------------------------------------------- 
  Line   xxx.php
 ------ ----------------------------------------------------------------------- 
  59     Parameter #1 $callback of method                                       
         Illuminate\Support\Collection<int,Illuminate\Foundation\Auth\User>::m  
         ap() expects callable(Illuminate\Foundation\Auth\User, int): mixed,    
         Closure(Illuminate\Support\Collection): mixed given.   
 ------ -----------------------------------------------------------------------

groupBy後のコレクションの次数が適切に上がらず、後続の処理で型が合いません。

@varで型情報を上書きしたり@phpstan-ignore-next-lineで黙らせたりすることはできますが、臭い物に蓋、本末転倒と言わざるを得ません。

// bad hack
/** @phpstan-ignore-next-line */
$collection->groupBy('status')
    ->map(
        function (\Illuminate\Support\Collection $group) {
            // do stuff...
        }
    );

こういった静的解析エラーをignoreせずに解消する方法を紹介します。

条件

  • Laravel 6.x
  • Larastan v0.5.7
  • PHPStan 0.12.19
  • 解析レベル 7

そもそもどうやって解析しているのか

基本は実装 + PHPDoc

Laravel 6.x
Illuminate\Support\Collection@groupBy

/**
 * Group an associative array by a field or using a callback.
 *
 * @param array|callable|string $groupBy
 * @param bool $preserveKeys
 * @return static
 */
public function groupBy($groupBy, $preserveKeys = false)

Laravelは歴史的経緯と後方互換のため、実装側に型情報がなくPHPDocがメインになることが多いです。

さて、この @return static が曲者で、

/** @var \Illuminate\Support\Collection<int, User> $collection */
$collection = collect(
    [
        $user1,
        $user2,
        $user3,
    ]
);

$collection->groupBy('status') // \Illuminate\Support\Collection<int, User> $collection

上記サンプルコードでは 「UserのCollection」を「UserのCollectionのCollection」にグルーピングすべきところが、「UserのCollection」のままとなってしまいます。
型情報の修正が必要です。

stubファイルで型情報を補う

こういった問題を「型情報を後付けする」ことで対処するために、PHPStanには「stubファイル」という仕組みがあります。
自作することもできますし、既存のものをインストールして使うこともできます。
Larastanでは、LaravelのEloquentまわりやCollectionのstubファイルを提供しています。

vendor/nunomaduro/larastan/stubs/Collection.stub 抜粋

<?php

namespace Illuminate\Support;

/**
 * @template TKey
 * @template TValue
 * @implements \ArrayAccess<TKey, TValue>
 * @implements Enumerable<TKey, TValue>
 */
class Collection implements \ArrayAccess, Enumerable
{
    /**
     * @param  callable|null  $callback
     * @param  mixed  $default
     * @return TValue|null
     */
    public function first(callable $callback = null, $default = null){}

...

stubファイルを書き換えてみよう

vendor/nunomaduro/larastan/stubs/Collection.stub に下記を追記します:

/**
 * @param (callable|string)[]|callable|string $groupBy
 * @param bool $preserveKeys
 * @return static<int|string,static<TKey,TValue>>
 */
public function groupBy($groupBy, $preserveKeys = false) {}

groupByの戻り値がCollectionで1層包まれるように型定義を修正してみました:

@return static<int|string,static<TKey,TValue>>

再度PHPStan(Larastan)を実行してみます。

 [OK] No errors                 

解析エラーが解消しました。

Let's Contribute!

残念ながら、上記の修正は下記の点において不十分で、そのままLarastanにPRを出すことはできません。

  • \Illuminate\Support\Collection を継承している \Illuminate\Database\Eloquent\Collection でうまく機能しない
  • $groupBy 引数に配列を渡して2重3重にグルーピングしたときに結局型が合わなくなる

おそらく、これがLarastanのCollection.stubファイルでgroupByメソッドがサポートされていない理由なのだと思います。

しかしながら、Collectionのstubが整備され始めたのは今年2020に入ってから。
nunomaduro/larastan@94c862b
まだまだ発展途上なのです。
型が未整備の部分はまだまだあるはずです。皆さん奮ってコントリビュートしましょう!

まとめ

  • PHPStanではstubファイルで型情報を付与できる
  • まだまだ発展途上。皆で良くしていこう
TOPに戻る