公開日 2026-05-26

PHPStan レベルを上げる実践ガイド(レベル5→8 の壁を越える)

level 5 が通る PHPStan 設定を、missing typehints・union type・nullable access を順に直しながら level 8 まで引き上げる。

目次

  1. 前提環境
  2. 1. まず、5 から 8 で何が増えるかを押さえる
  3. 2. デモ環境を作成して起動する
  4. 3. level 6 は「配列の中身が分からない」で落ち始める
  5. コードのポイント
  6. 4. level 7 は union type の雑な呼び方が落ちる
  7. コードのポイント
  8. 5. level 8 は nullable をそのまま進めるコードを止める
  9. コードのポイント
  10. 6. 既存案件で 5 -> 8 を上げるときの順番
  11. 7. まとめ

関連記事:

この記事は、level: 5 を通せる小さな PHP プロジェクトを前提に、6 7 8 へ順番に上げながら典型的な壁を越える手順をまとめた続編です。 先に baselineignoreErrors で黙らせるのではなく、エラーの意味を読み、コードか型情報を足して先へ進みます。

前提環境

  • Windows 11
  • WSL2(Ubuntu)
  • VS Code(Remote - WSL)
  • Docker Desktop(WSL連携有効)

以降のコマンドは、特記がない限り WSL 側ターミナルで実行します。 サービス名は app に固定しています。

1. まず、5 から 8 で何が増えるかを押さえる

PHPStan 公式の rule levels では、今回の範囲は次の意味で切られています。

レベル増える主な指摘この記事で扱う例
5メソッドや関数へ渡す引数型の不一致ここは通っている前提で始める
6missing typehintsarray の中身が不明なまま返している
7partially wrong union typesunion の片側にしかないメソッドを呼ぶ
8nullable access?Customer に対してそのまま ->email() を呼ぶ

この表のとおり、5 から先は「型の不一致」だけではありません。型の粒度、union の扱い、null の解消位置まで見られ始めます。

今回のゴールは次の3つです。

  • level 6 で落ちる missing typehints を、array shape か DTO の方向で直せる
  • level 7 の union type エラーを、分岐か共通契約の整理で直せる
  • level 8 の nullable access を、早めの分岐で止められる

扱わないものも先に切っておきます。

  • level 9 10
  • phpstan-strict-rules
  • bleeding edge
  • baseline の詳細運用
  • Larastan や Symfony extension の導入手順そのもの

2. デモ環境を作成して起動する

WSL 側のシェルで作業します。 Windows 側から始める場合は wsl で Ubuntu に入り、以下を実行してください。

# Windows側から始める場合のみ実行
# wsl
mkdir -p ~/projects/phpstan-level-up-demo/src
cd ~/projects/phpstan-level-up-demo
mkdir -p docker/php
code .

最小構成は次のとおりです。

phpstan-level-up-demo/
├─ compose.yml
├─ docker/
│  └─ php/
│     └─ Dockerfile
├─ composer.json
├─ phpstan.neon
└─ src/
   ├─ OrderSummary.php
   ├─ OrderSummaryBuilder.php
   ├─ CsvSource.php
   ├─ ApiSource.php
   ├─ ImportPreviewer.php
   ├─ Customer.php
   ├─ CustomerRepository.php
   └─ CustomerNotifier.php

compose.yml を作成します。

services:
  app:
    build:
      context: .
      dockerfile: docker/php/Dockerfile
    working_dir: /workspace
    volumes:
      - ./:/workspace
    command: ["sleep", "infinity"]

docker/php/Dockerfile を作成します。

FROM php:8.5-cli

