公開日 2026-07-16

PHPStanのlevel 9 / 10(max)に上げる(mixedをどこまで潰すか)

level 8 が通る PHP プロジェクトを PHPStan の level 9 と 10(max)へ上げる。9 は明示的な mixed だけを縛るため、型を書かないコードは使い方を問われないまま素通りする。その非対称性を実際の出力で示し、mixed を境界で型に落として max まで通す。

目次

  1. 前提環境
  2. 1. 9 と 10 が見ている mixed の違い
  3. 2. デモ環境を作成して起動する
  4. 3. level 8 は通り、level 9 で3件落ちる
  5. コードのポイント
  6. 4. mixed は境界で型に落とす
  7. 4.1 is_array() だけでは足りない
  8. 4.2 キーまで確かめて組み直す
  9. コードのポイント
  10. 5. level 9 を素通りするコード
  11. 6. 9 で止めるか、10 まで行くか
  12. 7. まとめ

関連記事:

level: 8 が通るプロジェクトを 910max)へ上げます。この2段で増える指摘は mixed の一点に集中していて、問われるのはコードの書き方よりも「外部から入ってくる値を、どこで型に落とすか」という設計判断になる。

前提環境

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

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

1. 910 が見ている mixed の違い

PHPStan の rule levels は 0 から 10 までの11段階で、最高レベルは 10--level max は「そのバージョンの最高レベル」を指すエイリアスなので、max を指定しておくと PHPStan を上げたときに自動で追随します。

レベル増える主な指摘
8nullable な値へのメソッド・プロパティアクセス
9明示的に書かれた mixed に厳格になる。許される操作は、別の mixed に渡すことだけ
10型を書いていないために mixed になっている値(暗黙の mixed)も、9 と同じ厳しさで扱う

10 が追加されたのは PHPStan 2.0 から。1.x を使っている場合、最高レベルは 9 になります。

9 は「mixed と書いた箇所」だけを縛り、10 は「型を書かなかった箇所」まで縛る。同じ mixed でも、どこから来たかで扱いが変わる段階が挟まっています。

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

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

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

最小構成は次の形です。

phpstan-max-demo/
├─ compose.yml
├─ docker/
│  └─ php/
│     └─ Dockerfile
├─ composer.json
├─ phpstan.neon
└─ src/
   ├─ ConfigLoader.php
   ├─ AppConfig.php
   ├─ Mailer.php
   └─ MailerFactory.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-max-demo",
  "type": "project",
  "require": {
    "php": "^8.5"
  },
  "require-dev": {
    "phpstan/phpstan": "^2.1"
  },
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  },
  "config": {
    "sort-packages": true
  }
}

phpstan.neon を作成します。出発点は level: 8 です。

parameters:
    level: 8
    paths:
        - src

コンテナを起動し、依存を入れます。

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

PHPStan - PHP Static Analysis Tool 2.x が表示されれば準備は終わりです。

3. level 8 は通り、level 9 で3件落ちる

題材は、JSON の設定ファイルを読んでメール送信の設定を組み立てるコードにします。外部から来た値が mixed のままコードの奥へ流れていく、9 の典型形です。

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

<?php

declare(strict_types=1);

namespace App;

use RuntimeException;

final class ConfigLoader
{
    /** @return array<string, mixed> */
    public function load(string $path): array
    {
        $raw = file_get_contents($path);
        if ($raw === false) {
            throw new RuntimeException(sprintf('設定ファイルを読めません: %s', $path));
        }

        return json_decode($raw, true);
    }
}

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

<?php

declare(strict_types=1);

namespace App;

final class AppConfig
{
    /** @param array<string, mixed> $values */
    public function __construct(private readonly array $values)
    {
    }

    public function get(string $key): mixed
    {
        return $this->values[$key] ?? null;
    }
}

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

<?php

declare(strict_types=1);

namespace App;

final class Mailer
{
    public function __construct(
        private readonly string $host,
        private readonly int $port,
    ) {
    }

    public function endpoint(): string
    {
        return $this->host . ':' . $this->port;
    }
}

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

<?php

declare(strict_types=1);

namespace App;

final class MailerFactory
{
    public function create(AppConfig $config): Mailer
    {
        return new Mailer(
            $config->get('mail_host'),
            $config->get('mail_port'),
        );
    }
}

level 8 で解析します。

docker compose exec app ./vendor/bin/phpstan analyse --no-progress
 [OK] No errors

