PHPStanの導入メモ

今回はComposer(PHPのパッケージマネージャ)でインストールできる、PHPStanというPHPの静的解析ツールを使用してみます。このツールを使用するとバグの早期発見や潜在的なセキュリティの脆弱性等が発見できる様です。解析レベル(ルールレベル)は 0~10 ('25/02/12現在) まであり、数値が大きいほど解析が厳密になる様です。

インストールするだけでは面白くないので、前々回ご紹介したPHP版のYelpCampに適用してみます。折角なのでレベル10を目指して、過程をメモとして残します。(実行環境はWindows版のVSCodeを使用しました。)

目次

PHPStanのインストールと初期設定

こちらのサイト様を参考にして、PHPStanのインストールと設定を行っていきます。VSCodeでCtrl + @と入力して、表示されたターミナル画面に以下の様に入力してPHPStanパッケージをインストールします。(Composer自体のインストール方法は、WSL2の場合はこちらの記事を、MAMPの場合はこちらの記事をご参照ください。)

composer require --dev phpstan/phpstan

次に「phpstan.neon」というファイルを作成して、以下の様に記述します。

parameters:
    paths:
        - .
    level: 0

以上でインストールと設定は終了です。VSCodeのターミナル画面に以下のコマンドを入力するとPHPStanが実行されます。

vendor/bin/phpstan analyse

特定のフォルダのみを検証したい場合は、「vendor/bin/phpstan analyse <検証したいフォルダ名>」の様に入力します。

ルールレベル0を検証します

VSCodeのターミナル画面に「vendor/bin/phpstan analyse」と入力してPHPStanを実行します。(何度も同じコマンドを入力するのは手間がかかるので、ターミナル画面で上矢印キー(↑)を押すと過去に入力したコマンドが表示されます。)

・--generate-baseline

実際に実行すると何かエラーが出ていますが、よく見ると「vendor」フォルダ内のファイルがエラーになっている様です。Composerのパッケージを修正する訳に行きませんので、これらのエラーを発生させない様にする必要があります。以下のコマンドを実行すると「phpstan-baseline.neon」という、現在発生しているエラー内容が記述されているファイルが作成されます。

vendor/bin/phpstan analyse --generate-baseline

その後、先ほど作成した「phpstan.neon」ファイルに以下の設定を追加すると、以降の検証で現在発生しているエラーを無視してくれます。(無視してくれないエラーもある様です。)

includes:
    - phpstan-baseline.neon
parameters:
    paths:
        - .
    level: 0
・excludePaths

フォルダ自体を検証する必要がない場合は、phpstan.neonファイルに「excludePaths」という設定項目を追加すると指定されたフォルダ内は検証しなくなる様です。フォルダが複数ある場合は改行して同じように「- <フォルダ名>」と記述すればいい様です。

includes:
    - phpstan-baseline.neon
parameters:
    level: 0
    paths:
        - .
    excludePaths:
        - vendor

レベル0の定義は以下の様になっています。(DeepL翻訳です。)

レベル0

基本的なチェック、未知のクラス、未知の関数、$thisで呼び出される未知のメソッド、メソッドや関数に渡される引数の数が間違っている、常に未定義の変数。

ルールレベル1を検証します

phpstan.neonファイルを以下の様に書き換えて、「vendor/bin/phpstan analyse」コマンドを実行します。

includes:
    - phpstan-baseline.neon
parameters:
    level: 1
    paths:
        - .
    excludePaths:
        - vendor

エラーが12個発生しました。

・variable.undefined

最初のエラーから見ていきます。どうやら変数が定義されていないというエラーの様です。

 ------ ------------------------------------ 
  Line   db\campgrounds.query.php
 ------ ------------------------------------ 
  :28    Variable $db might not be defined.  
         🪪  variable.undefined

該当箇所を見てみると以下の様になっています。

    public static function findByIdAndDelete($id)
    {
        try {
            $success = false;
            $db = new DataSource;
            $db->begin();

            if (!ImageQuery::findByCampIdAndDelete($id)) return false;
            if (!ReviewQuery::findByCampIdAndDelete($id)) return false;
            $sql = 'delete from campgrounds where id = :id;';
            $success= $db->execute($sql, [':id' => $id]);

            return $success;

        } finally {
            if ($success)
                $db->commit();
            else
                $db->rollback();
        }
        return false;
    }

確かに「$db」という変数は他の言語ではスコープ(変数の有効範囲)の問題でエラーになりそうです。$db変数の定義を以下の様に修正しました。

    public static function findByIdAndDelete($id)
    {
        $db = new DataSource;

        try {
            $success = false;
            $db->begin();

            if (!ImageQuery::findByCampIdAndDelete($id)) return false;
            if (!ReviewQuery::findByCampIdAndDelete($id)) return false;
            $sql = 'delete from campgrounds where id = :id;';
            $success = $db->execute($sql, [':id' => $id]);

            return $success;
        } finally {
            if ($success)
                $db->commit();
            else
                $db->rollback();
        }
        return false;
    }

他にも発生している同じエラー(variable.undefined)を修正して、再度チェックします。

・nullCoalesce.variable

エラーが5個になりました。先頭のエラーは以下の様になっています。

 ------ ------------------------------------------------------------------------------ 
  Line   utils\YelpCampError.php
 ------ ------------------------------------------------------------------------------ 
  :16    Variable $redirect_url on left side of ?? always exists and is not nullable.  
         🪪  nullCoalesce.variable

該当箇所は以下の様になっています。どうやらNull 合体演算子(??)と呼ばれる演算子の左側がヌルにならないので、演算子は必要ないというエラーの様です。

class YelpCampError extends Error
{
    public string $redirect_url;

    public function __construct(string $message = "", int $code = 0, string $redirect_url = '')
    {
        parent::__construct(message: $message, code: $code);
        $this->$redirect_url = $redirect_url ?? '';
    }
}

素直に演算子以降を削除してエラーを修正しました。

class YelpCampError extends Error
{
    public string $redirect_url;

    public function __construct(string $message = "", int $code = 0, string $redirect_url = '')
    {
        parent::__construct(message: $message, code: $code);
        $this->$redirect_url = $redirect_url;
    }
}

エラーが4個になりました。

・constant.notFound

先頭のエラーは以下の様になっています。

 ------ --------------------------------------------------------------------- 
  Line   utils\middleware.php
 ------ --------------------------------------------------------------------- 
  :24    Constant CURRENT_PATH not found.
         🪪  constant.notFound
         💡 Learn more at https://phpstan.org/user-guide/discovering-symbols  

該当箇所は以下の様になっています。この関数内の「CURRENT_PATH」定数が見つからないというエラーの様です。ちなみにこの定数は、ログインが必要なページにアクセスされた場合に、警告メッセージを表示してログイン完了後に最後にアクセスされていたページにリダイレクトするためのURLを保持しています。

function isLoggedIn()
{
    $user = UserSchema::getSession();

    if (!isset($user)) {
        ReturnTo::push(CURRENT_PATH);
        Flush::push(Flush::ERROR, 'ログインしてください');
        redirect('/login');
    }

    return $user;
}

この定数は以下の場所で定義されています。

function get_method(): array
{
    $req_method = strtoupper($_SERVER['REQUEST_METHOD']);
    $url = parse_url(CURRENT_URI);
    $path = rtrim(str_replace(BASE_CONTEXT_PATH, '', $url['path']), '/');
    $query = $url['query'] ?? '';

    define('CURRENT_PATH', $path);

    $match = [];
    $f = fn($a) => preg_match('/method=(put|delete)$/i', $a, $match) ? strtoupper($match[1]) : '';
    $items = array_reverse(explode('?', $query));
    $items = array_values(array_diff(array_map($f, $items), ['']));

    if (is_array($items) && count($items) > 0) {
        $method = HttpMethod::tryParse($items[0]);
    } else {
        $method = HttpMethod::tryParse($req_method);
    }

    return compact('method', 'path', 'query');
}

結構な荒業です。これでは見つけられません。PHPは表現の自由度が高いのでついつい甘えていました。この場所での定義をやめて関数の引数で渡す様に修正しました。

function isLoggedIn(?string $current_path = null)
{
    $user = UserSchema::getSession();

    if (!isset($user)) {
        if (!empty($current_path)) ReturnTo::push($current_path);
        Flush::push(Flush::ERROR, 'ログインしてください');
        redirect('/login');
    }

    return $user;
}

残りのエラーは3個になりました。

・empty.variable

残りのエラーは以下の様になっています。empty関数の引数に指定している変数(の値)が常に存在するので、false(falsy)になることがないというエラーの様です。

 ------ ----------------------------------------------------------------- 
  Line   utils\middleware.php
 ------ ----------------------------------------------------------------- 
  :34    Variable $campground in empty() always exists and is not falsy.  
         🪪  empty.variable
  :43    Variable $campground in empty() always exists and is not falsy.  
         🪪  empty.variable
  :43    Variable $review in empty() always exists and is not falsy.      
         🪪  empty.variable

該当箇所は以下の様になっています。

function isAuthor(CampGroundSchema $campground, UserSchema|null $user)
{
    if (!empty($campground) && !$campground->isOwner($user)) {
        ReturnTo::clearSession();
        Flush::push(Flush::ERROR, 'そのアクションの権限がありません');
        redirect("/campgrounds/{$campground->id}");
    }
}

function isReviewAuthor(CampGroundSchema $campground, ReviewSchema $review, UserSchema|null $user)
{
    if (!empty($campground) && !empty($review) && !$review->isOwner($user)) {
        ReturnTo::clearSession();
        Flush::push(Flush::ERROR, 'そのアクションの権限がありません');
        redirect("/campgrounds/{$campground->id}");
    }
}
・Null許容型

しかし、実際には引数の $campground は null になる場合があるので、Null許容型(nullable)という型を使用して以下の様に修正しました。

function isAuthor(?CampGroundSchema $campground, ?UserSchema $user)
{
    if (!empty($campground) && !$campground->isOwner($user)) {
        ReturnTo::clearSession();
        Flush::push(Flush::ERROR, 'そのアクションの権限がありません');
        redirect("/campgrounds/{$campground->id}");
    }
}

function isReviewAuthor(?CampGroundSchema $campground, ?ReviewSchema $review, ?UserSchema $user)
{
    if (!empty($campground) && !empty($review) && !$review->isOwner($user)) {
        ReturnTo::clearSession();
        Flush::push(Flush::ERROR, 'そのアクションの権限がありません');
        redirect("/campgrounds/{$campground->id}");
    }
}

以上でレベル1は何とかクリアできました。レベル1の定義は以下の様になっています。(DeepL翻訳です。)

レベル1

未定義の変数、未知のマジックメソッド、__call と __get を持つクラスのプロパティがある可能性があります。

ルールレベル2を検証します

phpstan.neonファイルを書き換えて、「vendor/bin/phpstan analyse」コマンドを実行します。

・staticClassAccess.privateMethod

エラーが1個発生しました。エラーの内容は以下の様になっています。何か関数の呼び出し方法に問題がある様です。

 ------ --------------------------------------------------------------------- 
  Line   utils\flush.php
 ------ --------------------------------------------------------------------- 
  :18    Unsafe call to private method utils\Flush::init() through static::.  
         🪪  staticClassAccess.privateMethod

該当箇所は以下の様になっています。

class Flush extends Session
{
    protected static $SESSION_NAME = '_msg';
    public const ERROR = 'error';
    public const INFO = 'info';
    public const DEBUG = 'debug';

    public static function push($type, $msg)
    {
        if (!is_array(static::getSession())) {
            static::init();
        }

        $msgs = static::getSession();
        $msgs[$type][] = $msg;
        static::setSession($msgs);
    }

    public static function pop()
    {
        try {
            return static::getSessionAndFlush() ?? [];
        } catch (Throwable $e) {
            Flush::push(Flush::DEBUG, $e->getMessage());
            Flush::push(Flush::DEBUG, 'Flush::popで例外が発生しました。');
        }
        return [];
    }

    private static function init()
    {
        static::setSession([
            static::ERROR => [],
            static::INFO => [],
            static::DEBUG => []
        ]);
    }
}
・遅延静的束縛

ソースを見ても良く分からないので、「phpstan Unsafe call to private method through static::.」というキーワードで検索したところ、こちらのサイト様に説明がありました。「static::」と書いて自分のクラス内の静的関数を呼び出すと、PHPの遅延静的束縛という機能によって、クラスの構成によっては違うクラスの関数が呼び出されてしまう可能性がある様です。具体的にはこのクラスを継承しているクラスがあり、そのクラス内に「init」という静的関数があった場合に、親クラス(Flushクラス)のpush関数が子クラスのinit関数を呼び出してしまう可能性があります。子クラスの同じ名前の関数呼び出しはC++やC#の仮想関数(Virtual)と同じと思われますのでとても便利な機能ですが、この場合は他のクラスのプライベート関数を呼び出してしまう可能性があるのでエラーになったものと思われます。

話が長くなりましたが、この場合は確実に自分のクラス内の静的関数を呼び出せる様に、「static::」を「self::」に置き換えてあげればいい様です。

static::init();
↓
self::init();

レベル2の定義は以下の様になっています。(DeepL翻訳です。)

レベル2

未知のメソッドを ($this だけでなく) すべての式でチェックし、PHPDocs を検証する。

ルールレベル3を検証します

phpstan.neonファイルを書き換えて、「vendor/bin/phpstan analyse」コマンドを実行します。

このレベルではエラーがありませんでした。レベル3の定義は以下の様になっています。(DeepL翻訳です。)

レベル3

戻り値の型、プロパティに割り当てられた型

ルールレベル4を検証します

phpstan.neonファイルを書き換えて、「vendor/bin/phpstan analyse」コマンドを実行します。

・deadCode.unreachable

エラーが13個発生しました。最初のエラー内容は以下の様になっています。到達できないコードがある様です。

 ------ -------------------------------------------------------------------------------------- 
  Line   db\campgrounds.query.php
 ------ -------------------------------------------------------------------------------------- 
  :32    Unreachable statement - code above always terminates.
         🪪  deadCode.unreachable

実際のコードは以下の様になっています。finallyブロックの後は通らない様なので、不要なreturn文を削除しました。

    public static function findByIdAndDelete($id)
    {
        $db = new DataSource;

        try {
            $success = false;
            $db->begin();

            if (!ImageQuery::findByCampIdAndDelete($id)) return false;
            if (!ReviewQuery::findByCampIdAndDelete($id)) return false;
            $sql = 'delete from campgrounds where id = :id;';
            $success = $db->execute($sql, [':id' => $id]);

            return $success;
        } finally {
            if ($success)
                $db->commit();
            else
                $db->rollback();
        }
        return false;
    }
・identical.alwaysFalse

次のエラー内容は以下の様になっています。「===」演算子の使い方が間違っている様です。

 ------ -------------------------------------------------------------------------------------- 
  Line   db\campgrounds.query.php
 ------ -------------------------------------------------------------------------------------- 
  :131   Strict comparison using === between int<1, max> and 0 will always evaluate to false.  
         🪪  identical.alwaysFalse
         💡 Because the type is coming from a PHPDoc, you can turn off this check by
            setting treatPhpDocTypesAsCertain: false in your
            phpstan.neon.

実際のコードを見てみると、配列の個数のチェックが冗長だったようです。

if (empty($result) || count($result) === 0) return null;
↓
if (empty($result)) return null;
・greater.alwaysTrue

同様のエラーを修正します。

 ------ ----------------------------------------------------------------------------- 
  Line   db\reviews.query.php
 ------ ----------------------------------------------------------------------------- 
  :57    Comparison operation ">" between int<1, max> and 0 is always true.
         🪪  greater.alwaysTrue
         💡 Because the type is coming from a PHPDoc, you can turn off this check by  
            setting treatPhpDocTypesAsCertain: false in your
            phpstan.neon.
return (!empty($result) && count($result) > 0) ? $result[0] : null;
↓
return (!empty($result)) ? $result[0] : null;
・function.alreadyNarrowedType

$item変数はすでに配列になっているので、is_arrayは不要な様です。

 ------ ----------------------------------------------------------------------------- 
  Line   utils\helper.php
 ------ ----------------------------------------------------------------------------- 
  :74    Call to function is_array() with list<string> will always evaluate to true.  
         🪪  function.alreadyNarrowedType
$items = array_values(array_diff(array_map($f, $items), ['']));
if (is_array($items) && count($items) > 0) {
    $method = HttpMethod::tryParse($items[0]);
} else {
    $method = HttpMethod::tryParse($req_method);
}
↓
$items = array_values(array_diff(array_map($f, $items), ['']));
if (count($items) > 0) {
    $method = HttpMethod::tryParse($items[0]);
} else {
    $method = HttpMethod::tryParse($req_method);
}
・return.unusedType

以降は順不同で直し易そうなエラーから修正していきます。関数の戻り値からNullを省略できる様です。

 ------ ----------------------------------------------------------------------------------- 
  Line   utils\helper.php
 ------ ----------------------------------------------------------------------------------- 
  :36    Function get_item() never returns null so it can be removed from the return type.  
         🪪  return.unusedType
function get_item(mixed $array, string $key): string|null
{
    $value =  (is_array($array) && array_key_exists($key, $array)) ? $array[$key] : '';
    return htmlspecialchars($value);
}
↓
function get_item(mixed $array, string $key): string
{
    $value =  (is_array($array) && array_key_exists($key, $array)) ? $array[$key] : '';
    return htmlspecialchars($value);
}
・ternary.alwaysTrue

3項演算子の条件が常にtrueというエラーの様です。

 ------ -------------------------------------------- 
  Line   models\campground.php
 ------ -------------------------------------------- 
  :157   Ternary operator condition is always true.  
         🪪  ternary.alwaysTrue

実際にコードを見てみると以下の様になっています。

define('IMAGICK', IS_WINDOWS ? 'magick' : 'convert');
・@phpstan-ignore

「IS_WINDOWS」定数は設定を切り替えるための変数(定数)になっています。他に記述する方法が無いのでエラーを抑制することにします。以下のPHPDocコメントを追加します。

// @phpstan-ignore ternary.alwaysTrue
define('IMAGICK', IS_WINDOWS ? 'magick' : 'convert');
・empty.expr

empty関数が常に失敗するというエラーの様です。

 ------ ------------------------------------------------------- 
  Line   utils\helper.php
 ------ ------------------------------------------------------- 
  :85    Expression in empty() is always falsy.
         🪪  empty.expr

実際にコードを見てみると以下の様になっています。

function forwardGeocode($location)
{
    if (empty(MAPBOX_TOKEN)) return '';


    $mapbox_api = 'https://api.mapbox.com/search/geocode/v6/forward';
    $params = array(
        'q' => $location,
        'proximity' => 'ip',
        'access_token' => MAPBOX_TOKEN
    );
    $query = http_build_query($params);
    $url = "{$mapbox_api}?{$query}";

    if (IS_WINDOWS)
        $response = forwardGeocodeWin($url);
    else
        $response = forwardGeocodeLinux($url);

    if (empty($response)) return null;
    $result = json_decode($response, true);
    if (empty($result) || !is_array($result['features']) || count($result['features']) === 0) return '';
    $geometry = json_encode($result['features'][0]['geometry'], JSON_UNESCAPED_UNICODE);

    return $geometry;
}
・if.alwaysTrue

97行目も同様のエラーになっている様なので、まとめて抑制します。

  Line   utils\helper.php
 ------ ------------------------------ 
  :97    If condition is always true.  
         🪪  if.alwaysTrue
function forwardGeocode($location)
{
    // @phpstan-ignore empty.expr
    if (empty(MAPBOX_TOKEN)) return '';

    $mapbox_api = 'https://api.mapbox.com/search/geocode/v6/forward';
    $params = array(
        'q' => $location,
        'proximity' => 'ip',
        'access_token' => MAPBOX_TOKEN
    );
    $query = http_build_query($params);
    $url = "{$mapbox_api}?{$query}";

    // @phpstan-ignore if.alwaysTrue 
    if (IS_WINDOWS)
        $response = forwardGeocodeWin($url);
    else
        $response = forwardGeocodeLinux($url);

    if (empty($response)) return null;
    $result = json_decode($response, true);
    if (empty($result) || !is_array($result['features']) || count($result['features']) === 0) return '';
    $geometry = json_encode($result['features'][0]['geometry'], JSON_UNESCAPED_UNICODE);

    return $geometry;
}

他のエラーもすべて定数がらみなので、ご説明は割愛します。

------ ------------------------------ 
  Line   views\error.php
 ------ ------------------------------ 
  :23    If condition is always true.  
         🪪  if.alwaysTrue
 ------ ------------------------------ 

 ------ --------------------------------------------- 
  Line   views\partials\flush.php
 ------ --------------------------------------------- 
  :14    Negated boolean expression is always false.  
         🪪  booleanNot.alwaysFalse
  :14    Result of && is always false.
         🪪  booleanAnd.alwaysFalse

何とか修正(抑制?)できました。レベル4の定義は以下の様になっています。(DeepL翻訳です。)

レベル4

基本的なデッドコード・チェック-常に偽のinstanceofやその他の型チェック、dead else分岐、return後の到達不可能なコードなど。

ルールレベル5を検証します

phpstan.neonファイルを書き換えて、「vendor/bin/phpstan analyse」コマンドを実行します。

このレベルではエラーがありませんでした。レベル5の定義は以下の様になっています。(DeepL翻訳です。)

レベル5

メソッドや関数に渡される引数の種類のチェック

ルールレベル6を検証します

phpstan.neonファイルを書き換えて、「vendor/bin/phpstan analyse」コマンドを実行します。

・missingType.return

いきなり厳しくなりました。エラーが全部で164個あります。最初のエラーは関数の戻り値の型が指定されていないというエラーの様です。

 ------ ---------------------------------------------------------------------------------- 
  Line   controllers\campgrounds.php
 ------ ---------------------------------------------------------------------------------- 
  :13    Function controller\campground\createCampground() has no return type specified.   
         🪪  missingType.return

指摘された箇所のソースコードをみると以下の様になっています。

function createCampground()
{
    $user = \middleware\isLoggedIn('/campgrounds/');

    $campground = CampGroundSchema::getModel();
    $campground->geometry = forwardGeocode($campground->location);
    $campground->author = $user->username;
    $campground =  CampGroundQuery::save($campground);
    if (empty($campground)) {
        Flush::push(Flush::ERROR, 'キャンプ場が登録できませんでした');
        redirect("/campgrounds");
    }
    $id = $campground->id;
    Flush::push(Flush::INFO, '登録しました');
    redirect("/campgrounds/{$id}");
}

上の関数は何も返していないので、以下の様に修正しました。同様にすべての関数に戻り値を指定しました。

function createCampground(): void
・missingType.parameter

まだまだエラーが100個以上あります。次のエラーは関数の引数の型が指定されていないというエラーの様です。すべての関数の引数に型指定を追加します。

 ------ ---------------------------------------------------------------------------------- 
  Line   controllers\campgrounds.php
 ------ ---------------------------------------------------------------------------------- 
  :30    Function controller\campground\updateCampground() has parameter $id with no type  
         specified.
         🪪  missingType.parameter
・missingType.property

プロパティ(クラス内の変数)にも型指定が必要な様です。

 ------ -------------------------------------------------------------------------------------- 
  Line   db\datasource.php
 ------ -------------------------------------------------------------------------------------- 
  :53    Property db\DataSource::$sqlResult has no type specified.
         🪪  missingType.property

以下の様に修正しました。

/**
 * MySQL接続クラス定義
 */
class DataSource
{
    private PDO $conn;
    private bool $sqlResult;
    public const CLS = 'cls';
・missingType.iterableValue

関数のすべての引数と戻り値の型を指定してもまだ、引数の型が指定されていない(missingType.parameter)というエラーが発生します。また、配列を返す関数の戻り値の型が指定されていないというエラーも発生しています。

 ------ -------------------------------------------------------------------------------------- 
  Line   db\datasource.php
 ------ -------------------------------------------------------------------------------------- 
  :69    Method db\DataSource::select() has parameter $params with no type specified.
         🪪  missingType.parameter
  :69    Method db\DataSource::select() return type has no value type specified in iterable    
         type array.
         🪪  missingType.iterableValue
         💡 See:
            https://phpstan.org/blog/solving-phpstan-no-value-type-specified-in-iterable-type

実際にコードを見てみると以下の様になっています。引数に配列($params)があり、戻り値もarrayになっています。

    public function select(string $sql = "", $params = [], string $type = '', string $cls = ''): array
    {
        $stmt = $this->executeSql($sql, $params);

        if ($type === static::CLS) {

            return $stmt->fetchAll(PDO::FETCH_CLASS, $cls);
        } else {

            return $stmt->fetchAll();
        }
    }

どちらのエラーも「配列の中身の構造が定義されていない。」という意味のエラーの様ですが、PHPの言語仕様だけではどうにもできないので、コメント(PHPDoc)で指定する必要がある様です。上の関数の場合は以下の様なPHPDocコメントを追加するとエラーが出なくなりました。

    /**
     * @param array<string, int|string> $params
     * @return array<mixed>
     */
    public function select(string $sql = "", $params = [], string $type = '', string $cls = ''): array
    {
        $stmt = $this->executeSql($sql, $params);

        if ($type === static::CLS) {

            return $stmt->fetchAll(PDO::FETCH_CLASS, $cls);
        } else {

            return $stmt->fetchAll();
        }
    }
・ジェネリクス記法

上のコメントにある「array<...>」という書き方は、ジェネリクス記法と呼ばれている様です。<>内がカンマで区切られている場合は、最初がキーの型で2番目が値の型を表しています。それぞれの型は縦棒(|)で区切って複数の型を指定できます。ジェネリクス記法は主に要素の型に着目した書き方ですが、連想配列の様にそれぞれのキーの名前まで表現したい場合は以下の形式でも記述できる様です。関数の引数や戻り値が、ここで指定したキーの名称や値のタイプと一致しない場合はエラーになります。

/**
 * @return array{geometry: string, title: string, location: string, properties: array{popupMarkup: string}}
 */
・@var

プロパティ(クラス内の変数)の場合は「@var」キーワードを使用して以下の様に記述できる様です。

/**
 * @var array<string>
 */
public array $deleteImages;
・argument.type

いよいよエラーも残り9個になりました。エラーのタイプも2種類のみなので一気に片づけます。最初のエラーは整数型の引数を要求する関数に文字列を渡しているというエラーの様です。

 ------ ------------------------------------------------------------------------------------- 
  Line   routes\campgrounds.php
 ------ ------------------------------------------------------------------------------------- 
  :31    Parameter #1 $id of function view\campgrounds\show\index expects int, string given.  
         🪪  argument.type

該当箇所を見てみると以下の様になっています。関数の引数の型を設定して行く時に、各キャンプ場のIDを文字列から数値に変更したため、27行目のstrval関数で文字列に変換する必要が無くなった様です。

        case preg_match('/campgrounds\/([0-9]+)$/i', $rpath, $match):
            $id = strval($match[1]);

            switch ($method) {
                case HttpMethod::GET:
                    \view\campgrounds\show\index($id);
                    break;

                case HttpMethod::PUT:
                    \controller\campground\updateCampground($id);
                    break;

                case HttpMethod::DELETE:
                    \controller\campground\deleteCampground($id);
                    break;
            }
            break;

どちらに合わせてもいいのですが、必ず数値型の文字列が来るので以下の様にキャストに変更しました。

$id = (int)$match[1];
・return.type

最後のエラーは定義した関数の戻り値と実際の戻り値が違うというエラーの様です。。

 ------ ---------------------------------------------------------------------------
  Line   utils\returnTo.php
 ------ ---------------------------------------------------------------------------
  :28    Method utils\ReturnTo::pop() should return string|null but returns array.
         🪪  return.type

以下の様になっていたので、28行目を「return null;」に変更しました。

    public static function pop(): ?string
    {
        try {
            return static::getSessionAndFlush();
        } catch (Throwable $e) {
            Flush::push(Flush::DEBUG, $e->getMessage());
            Flush::push(Flush::DEBUG, 'ReturnTo::popで例外が発生しました。');
        }
        return [];
    }

以上でこのレベルは終了です。レベル6の定義は以下の様になっています。(DeepL翻訳です。)

レベル6

タイプヒントの欠落を報告する

ルールレベル7を検証します

phpstan.neonファイルを書き換えて、「vendor/bin/phpstan analyse」コマンドを実行します。

・method.nonObject

エラーが17個発生しました。最初のエラーは以下の様になっています。PDO(PHPのデータ操作用オブジェクト)の「fetchAll」関数を含んでいるクラスのオブジェクト(PDOStatement)がfalseの場合もあるので呼び出せないというエラーの様です。

 ------ ------------------------------------------------------ 
  Line   db\datasource.php
 ------ ------------------------------------------------------ 
  :79    Cannot call method fetchAll() on PDOStatement|false.  
         🪪  method.nonObject
  :82    Cannot call method fetchAll() on PDOStatement|false.  
         🪪  method.nonObject

コードを見てみると以下の様になっています。

    /**
     * @param array<string, int|string> $params
     * @return array<mixed>
     */
    public function select(string $sql = "", $params = [], string $type = '', string $cls = ''): array
    {
        $stmt = $this->executeSql($sql, $params);

        if ($type === static::CLS) {

            return $stmt->fetchAll(PDO::FETCH_CLASS, $cls);
        } else {

            return $stmt->fetchAll();
        }
    }

75行目の「executeSql」関数は以下の様になっていて、PDOStatementクラスのインスタンスを取得するPDOの「prepare」関数の戻り値を返す関数になっています。preppare関数の戻り値の型は「PDOStatement|false」になっていて、このインスタンス(オブジェクト)の「fetchAll」関数を実行するとSQLの実行結果が取得できる様になっています。

    /**
     * @param array<string, int|string> $params
     */
    private function executeSql(string $sql, array $params): PDOStatement|false
    {
        $stmt = $this->conn->prepare($sql);
        $this->sqlResult = $stmt->execute($params);
        return $stmt;
    }

少し話が脇道にそれましたが、prepare関数がfalseを返す場合もあるので以下の様に修正しました。

    /**
     * @param array<string, int|string> $params
     * @return array<mixed>
     */
    public function select(string $sql = "", $params = [], string $type = '', string $cls = ''): array
    {
        $stmt = $this->executeSql($sql, $params);

        if(!$stmt) return [];

        if ($type === static::CLS) {

            return $stmt->fetchAll(PDO::FETCH_CLASS, $cls);
        } else {

            return $stmt->fetchAll();
        }
    }
・assign.propertyType

次のエラーを修正します。プロパティが「array<string>|string」というunion型を受け付けないというエラーの様です。

 ------ -------------------------------------------------------------------------------------- 
  Line   models\user.php
 ------ -------------------------------------------------------------------------------------- 
  :33    Property models\UserSchema::$username (string) does not accept array<string>|string.  
         🪪  assign.propertyType
  :34    Property models\UserSchema::$email (string) does not accept array<string>|string.     
         🪪  assign.propertyType
  :35    Property models\UserSchema::$password (string) does not accept array<string>|string.  
         🪪  assign.propertyType

実際のコードを見てみると以下の様になっています。プロパティは13~15行目に定義されていて「string」型になっています。余談ですがこの関数は、ユーザー登録やログインページからポストされたフォームデータからユーザー情報を取得する関数になっています。

class UserSchema extends Session
{
    public int $id;
    public string $username;
    public string $email;
    public string $password;

    /**
     * @var string
     */
    protected static $SESSION_NAME = '_user';

    public static function getModel(bool $check_email = true): self
    {
        $username = get_param('username', '');
        $email = get_param('email', '');
        $password = get_param('password', '');

        if (empty($username)) throw new YelpCampError('ユーザー名がありません。');
        if (empty($email) && $check_email) throw new YelpCampError('emailがありません。');
        if (empty($password)) throw new YelpCampError('パスワードがありません。');

        $user = new UserSchema;
        $user->username = $username;
        $user->email = $email;
        $user->password = $password;

        return $user;
    }
}

値を取得している「get_param」関数は以下の様になっていて、string型の配列かstringを返す様になっています。

/**
 * @return array<string>|string
 */
function get_param(string $key, string $default_val, bool $is_post = true): array|string
{
    $arry = $is_post ? $_POST : $_GET;
    return $arry[$key] ?? $default_val;
}

これはPOSTされたデータが以下の様になっていた場合は、$_POSTというPHPのスーパーグローバル変数と呼ばれる変数に['username' => <ユーザー名>, 'password' => <パスワード>]という連想配列の形で保存されます。この場合はキーの値を文字列として取得できます。

<form action="//localhost/login" method="POST">
    <label>ユーザー名</label>
    <input type="text" name="username">
    <label>パスワード</label>
    <input type="password" name="password">
    <button>ログイン</button>
</form>
↓
['username' => <ユーザー名>, 'password' => <パスワード>]

また、POSTされたフォームデータが以下の様になっていた場合は、campgroundsという名称の連想配列が作成され、その中にそれぞれのキーと値が保存されます。この場合はキー(campgrounds)の値が文字列の連想配列として取得できます。

<form action="//localhost/campgrounds/new" method="POST">
    <label>タイトル</label>
    <input type="text" name="campground[title]">
    <label>場所</label>
    <input type="text" name="campground[location]">
    <label>価格</label>
    <input type="text" name="campground[price]">
    <button>登録する</button>
</form>
↓
['campground' => ['title' => <タイトル>, 'location' => <場所>, 'price' => <価格>]]

これらの処理を1つの関数で処理していたために、どちらが返されるのか分からないのでエラーになった様です。受け取り(関数の使用)側で判定してもいいのですが、他の箇所でも使用しているので素直に関数を以下の様に分けました。

/**
 * @return array<string>|null
 */
function get_params(string $key, string $default_val, bool $is_post = true): array|null
{
    $src = $is_post ? $_POST : $_GET;
    $arr = $src[$key];
    return is_array($arr) ? $arr : null;
}

function get_param(string $key, string $default_val, bool $is_post = true): string
{
    $src = $is_post ? $_POST : $_GET;
    $val = $src[$key];
    return is_string($val) ? $val : $default_val;
}
・offsetAccess.nonOffsetAccessible

次のエラーを修正します。配列のオフセット文字列でアクセスできないというエラーの様です。

 ------ ------------------------------------------------------------------------------------- 
  Line   utils\flush.php
 ------ ------------------------------------------------------------------------------------- 
  :26    Cannot access offset string on array<string>|models\UserSchema|string.
         🪪  offsetAccess.nonOffsetAccessible

実際のコードを見てみると以下の様になっています。

    public static function push(string $type, string $msg): void
    {
        if (!is_array(static::getSession())) {
            self::init();
        }

        $msgs = static::getSession();
        $msgs[$type][] = $msg;
        static::setSession($msgs);
    }

コードを見ても良く分からないので、「phpstan Cannot access offset string on」というキーワードで検索するとこちらのサイト様に説明がありました。配列ではなく文字列に対して「文字列をキーにして」アクセスすると発生する様です。どうやら$msgs変数が文字列として認識されている様です。この変数値を取得するために呼び出している「getSession」関数は以下の様になっています。この関数はPHPの$_SESSIONというスーパーグローバル変数から、エラーメッセージやログイン情報等の様々な情報を取得するための関数になっています。

    /**
     * @return array<string>|string|UserSchema|null
     */
    public static function getSession(): array|string|UserSchema|null
    {
        return $_SESSION[static::$SESSION_NAME] ?? null;
    }

本当はもう少し厳密に型チェックをする必要があると思われますが、この位置ではエラーメッセージ(文字列の配列)しか扱わないので、以下の様に修正しました。

    public static function push(string $type, string $msg): void
    {
        if (!is_array(static::getSession())) {
            self::init();
        }

        $msgs = (array)static::getSession();
        $msgs[$type][] = $msg;
        static::setSession($msgs);
    }
・mixed

これだけではエラーが消えないので、getSession関数も以下の様に修正しました。mixedはすべてのタイプを表す型の様です。

    public static function getSession(): mixed
    {
        return $_SESSION[static::$SESSION_NAME] ?? null;
    }
・return.type

関数の戻り値が違うという「return.type」エラーはレベル6でも発生しますが、設定をレベル6にして検証してもこのエラーは出ない様です。型のタイプによって違いがあるのかもしれません。

 ------ ------------------------------------------------------------------------------------- 
  Line   utils\flush.php
 ------ ------------------------------------------------------------------------------------- 
  :36    Method utils\Flush::pop() should return array<array<string>>|models\UserSchema|null  
         but returns array<string>|models\UserSchema|string.
         🪪  return.type

実際のコードを見てみると以下の様になっています。

    /**
     * @return array<array<string>>|UserSchema|null
     */
    public static function pop(): array|UserSchema|null
    {
        try {
            return static::getSessionAndFlush() ?? [];
        } catch (Throwable $e) {
            Flush::push(Flush::DEBUG, $e->getMessage());
            Flush::push(Flush::DEBUG, 'Flush::popで例外が発生しました。');
        }
        return [];
    }

この関数も先ほどの便利な型(mixed)に変更しておきます。他の「return.type」エラーも戻り値に合わせて修正しました。

    public static function pop(): mixed
    {
        try {
            return static::getSessionAndFlush() ?? [];
        } catch (Throwable $e) {
            Flush::push(Flush::DEBUG, $e->getMessage());
            Flush::push(Flush::DEBUG, 'Flush::popで例外が発生しました。');
        }
        return [];
    }
・offsetAccess.notFound

配列内に'path'というキーワードが存在しない可能性があるというエラーの様です。

 ------ ------------------------------------------------------------------------------------- 
  Line   utils\helper.php
 ------ ------------------------------------------------------------------------------------- 
  :80    Offset 'path' might not exist on array{false}|array{scheme?: string, host?: string,  
         port?: int<0, 65535>, user?: string, pass?: string, path?: string, query?: string,   
         fragment?: string}.
         🪪  offsetAccess.notFound

実際のコードを見てみると以下の様になっています。これはアクセスされたURLをPHPの「parse_url」という関数で解析して、URLのパスの部分('path')を取得するという処理になっています。

/**
 * @return array{method: HttpMethod, path: string, query: string}
 */
function get_method(): array
{
    $req_method = strtoupper($_SERVER['REQUEST_METHOD']);
    $url = parse_url(CURRENT_URI);
    $path = rtrim(str_replace(BASE_CONTEXT_PATH, '', $url['path']), '/');
    $query = $url['query'] ?? '';

    $match = [];
    $f = fn($a) => preg_match('/method=(put|delete)$/i', $a, $match) ? strtoupper($match[1]) : '';
    $items = array_reverse(explode('?', $query));
    $items = array_values(array_diff(array_map($f, $items), ['']));

    if (count($items) > 0) {
        $method = HttpMethod::tryParse($items[0]);
    } else {
        $method = HttpMethod::tryParse($req_method);
    }

    return compact('method', 'path', 'query');
}

これはそういう決まりでどうしようもないので、「@phpstan-ignore」で以下の様に抑制しました。

    // @phpstan-ignore offsetAccess.nonOffsetAccessible
    $path = rtrim(str_replace(BASE_CONTEXT_PATH, '', $url['path']), '/');
・argument.type

これはPHPの「curl_setopt」関数の3番目の引数は、Null(空)でない文字列でなければいけないというエラーの様です。

 ------ ------------------------------------------------------------------------------------- 
  Line   utils\helper.php
 ------ ------------------------------------------------------------------------------------- 
  :129   Parameter #3 $value of function curl_setopt expects non-empty-string, string given.  
         🪪  argument.type

実際のコードを見てみると以下の様になっています。(余談ですがこの関数でMapboxというサイトのAPIを使用して、住所から経度・緯度情報を取得しています。(Linuxで動作している場合))

function forwardGeocodeLinux(string $url): ?string
{
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, $url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    $response = curl_exec($ch);
    curl_close($ch);

    return is_string($response) ? $response : null;
}
・@param non-empty-string

$url変数の型はstringでこれ以上コードで表現できないので、以下の様にコメント(PHPDoc)を追加しました。

/**
 * @param non-empty-string $url
 */
function forwardGeocodeLinux(string $url): ?string
{
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, $url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    $response = curl_exec($ch);
    curl_close($ch);

    return is_string($response) ? $response : null;
}
・callable.nonCallable

このレベルの最後のエラーになりました。文字列を使用して関数を呼び出そうとしているが、呼び出し可能(callable)ではないかもしれないというエラーの様です。

 ------ --------------------------------------------------------- 
  Line   views\layouts\boilerplate.php
 ------ --------------------------------------------------------- 
  :33    Trying to invoke string but it might not be a callable.  
         🪪  callable.nonCallable

実際のコードを見てみると以下の様になっています。PHPは「"test"」等の文字列を「"test"()」の様に関数として評価できるそうなので、ページのひな型(ボイラープレート)を表示する関数にメインコンテンツを表示する関数を文字列として引数に渡して実行しています。(33行目)

/**
 * @param array<CampGroundSchema>|CampGroundSchema|Error|Throwable|null $args
 */
function index(string $body, array|CampGroundSchema|Error|Throwable|null $args): void
{
?>
    <!DOCTYPE html>
    <html lang="jp">

    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>YelpCampPhpDemo</title>
        <link rel="stylesheet" href="<?php echo BASE_CSS_PATH; ?>bootstrap.min.css">
        <link href="https://api.mapbox.com/mapbox-gl-js/v3.9.3/mapbox-gl.css" rel="stylesheet">
        <script src="https://api.mapbox.com/mapbox-gl-js/v3.9.3/mapbox-gl.js"></script>
        <link rel="stylesheet" href="<?php echo BASE_CSS_PATH; ?>app.css">
    </head>

    <body class="d-flex flex-column vh-100">
        <?php \views\partials\navbar(); ?>

        <main class="container mt-5">
            <?php \views\partials\flush(); ?>
            <?php $body($args); ?>
        </main>

        <?php \views\partials\footer(); ?>

        <script src="<?php echo BASE_JS_PATH ?>bootstrap.bundle.min.js"></script>
        <script src="<?php echo BASE_JS_PATH ?>validateForms.js"></script>
    </body>

    </html>
<?php
}

これは単に、関数として評価できるかチェックする関数(is_callable)を追加するだけで修正できました。

<?php $body($args); ?>
↓
<?php if (is_callable($body)) $body($args); ?>

レベル7の定義は以下の様になっています。(DeepL翻訳です。)

レベル7

部分的に間違ったユニオン・タイプを報告する - ユニオン・タイプ内のいくつかのタイプにのみ存在するメソッドを呼び出した場合、レベル7はそのことを報告し始める。

ルールレベル8を検証します

phpstan.neonファイルを書き換えて、「vendor/bin/phpstan analyse」コマンドを実行します。

・assign.propertyType

「assign.propertyType」エラーはレベル7でも発生しますが、設定をレベル7にして検証してもこのエラーは出ない様です。型のタイプによって違いがあるのかもしれません。

 ------ ----------------------------------------------------------------------------------- 
  Line   controllers\campgrounds.php
 ------ ----------------------------------------------------------------------------------- 
  :18    Property models\CampGroundSchema::$geometry (string) does not accept string|null.  
         🪪  assign.propertyType

実際のコードを見てみると以下の様になっています。

function createCampground(): void
{
    $user = \middleware\isLoggedIn('/campgrounds/');

    $campground = CampGroundSchema::getModel();
    $campground->geometry = forwardGeocode($campground->location);
    $campground->author = $user->username;
    $campground =  CampGroundQuery::save($campground);
    if (empty($campground)) {
        Flush::push(Flush::ERROR, 'キャンプ場が登録できませんでした');
        redirect("/campgrounds");
    }
    $id = $campground->id;
    Flush::push(Flush::INFO, '登録しました');
    redirect("/campgrounds/{$id}");
}

18行目の「$campground->geometry」は以下の様に定義されています。

    public string $geometry;

「forwardGeocode」関数は以下の様に定義されていましたので、失敗した場合はNullを返さずに空文字('')を返す様に修正して戻り値の型を「string」に変更しました。

function forwardGeocode(string $location): ?string
↓
function forwardGeocode(string $location): string
・property.nonObject

残りのエラーは1種類です。UserSchemaインスタンスはNullかもしれないので$usernameプロパティにアクセスできないというエラーの様です。

 ------ ------------------------------------------------------------- 
  Line   controllers\campgrounds.php
 ------ ------------------------------------------------------------- 
  :19    Cannot access property $username on models\UserSchema|null.  
         🪪  property.nonObject

実際のコードを見てみると以下の様になっています。

function createCampground(): void
{
    $user = \middleware\isLoggedIn('/campgrounds/');

    $campground = CampGroundSchema::getModel();
    $campground->geometry = forwardGeocode($campground->location);
    $campground->author = $user->username;
    $campground =  CampGroundQuery::save($campground);
    if (empty($campground)) {
        Flush::push(Flush::ERROR, 'キャンプ場が登録できませんでした');
        redirect("/campgrounds");
    }
    $id = $campground->id;
    Flush::push(Flush::INFO, '登録しました');
    redirect("/campgrounds/{$id}");
}

一見、「\middleware\isLoggedIn」関数がNullを返すと19行目でアクセスエラーが発生しそうですが、実際にはログインしていない場合はリダイレクトされる(関数が終了する)のでエラーは発生しませんが、そこまで解析できないので以下の様に修正しました。

    $campground->author = is_null($user) ? '' : $user->username;

レベル8の定義は以下の様になっています。(DeepL翻訳です。)

レベル8

null可能な型に対するメソッドの呼び出しとプロパティへのアクセスを報告する。

ルールレベル9を検証します

phpstan.neonファイルを書き換えて、「vendor/bin/phpstan analyse」コマンドを実行します。

・argument.type

またチェックが厳しくなりました。最初のエラーは「preg_match」関数の2番目のパラメータは文字列を想定しているのにmixed型になっているというエラーの様です。

 ------ --------------------------------------------------------------------------- 
  Line   config.php
 ------ --------------------------------------------------------------------------- 
  :3     Parameter #2 $subject of function preg_match expects string, mixed given.  
         🪪  argument.type

実際のコードを見てみると以下の様になっています。

<?php
define('CURRENT_URI', $_SERVER['REQUEST_URI']);
if (preg_match("/(.+(yelpcampphpdemo))/i", CURRENT_URI, $match)) {
    define('BASE_CONTEXT_PATH', $match[0] . '/');
}
・cast.string

ただ単に「(string)CURRENT_URI」とキャストしただけでは今度は以下のエラーが発生します。mixed型をstringに直接キャストできない様です。

 ------ ------------------------------ 
  Line   config.php
 ------ ------------------------------ 
  :3     Cannot cast mixed to string.  
         🪪  cast.string

しかし、以下の様に「is_string」関数で事前に判定してあげるとエラーにならない様です。

<?php
define('CURRENT_URI', $_SERVER['REQUEST_URI']);
if (is_string(CURRENT_URI) && preg_match("/(.+(yelpcampphpdemo))/i", CURRENT_URI, $match)) {
    define('BASE_CONTEXT_PATH', $match[0] . '/');
}

同じタイプのエラー(argument.type)を修正していきます。

 ------ --------------------------------------------------------------------------------------- 
  Line   models\campground.php
 ------ --------------------------------------------------------------------------------------- 
  :132   Parameter #1 $path of function basename expects string, mixed given.
         🪪  argument.type
  :135   Parameter #1 $from of function move_uploaded_file expects string, mixed given.
         🪪  argument.type

実際のコードは以下の様になっています。

    /**
     * @return array<ImageSchema>
     */
    private static function getImages()
    {
        $results = [];

        if (!isset($_FILES['image'])) return [];

        foreach ($_FILES['image']['error'] as $key => $error) {
            if ($error) continue;
            $fname = basename($_FILES['image']['name'][$key]);
            $save_name = self::getUniqueFname('uploads/' . $fname);
            if (empty($save_name)) continue;
            if (!move_uploaded_file($_FILES['image']['tmp_name'][$key], $save_name)) continue;
            $image = new ImageSchema;
            $image->filename = $save_name;
            $image->thumbnail = self::createThubnail2($save_name);
            array_push($results, $image);
        }

        return $results;
    }

$_FILESスーパーグローバル変数の値(mixed型)を文字列に変換するには、以下の様に一旦string型を返す関数に通してあげればいい様です。以下の例では無名関数($to_str)で修正しました。(ルールレベルを10にすると、簡単な無名関数を通す方法ではエラーになるので、ルールレベル10でご説明しているヘルパー関数等をはじめから作成したほうがいいと思われます。)

    /**
     * @return array<ImageSchema>
     */
    private static function getImages()
    {
        $results = [];

        if (!isset($_FILES['image'])) return [];

        $to_str = fn($a): string => $a;

        foreach ($_FILES['image']['error'] as $key => $error) {
            if ($error) continue;
            $fname = basename($to_str($_FILES['image']['name'][$key]));
            $save_name = self::getUniqueFname('uploads/' . $fname);
            if (empty($save_name)) continue;
            if (!move_uploaded_file($to_str($_FILES['image']['tmp_name'][$key]), $save_name)) continue;
            $image = new ImageSchema;
            $image->filename = $save_name;
            $image->thumbnail = self::createThubnail2($save_name);
            array_push($results, $image);
        }

        return $results;
    }
・foreach.nonIterable

同じ関数内の違うエラーを修正します。foreachの引数に反復可能(iterables)ではないmixedの型が渡されているというエラーの様です。

 ------ --------------------------------------------------------------------------------------- 
  Line   models\campground.php
 ------ --------------------------------------------------------------------------------------- 
  :132   Argument of an invalid type mixed supplied for foreach, only iterables are supported.  
         🪪  foreach.nonIterable

以下の様に「is_iterable」関数で事前に判定してあげるとエラーにならない様です。また、foreach関数の引数に直接「$_FILES」の様なスーパーグローバル変数を書くとエラーになるので、一旦変数に代入してあげる必要がある様です。

    /**
     * @return array<ImageSchema>
     */
    private static function getImages()
    {
        $results = [];

        if (!isset($_FILES['image'])) return [];

        $files = $_FILES['image']['error'];
        $to_str = fn($a): string => $a;

        if (is_iterable($files)) {
            foreach ($files as $key => $error) {
                if ($error) continue;
                $fname = basename($to_str($_FILES['image']['name'][$key]));
                $save_name = self::getUniqueFname('uploads/' . $fname);
                if (empty($save_name)) continue;
                if (!move_uploaded_file($to_str($_FILES['image']['tmp_name'][$key]), $save_name)) continue;
                $image = new ImageSchema;
                $image->filename = $save_name;
                $image->thumbnail = self::createThubnail2($save_name);
                array_push($results, $image);
            }
        }

        return $results;
    }

または、先ほどの様に無名関数等で以下の様に書いてもいい様です。こちらの方が簡潔かもしれません。

    /**
     * @return array<ImageSchema>
     */
    private static function getImages()
    {
        $results = [];

        if (!isset($_FILES['image'])) return [];

		$to_iter = fn($a):iterable => $a;
        $to_str = fn($a): string => $a;

        foreach ($to_iter($_FILES['image']['error']) as $key => $error) {
            if ($error) continue;
            $fname = basename($to_str($_FILES['image']['name'][$key]));
            $save_name = self::getUniqueFname('uploads/' . $fname);
            if (empty($save_name)) continue;
            if (!move_uploaded_file($to_str($_FILES['image']['tmp_name'][$key]), $save_name)) continue;
            $image = new ImageSchema;
            $image->filename = $save_name;
            $image->thumbnail = self::createThubnail2($save_name);
            array_push($results, $image);
        }

        return $results;
    }
・return.type

次のエラーを修正します。CampGroundQueryクラスのselect関数は「CampGroundSchema」クラスの配列を返す様に定義されているのに、実際はmixed型の配列を返しているというエラーの様です。

 ------ -------------------------------------------------------------------------------------- 
  Line   db\campgrounds.query.php
 ------ -------------------------------------------------------------------------------------- 
  :126   Method db\CampGroundQuery::select() should return array<models\CampGroundSchema> but  
         returns array<mixed>.
         🪪  return.type

実際のコードは以下の様になっています。120行目の$db->select関数の戻り値が「@return array<mixed>」となっているためにエラーになった様です。

    /**
     * @return array<CampGroundSchema>
     */
    public static function select(?string $criteria = null): array
    {
        $db = new DataSource;
        $sql = 'select * from campgrounds ' . (empty($criteria) ? '' : " where {$criteria}");
        $result = $db->select($sql, [], DataSource::CLS, CampGroundSchema::class);
        if (!empty($result)) {
            foreach ($result as $camp)
                $camp->images = ImageQuery::select("camp_id = {$camp->id}");
        }

        return $result;
    }

以下の様に、無名関数を使用すればとりあえずエラーは出なくなります。

    /**
     * @return array<CampGroundSchema>
     */
    public static function select(?string $criteria = null): array
    {
        $db = new DataSource;
        $sql = 'select * from campgrounds ' . (empty($criteria) ? '' : " where {$criteria}");
        $result = $db->select($sql, [], DataSource::CLS, CampGroundSchema::class);
        $results = [];
        $to_camp = fn($a): CampGroundSchema => $a;
        if (!empty($result)) {
            foreach ($result as $camp) {
                $camp->images = ImageQuery::select("camp_id = {$camp->id}");
                array_push($results, $to_camp($camp));
            }
        }

        return $results;
    }

もう少しちゃんとしたいのでCampGroundSchemaクラスに「cast」という静的関数を作成して、型チェックを行う様に修正しました。他のクラスも同様に修正します。

    /**
     * @param object $obj
     */
    public static function cast(object $obj): self
    {
        if (!($obj instanceof self)) {
            $name = get_class($obj);
            throw new InvalidArgumentException("{$name} は CampGroundSchema ではありません。");
        }
        return $obj;
    }
    /**
     * @return array<CampGroundSchema>
     */
    public static function select(?string $criteria = null): array
    {
        $db = new DataSource;
        $sql = 'select * from campgrounds ' . (empty($criteria) ? '' : " where {$criteria}");
        $selected = $db->select($sql, [], DataSource::CLS, CampGroundSchema::class);
        $results =[];
        if (!empty($selected)) {
            foreach ($selected as $item) {
                $camp = CampGroundSchema::cast($item);
                $camp->images = ImageQuery::select("camp_id = {$camp->id}");
                array_push($results, $camp);
            }
        }

        return $results;
    }
・offsetAccess.nonOffsetAccessible

いよいよこのレベルの最後のエラーになりました。配列のオフセット文字列でアクセスできないという「offsetAccess.nonOffsetAccessible」エラーはレベル7でも発生しますが、設定をレベル7にして検証してもこのエラーは出ない様です。エラーにする対象が違うのかもしれません。

 ------ ------------------------------------------- 
  Line   models\campground.php
 ------ ------------------------------------------- 
  :144   Cannot access offset 'error' on mixed.     
         🪪  offsetAccess.nonOffsetAccessible

実際のコードは以下の様になっています。「error」というオフセット文字列でアクセスできないというエラーですが、こういう決まりなのでどうにもできません。他の箇所を見ても同じようです。

        $files = $_FILES['image']['error'];
・ignoreErrors

仕方がないので、「phpstan.neon」設定ファイルに「ignoreErrors」という項目を追加して、「offsetAccess.nonOffsetAccessible」エラーを抑制しました。

includes:
    - phpstan-baseline.neon
parameters:
    level: 9
    paths:
        - .
    excludePaths:
        - vendor
    ignoreErrors:
        -
            identifier: offsetAccess.nonOffsetAccessible

レベル9の定義は以下の様になっています。(DeepL翻訳です。)

レベル9

明示的な混合型は厳格に扱わなければなりません。

ルールレベル10を検証します

phpstan.neonファイルを書き換えて、「vendor/bin/phpstan analyse」コマンドを実行します。新たにエラーが27個発生しました。

・return.type

最初のエラーはselect関数はオブジェクト型の配列を返す様に定義されているが、普通の配列を返しているというエラーの様です。

 ------ ------------------------------------------------------------------------------- 
  Line   db\datasource.php
 ------ ------------------------------------------------------------------------------- 
  :81    Method db\DataSource::select() should return array<object> but returns array.  
         🪪  return.type

実際のコードは以下の様になっています。select関数の値を返しているPDOStatementクラスの「fetchAll」関数の戻り値は「@return array|false」の様になっているので、取得された配列の各要素をオブジェクト型に変換してあげれば良さそうです。

    /**
     * @param array<string, int|string> $params
     * @return array<object>
     */
    public function select(string $sql = "", $params = [], string $type = '', string $cls = ''): array
    {
        $stmt = $this->executeSql($sql, $params);

        if(!$stmt) return [];

        if ($type === static::CLS) {

            return $stmt->fetchAll(PDO::FETCH_CLASS, $cls);
        } else {

            return $stmt->fetchAll();
        }
    }

配列の要素を一度に変換することはできなさそうなので、一旦foreachループで回していつもの手法(無名関数)でオブジェクトに変換してみました。

    /**
     * @param array<string, int|string> $params
     * @return array<object>
     */
    public function select(string $sql = "", $params = [], string $type = '', string $cls = ''): array
    {
        $stmt = $this->executeSql($sql, $params);

        if (!$stmt) return [];
        $results = [];
        $selected = [];

        if ($type === static::CLS) {
            $selected = $stmt->fetchAll(PDO::FETCH_CLASS, $cls);
        } else {
            $selected = $stmt->fetchAll();
        }

        if (!empty($selected)) {
            $to_obj = fn($a): object => $a;
            foreach ($selected as $item) {
                $obj = $to_obj($item);
                if (!empty($obj)) array_push($results, $obj);
            }
        }

        return $results;
    }

すると今度は無名関数の戻り値がオブジェクト型ではなくて、mixed型になっているというエラーが発生しました。

 ------ ------------------------------------------------------------ 
  Line   db\datasource.php
 ------ ------------------------------------------------------------ 
  :88    Anonymous function should return object but returns mixed.  
         🪪  return.type

無名関数の戻り値に「(object)$a」の様にキャストをかけても状況は変わりませんでしたが、以下の様に「is_object」関数で判定してからキャストをかけるとエラーが出なくなる様です。

function toObject(mixed $arg): ?object
{
    return is_object($arg) ? (object)$arg : null;
}

次のエラーを見てみると、同じような言い回しのエラーが続いています。

 ------ ------------------------------------------------------------ 
  Line   models\campground.php
 ------ ------------------------------------------------------------ 
  :100   Anonymous function should return string but returns mixed.  
         🪪  return.type
  :145   Anonymous function should return string but returns mixed.  
         🪪  return.type

試しにコードを見てみると以下の様になっています。ただ単に関数を通しただけでは駄目な様です。

    /**
     * @return array{geometry: mixed, title: string, location: string, properties: array{popupMarkup: string}}
     */
    public function getGeoData(): array
    {
        $href = BASE_CONTEXT_PATH . "campgrounds/{$this->id}";
        $summary = mb_substr($this->description, 0, 20);
        if (mb_strlen($this->description) > 20) $summary .= "...";
        $to_str = fn($a): string => $a;
        $geo_info = [
            'geometry' => json_decode($this->geometry),
            'title' => $this->title,
            'location' => $this->location,
            'properties' => ['popupMarkup' => "<strong><a href=\"{$href}\">{$this->title}</a></strong>\n<p>{$summary}</p>"]
        ];

        return $geo_info;
    }

以下の様なヘルパー関数を作成して、今まで使用していた無名関数を置き換えたところエラーを解消することが出来ました。

function toObject(mixed $arg): ?object
{
    return is_object($arg) ? (object)$arg : null;
}

function toString(mixed $arg): string
{
    return is_string($arg) ? strval($arg) : '';
}

/**
 * @return array<string>
 */
function toStrArray(mixed $arg): array
{
    $f = fn($a): string => is_string($a) ? strval($a) : '';
    $arr = [];
    if (is_array($arg)) $arr = (array)$arg;
    $items = array_values(array_diff(array_map($f, $arr), ['']));

    return $items;
}

/**
 * @return array<string, string|int|float>
 */
function toAssocArray(mixed $arg): array
{
    if (!is_array($arg)) return [];

    $results = [];
    $arr = (array)$arg;

    foreach ($arr as $key => $value) {
        if (!is_string($key) || (!is_string($value) && !is_numeric($value))) continue;
        $results[$key] = $value;
    }

    return $results;
}

/**
 * @return array<array<string, string|int|float>>
 */
function toAssocArrArray(mixed $arg): array
{
    if (!is_array($arg)) return [];

    $results = [];
    $arr = (array)$arg;

    foreach ($arr as $item) {
        $child_arr = toAssocArray($item);
        if (!empty($child_arr)) array_push($results, $child_arr);
    }

    return $results;
}

レベル10の定義は以下の様になっています。(DeepL翻訳です。)

レベル10

(PHPStan2.0の新機能)混合型をより厳密に - 明示的な混合型だけでなく、暗黙的な混合型(型が見つからない)でもエラーを報告する。

VSCode用のPHPStanプラグイン

さて以上の様に大変優秀な静的解析(PHPStan)ですが、最後に一気に片づけようとするととても大変です。普段から気軽に解析できる様にするプラグインがある様です。

・PHPStan - swordev 様

このプラグインを入れておくと、「ファイルを保存した」タイミングで「phpstan.neon」ファイルで指定されている解析レベルで解析処理を実行してくれます。

何かエラーが発生した場合は、VSCodeのコンソール画面にある「問題」タブページにエラー内容が表示されます。表示されているエラーをダブルクリックすると問題が発生しているファイルの該当箇所にジャンプします。

・PHPStan - SanderRonde 様

このプラグインを入れると、「キーを押した」タイミングで「phpstan.neon」ファイルで指定されている解析レベルで解析処理を実行してくれます。実行するには別途「Bun」と呼ばれる高速動作するNode.jsの様なアプリをインストールする必要があります。VSCodeのターミナルを開いて、「powershell -c "irm bun.sh/install.ps1 | iex"」と入力するとインストールされます。

このプラグインもVSCodeのコンソール画面の「問題」タブページにエラー内容が表示されます。表示されているエラーをダブルクリックすると問題が発生しているファイルの該当箇所にジャンプします。

その他

PHPStanのルールレベルの検証はそれ以下のルールも同時に検証される様です。ルールレベル10(max)の検証にパスすればPHPStanのエラーはすべて修正されたと感じますが、何か所かエラーを抑制しましたのでそれ以下のレベルで検証すると(抑制した)エラーが発生する様です。

今回修正したアプリはこちらのリンクからダウンロードできます。ダウンロードして展開したフォルダをVSCodeで開いて、Ctrl + @キーでターミナル画面を表示してから「composer install」と入力するとPHPStanが使用できる様になります。URL名(フォルダ名)を変更したい場合はこちらの記事をご参照ください。

以上です。お疲れさまでした。