RUN apt-get update \
    && apt-get install -y --no-install-recommends unzip \
    && rm -rf /var/lib/apt/lists/*

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

WORKDIR /workspace

composer.json を作成します。

{
  "name": "example/phpstan-level-up-demo",
  "type": "project",
  "require": {
    "php": "^8.5"
  },
  "require-dev": {
    "phpstan/phpstan": "^2.1"
  },
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  },
  "scripts": {
    "analyse": "phpstan analyse"
  }
}

コンテナを起動して PHP と Composer のバージョンを確認します。

docker compose up -d --build
docker compose exec app php -v
docker compose exec app composer --version
PHP・Composerのバージョン確認

PHPStan を導入します。

docker compose exec app composer install
docker compose exec app ./vendor/bin/phpstan --version

phpstan.neon を作成します。まず level: 5 が通る状態から始め、次章以降で 6 7 8 へ順に上げます。

parameters:
  level: 5
  paths:
    - src

以後の主線コマンドは次です。

docker compose exec app composer analyse

この時点では src/ が空のため、実行すると No files found to analyse. でエラーになります。次章からサンプルコードを追加して実行します。

既存案件で試す場合も、最初は paths を自分で直せるコードだけに寄せたほうが進めやすいです。vendor や生成コードまで最初から含めると、今回の論点ではないエラーに埋もれます。

3. level 6 は「配列の中身が分からない」で落ち始める

phpstan.neonlevel6 に上げます。

parameters:
  level: 6
  paths:
    - src

この段階でよく出るのが、array の中身を PHPStan が判断できないというエラーです。

src/OrderSummary.php を作成します。

<?php
declare(strict_types=1);

namespace App;

final class OrderSummary
{
    public function __construct(
        public readonly string $orderId,
        public readonly int $itemCount,
    ) {
    }
}

src/OrderSummaryBuilder.php の失敗版を作成します。

<?php
declare(strict_types=1);

namespace App;

final class OrderSummaryBuilder
{
    public function build(array $rows): array
    {
        $summaries = [];

        foreach ($rows as $row) {
            $summaries[] = new OrderSummary(
                $row['orderId'],
                (int) $row['itemCount'],
            );
        }

        return $summaries;
    }
}

実行します。

docker compose exec app composer analyse

出力例:

 ------ -----------------------------------------------------------------
  Line   src/OrderSummaryBuilder.php
 ------ -----------------------------------------------------------------
  8      Method App\OrderSummaryBuilder::build() has parameter $rows with
         no value type specified in iterable type array.
  8      Method App\OrderSummaryBuilder::build() return type has no value
         type specified in iterable type array.
 ------ -----------------------------------------------------------------
level 6 のエラー出力

このエラーは「array がダメ」という意味ではありません。rows の各要素がどんな配列で、戻り値の配列に何が入るのかが分からない、という意味です。

array shape で越えるのが軽いです。src/OrderSummaryBuilder.php を次の内容に更新します。

<?php
declare(strict_types=1);

namespace App;

final class OrderSummaryBuilder
{
    /**
     * @param list<array{orderId: string, itemCount: int|string}> $rows
     * @return list<OrderSummary>
     */
    public function build(array $rows): array
    {
        $summaries = [];

        foreach ($rows as $row) {
            $summaries[] = new OrderSummary(
                $row['orderId'],
                (int) $row['itemCount'],
            );
        }

        return $summaries;
    }
}

再実行して確認します。

docker compose exec app composer analyse
 [OK] No errors
level 6 修正後の No errors

この段階では DTO を増やしすぎなくて構いません。配列入力が自然な処理なら、まず array shape で「要素の形」を渡すだけで十分です。

逆に、同じ shape が何度も出るなら DTO 化したほうが読みやすくなります。level 6 は「全部クラスにしろ」という話ではなく、「要素型を曖昧なまま流すな」という段階です。

コードのポイント

@param / @return で array shape を伝える

@param list<array{orderId: string, itemCount: int|string}> $rows が「各要素の形(array shape)」を型レベルで伝えます。@return list<OrderSummary> と合わせることで、PHPStan は引数と戻り値の要素型を判断できます。array のみでは level 6 の missing typehints に引っかかります。

/**
 * @param list<array{orderId: string, itemCount: int|string}> $rows
 * @return list<OrderSummary>
 */
public function build(array $rows): array

4. level 7 は union type の雑な呼び方が落ちる

次に phpstan.neonlevel: 7 に上げます。

parameters:
  level: 7
  paths:
    - src

この段階で出やすいのが、union type の片側にしか存在しないメソッドを呼ぶコードです。

src/CsvSource.php を作成します。

<?php
declare(strict_types=1);

namespace App;

final class CsvSource
{
    public function delimiter(): string
    {
        return ',';
    }
}