Mailer のコンストラクタは stringint を要求しているのに、AppConfig::get() から返るのは mixed です。それでも 8 は何も言いません。8 までのルールに、mixed を型のある場所へ渡すことを咎めるものが無いからです。

レベルを 9 に上げます。

docker compose exec app ./vendor/bin/phpstan analyse --level 9 --no-progress
 ------ -----------------------------------------------------------------------
  Line   ConfigLoader.php
 ------ -----------------------------------------------------------------------
  19     Method App\ConfigLoader::load() should return array<string, mixed> bu
         t returns mixed.
         🪪  return.type
 ------ -----------------------------------------------------------------------

 ------ -----------------------------------------------------------------------
  Line   MailerFactory.php
 ------ -----------------------------------------------------------------------
  12     Parameter #1 $host of class App\Mailer constructor expects string,
         mixed given.
         🪪  argument.type
  13     Parameter #2 $port of class App\Mailer constructor expects int, mixed
         given.
         🪪  argument.type
 ------ -----------------------------------------------------------------------

 [ERROR] Found 3 errors

3件とも、mixedmixed 以外の場所へ渡したことへの指摘になっています。

コードのポイント

json_decode() の戻り値は mixed

$raw = file_get_contents($path);
if ($raw === false) {
    throw new RuntimeException(sprintf('設定ファイルを読めません: %s', $path));
}

return json_decode($raw, true);

json_decode($raw, true) が返すのは mixed です。JSON には配列もスカラーも null も書けるため、PHPStan は「配列が返る」と仮定できません。宣言では array<string, mixed> を返すと言っているのに、実際に渡しているのは mixed。この食い違いが return.type として出ます。

mixed を返すゲッターは、呼び出し側へ責任を押し付ける

public function get(string $key): mixed
{
    return $this->values[$key] ?? null;
}

get() は1つで何でも取り出せる代わりに、返した瞬間に型の情報を捨てています。level 9 は「捨てた型を復元しないまま使うな」と言ってくる。エラーが出たのは MailerFactory 側ですが、原因を作ったのは AppConfig の設計のほうにあります。

4. mixed は境界で型に落とす

level 9 のエラーは、型を確定させる場所を決めれば消えます。呼び出し側で毎回 is_string() を書くか、mixed が生まれる場所で型を確定させて以降へ流さないか。

後者を採ります。同じ検証を呼び出し側に何度も書かずに済み、設定ファイルが壊れていたときの失敗地点も1か所にまとまるからです。

flowchart LR
    A["JSONファイル<br/>(外部・型なし)"] --> B["ConfigLoader<br/>mixed が生まれる"]
    B --> C{"境界で検証"}
    C -->|"形が違う"| D["例外を投げる"]
    C -->|"検証を通った"| E["AppConfig<br/>getString() / getInt()"]
    E --> F["Mailer<br/>string / int しか受け取らない"]

4.1 is_array() だけでは足りない

ConfigLoaderis_array() のガードを足してみます。

$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
    throw new RuntimeException(sprintf('設定ファイルの最上位が object ではありません: %s', $path));
}

return $decoded;

level 9 で解析します。ConfigLoader への指摘は消えず、文面だけが変わる。

 ------ -----------------------------------------------------------------------
  Line   ConfigLoader.php
 ------ -----------------------------------------------------------------------
  24     Method App\ConfigLoader::load() should return array<string, mixed> bu
         t returns array<mixed, mixed>.
         🪪  return.type
 ------ -----------------------------------------------------------------------

 ------ -----------------------------------------------------------------------
  Line   MailerFactory.php
 ------ -----------------------------------------------------------------------
  12     Parameter #1 $host of class App\Mailer constructor expects string,
         mixed given.
         🪪  argument.type
  13     Parameter #2 $port of class App\Mailer constructor expects int, mixed
         given.
         🪪  argument.type
 ------ -----------------------------------------------------------------------

 [ERROR] Found 3 errors

returns mixed だった指摘が returns array<mixed, mixed> に変わりました。is_array() が保証するのは「配列であること」だけで、キーが string である保証は含みません。PHPStan の見立ては array<mixed, mixed> に留まり、宣言した array<string, mixed> には届いていない。mixed を潰すつもりで書いたガードが、キーの側に mixed を残した形です。

MailerFactory の2件は手つかずのまま残る。AppConfig::get()mixed を返す構造に触れていないからで、こちらは次節で消します。

4.2 キーまで確かめて組み直す

src/ConfigLoader.php を次の内容へ更新します。

<?php

declare(strict_types=1);

namespace App;