src/ApiSource.php を作成します。

<?php
declare(strict_types=1);

namespace App;

final class ApiSource
{
    public function endpoint(): string
    {
        return 'https://example.test/orders';
    }
}

src/ImportPreviewer.php の失敗版を作成します。

<?php
declare(strict_types=1);

namespace App;

final class ImportPreviewer
{
    public function preview(CsvSource|ApiSource $source): string
    {
        return 'delimiter=' . $source->delimiter();
    }
}

実行します。

docker compose exec app composer analyse

出力例:

 ------ -----------------------------------------------------------
  Line   src/ImportPreviewer.php
 ------ -----------------------------------------------------------
  10     Cannot call method delimiter() on App\ApiSource|App\CsvSource.
 ------ -----------------------------------------------------------
level 7 のエラー出力

ここで見直すべきなのは、PHPStan ではなく設計です。CsvSource|ApiSource と書いた時点で、呼び出し側は「両方に共通する操作しかできない」と考える必要があります。

src/ImportPreviewer.php を次の内容に更新します。

<?php
declare(strict_types=1);

namespace App;

final class ImportPreviewer
{
    public function preview(CsvSource|ApiSource $source): string
    {
        if ($source instanceof CsvSource) {
            return 'csv delimiter=' . $source->delimiter();
        }

        return 'api endpoint=' . $source->endpoint();
    }
}

再実行して確認します。

docker compose exec app composer analyse
 [OK] No errors
level 7 修正後の No errors

この修正で PHPStan は通りますが、重要なのは instanceof 自体ではありません。必要な振る舞いが分岐でしか書けないなら、共通 interface を切るか、呼び出し側の責務を分ける余地があります。

level 7 は、union type を増やしただけで設計した気になっている場所を止める段階です。

コードのポイント

instanceof による型の絞り込み

instanceof CsvSource で型を絞ると、その分岐内で $sourceCsvSource として確定します。else 側では残った ApiSource のみが候補になるため、endpoint() の呼び出しが通ります。union type に共通しないメソッドは、この型絞り込みか共通 interface が必要です。

public function preview(CsvSource|ApiSource $source): string
{
    if ($source instanceof CsvSource) {
        return 'csv delimiter=' . $source->delimiter();
    }

    return 'api endpoint=' . $source->endpoint();
}

5. level 8 は nullable をそのまま進めるコードを止める

phpstan.neonlevel: 8 に更新します。

parameters:
  level: 8
  paths:
    - src

ここで目立つのは、?Type を返しているのに、そのままメソッドやプロパティへ触っているコードです。

src/Customer.php を作成します。

<?php
declare(strict_types=1);

namespace App;

final class Customer
{
    public function __construct(
        private string $email,
    ) {
    }

    public function email(): string
    {
        return $this->email;
    }
}

src/CustomerRepository.php を作成します。

<?php
declare(strict_types=1);

namespace App;

final class CustomerRepository
{
    public function findById(int $customerId): ?Customer
    {
        if ($customerId === 1) {
            return new Customer('first@example.com');
        }

        return null;
    }
}

src/CustomerNotifier.php の失敗版を作成します。

<?php
declare(strict_types=1);

namespace App;

final class CustomerNotifier
{
    public function buildRecipient(CustomerRepository $repository, int $customerId): string
    {
        $customer = $repository->findById($customerId);

        return strtolower($customer->email());
    }
}

実行します。

docker compose exec app composer analyse

出力例:

 ------ ----------------------------------------------------------------
  Line   src/CustomerNotifier.php
 ------ ----------------------------------------------------------------
  12     Cannot call method email() on App\Customer|null.
 ------ ----------------------------------------------------------------
level 8 のエラー出力

?Customer は「たぶんいる」ではなく、「いない可能性がある」という型情報です。呼び出し側のどこかで必ず解消しないと、level 8 のまま止まることになります。

src/CustomerNotifier.php を次の内容に更新します。

<?php
declare(strict_types=1);

namespace App;

use RuntimeException;