use JsonException;
use RuntimeException;

final class ConfigLoader
{
    /** @return array<string, mixed> */
    public function load(string $path): array
    {
        $raw = file_get_contents($path);
        if ($raw === false) {
            throw new RuntimeException(sprintf('設定ファイルを読めません: %s', $path));
        }

        try {
            $decoded = json_decode($raw, true, flags: JSON_THROW_ON_ERROR);
        } catch (JsonException $e) {
            throw new RuntimeException(sprintf('設定ファイルの JSON が壊れています: %s', $path), 0, $e);
        }

        if (!is_array($decoded)) {
            throw new RuntimeException(sprintf('設定ファイルの最上位が object ではありません: %s', $path));
        }

        $values = [];
        foreach ($decoded as $key => $value) {
            if (!is_string($key)) {
                throw new RuntimeException(sprintf('設定キーが文字列ではありません: %s', $path));
            }
            $values[$key] = $value;
        }

        return $values;
    }
}

src/AppConfig.php を次の内容へ更新します。get() を捨て、型ごとのアクセサに置き換えます。

<?php

declare(strict_types=1);

namespace App;

use InvalidArgumentException;

final class AppConfig
{
    /** @param array<string, mixed> $values */
    public function __construct(private readonly array $values)
    {
    }

    public function getString(string $key): string
    {
        $value = $this->values[$key] ?? null;
        if (!is_string($value)) {
            throw new InvalidArgumentException(sprintf('設定 %s は string ではありません', $key));
        }

        return $value;
    }

    public function getInt(string $key): int
    {
        $value = $this->values[$key] ?? null;
        if (!is_int($value)) {
            throw new InvalidArgumentException(sprintf('設定 %s は int ではありません', $key));
        }

        return $value;
    }
}

src/MailerFactory.php を次の内容へ更新します。

<?php

declare(strict_types=1);

namespace App;

final class MailerFactory
{
    public function create(AppConfig $config): Mailer
    {
        return new Mailer(
            $config->getString('mail_host'),
            $config->getInt('mail_port'),
        );
    }
}

level 10 で確認します。

docker compose exec app ./vendor/bin/phpstan analyse --level 10 --no-progress
 [OK] No errors

9 を飛ばして 10 が通りました。ここまでのコードに「型を書いていない箇所」が無いため、10 で増えるルールに引っかかる余地がありません。

コードのポイント

foreach で組み直すと、キーの型が確定する

$values = [];
foreach ($decoded as $key => $value) {
    if (!is_string($key)) {
        throw new RuntimeException(sprintf('設定キーが文字列ではありません: %s', $path));
    }
    $values[$key] = $value;
}

return $values;

$values[$key] = $value$keyis_string() を通った後なので string に絞れています。PHPStan は $valuesarray<string, mixed> として組み立て直し、宣言と一致するようになる。@var で黙らせずに済むのは、検証がコードとして実在するからです。

② 値側の mixed は残してよい

/** @return array<string, mixed> */
public function load(string $path): array

戻り値の値側は mixed のままです。設定ファイルには文字列も数値も配列も入るので、ここで型を1つに決めることはできません。ただし level 9 が禁じているのは「mixed を持つこと」ではなく「mixed を型のある場所へ渡すこと」。保持したまま AppConfig へ渡す分には指摘されません。

③ 型を確定させる責任は、アクセサ側に置く

public function getInt(string $key): int
{
    $value = $this->values[$key] ?? null;
    if (!is_int($value)) {
        throw new InvalidArgumentException(sprintf('設定 %s は int ではありません', $key));
    }

    return $value;
}

mixed から int への変換は、値を使う直前ではなく AppConfig の中で1回だけ起きます。呼び出し側は getInt() を呼ぶだけで int を受け取れるため、MailerFactory に型チェックのコードは増えない。設定が壊れていたときは、使う場所ではなく取り出す場所で例外になります。

5. level 9 を素通りするコード

ここまでは mixed と明示的に書いてあるコードでした。次は、型を何も書いていないコードを足す。

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

<?php

declare(strict_types=1);

namespace App;

final class LabelFormatter
{
    public function format($row): string
    {
        return strtoupper($row['name']);
    }
}

$row に型がありません。中身が配列である保証も、name というキーがある保証も、その値が文字列である保証も無い。strtoupper() に何が渡るかは実行するまで分からないコードです。

level 9 で解析します。