final class CustomerNotifier
{
    public function buildRecipient(CustomerRepository $repository, int $customerId): string
    {
        $customer = $repository->findById($customerId);

        if ($customer === null) {
            throw new RuntimeException('Customer not found.');
        }

        return strtolower($customer->email());
    }
}

再実行して確認します。

docker compose exec app composer analyse
 [OK] No errors
level 8 修正後の No errors

戻り値を nullable にして呼び出し元へ返す設計もありえます。ただ、どこで null を解消するのかは早めに決めたほうが読みやすいです。

level 8 で詰まるときは、次の順で見ると早いです。

  • find* first* get* の戻り値が ?Type になっていないか
  • その null をどの層で処理するべきか
  • 例外、早期 return、別メソッド分割のどれが自然か

コードのポイント

?Customer の nullable 戻り値型宣言

?CustomerCustomer|null の省略表記で、null を返す可能性があることを型で宣言します。level 8 はこの nullable な戻り値を受け取る側に、null の解消を求めます。

public function findById(int $customerId): ?Customer

=== null チェックによる早期ガード

$customer === null の分岐内で throw(または return)があると、PHPStan はそこ以降の $customerCustomer 型として確定します。null の可能性が排除されるため、strtolower($customer->email()) が安全に呼べます。

if ($customer === null) {
    throw new RuntimeException('Customer not found.');
}

return strtolower($customer->email());

6. 既存案件で 5 -> 8 を上げるときの順番

小さなサンプルでは1ファイルずつ直せます。既存案件では量が増えるため、進め方は次の順にそろえると崩れにくくなります。

  1. phpstan.neon.dist を VCS 管理し、ローカル上書き用の phpstan.neon.gitignore 側に分ける。
  2. paths を、自分たちで直せる src tests などへ絞る。
  3. level を1つだけ上げ、最初に多いカテゴリを1種類だけ潰す。
  4. array の value type、union type、nullable の順で片づける。
  5. それでも残るものだけを、最後に baseline 化するか判断する。

最初から baseline を作ると、「どのカテゴリで詰まっているか」が見えにくくなります。特に level 6 7 8 は、直し方が似たエラーが大量に出やすいので、先にパターンを読める状態を作ったほうが速いです。

フレームワークを使っているなら、その extension は先に検討してください。Laravel なら Larastan、Doctrine や Symfony でも対応 extension があると、PHPStan がコードの前提を理解しやすくなります。

設定ファイルの最小例は次のように保つと扱いやすいです。

phpstan.neon.dist:

parameters:
  level: 8
  paths:
    - src
    - tests

ローカルだけで追加設定が必要なら、phpstan.neon を分けます。

includes:
  - phpstan.neon.dist

parameters:
  parallel:
    maximumNumberOfProcesses: 1

この形にしておくと、共有したい本線設定と、個人環境だけの調整を混ぜずに済みます。役割分担が見えやすい構成です。

7. まとめ

level 5 -> 8 の壁は、次の4行で整理できます。

  • 5: 引数型の不一致を止める
  • 6: 配列や戻り値の中身を曖昧なまま流さない
  • 7: union type の片側専用メソッドを雑に呼ばない
  • 8: nullable を nullable のまま進めない

ここまで通せると、PHPStan は単なる型チェックを超え、設計の崩れを早めに止める道具になります。

次に読むなら、導入からやり直したい場合は PHPStan入門(最初のエラー1件を直す)、ローカル品質ゲート全体を組みたいなら AI生成コードを受け入れる最小品質ゲート(PHPStan + PHPUnit + CS Fixer)、CI まで載せたいなら GitHub ActionsでPHPUnit / PHP CS Fixer / PHPStanを回す最小CI が続きます。

シリーズ 6/6

このシリーズ

PHPのテストと品質改善

  1. 1. PHPUnit入門(最初のテスト1本)
  2. 2. PHPStan入門(最初のエラー1件を直す)
  3. 3. GitHub ActionsでPHPUnit / PHP CS Fixer / PHPStanを回す最小CI
  4. 4. PHPUnitでDBテストを始める(PostgreSQL + Docker)
  5. 5. PHPUnitでテストダブル入門(モック / スタブ最小構成)
  6. 6. PHPStan レベルを上げる実践ガイド(レベル5→8 の壁を越える) 現在の記事