docker compose exec app ./vendor/bin/phpstan analyse --level 9 --no-progress
 ------ ---------------------------------------------------------------------
  Line   LabelFormatter.php
 ------ ---------------------------------------------------------------------
  9      Method App\LabelFormatter::format() has parameter $row with no type
         specified.
         🪪  missingType.parameter
 ------ ---------------------------------------------------------------------

 [ERROR] Found 1 error

指摘は1件、「型が書かれていない」だけです。しかも missingType.parameterlevel 6 から出るルールで、9 が足したものではない。strtoupper($row['name']) という危険な使い方そのものは、level 9 まで上げても1件も指摘されません。

level 10 で同じコードを解析します。

docker compose exec app ./vendor/bin/phpstan analyse --level 10 --no-progress
 ------ ---------------------------------------------------------------------
  Line   LabelFormatter.php
 ------ ---------------------------------------------------------------------
  9      Method App\LabelFormatter::format() has parameter $row with no type
         specified.
         🪪  missingType.parameter
  11     Cannot access offset 'name' on mixed.
         🪪  offsetAccess.nonOffsetAccessible
  11     Parameter #1 $string of function strtoupper expects string, mixed
         given.
         🪪  argument.type
 ------ ---------------------------------------------------------------------

 [ERROR] Found 3 errors

11行目への指摘が2件増えている。10 は「型が書かれていない引数」を mixed と見なし、9 が明示的な mixed へ課したのと同じ制約を適用します。

ここに 9 の非対称性があります。

書き方level 9 での扱い
format(mixed $row) と正直に書く$row['name'] の時点でエラー
format($row) と型を書かない使い方は不問。「型が無い」とだけ言われる

mixed と明示したほうが厳しく叱られ、何も書かなければ使い方を問われない。level 9 で止めると、この歪みを抱えたままになります。10 が閉じているのはこの穴です。

level 10 を通すには、型を書きます。src/LabelFormatter.php を次の内容へ更新します。

<?php

declare(strict_types=1);

namespace App;

final class LabelFormatter
{
    /** @param array{name: string} $row */
    public function format(array $row): string
    {
        return strtoupper($row['name']);
    }
}
docker compose exec app ./vendor/bin/phpstan analyse --level 10 --no-progress
 [OK] No errors

array{name: string} は「name というキーがあり、その値は string」という形の指定です。ここまで書くと $row['name']string であることを PHPStan が追えるようになり、strtoupper() への引き渡しが通ります。

6. 9 で止めるか、10 まで行くか

phpstan.neonmax に更新すれば、以降は最高レベルで固定できます。

parameters:
    level: max
    paths:
        - src

判断の材料になるのは、既存コードに「型を書いていない箇所」がどれだけ残っているかです。

  • 10max)から始めてよい場合: 新規プロジェクト、あるいは level 6missingType.* を既に全て解消しているプロジェクト。型が書かれている限り 10 で増える指摘はほとんど出ません。§4 のコードが 9 を飛ばして 10 を通ったのはこのためです。
  • 9 で一度止めたほうがよい場合: missingType.*ignoreErrors や baseline で抑えている既存プロジェクト。10 に上げると、抑えていた箇所の「使い方」まで一斉に噴き出します。件数が数百に達することもあるため、先に 9 を通し、missingType.* の baseline を削りながら 10 へ寄せるほうが差分を追いやすくなります。

どちらの場合も、baseline へ逃がした指摘は「あとで直す」ための借金として残ります。level 10 の指摘は、その値の型がコード上のどこにも書かれていないことを示します。型の無い引数に何が渡るかは実行するまで決まらないので、放置した数だけ実行時エラーの候補が残ります。

7. まとめ

  • level 9 は、mixed と明示された値を型のある場所へ渡すことを止める。json_decode() の戻り値や、mixed を返すゲッターが最初の壁になる。
  • mixed は使う直前ではなく、生まれた場所で型に落とす。ConfigLoader で形を検証し、AppConfig の型付きアクセサで確定させれば、呼び出し側に型チェックが散らばらない。
  • is_array() が保証するのは配列であることだけで、キーの型は絞れない。array<string, mixed> まで詰めるには、検証しながら組み直す必要がある。
  • level 9 には非対称性がある。mixed と書けば縛られ、型を書かなければ使い方を問われない。level 10 はその抜け道を閉じ、「型が無い」ことと「その値を雑に使う」ことを別々に指摘する。

シリーズ 7/7

このシリーズ

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 の壁を越える)
  7. 7. PHPStanのlevel 9 / 10(max)に上げる(mixedをどこまで潰すか) 現在の記事