関連記事:
- PHPStan レベルを上げる実践ガイド(レベル5→8 の壁を越える)
- PHPStan入門(最初のエラー1件を直す)
- AI生成コードを受け入れる最小品質ゲート(PHPStan + PHPUnit + CS Fixer)
level: 8 が通るプロジェクトを 9 と 10(max)へ上げます。この2段で増える指摘は mixed の一点に集中していて、問われるのはコードの書き方よりも「外部から入ってくる値を、どこで型に落とすか」という設計判断になる。
前提環境
- Windows 11
- WSL2(Ubuntu)
- VS Code(Remote - WSL)
- Docker Desktop(WSL連携有効)
以降のコマンドは、特記がない限り WSL 側ターミナルで実行します。
サービス名は app に固定しています。
1. 9 と 10 が見ている mixed の違い
PHPStan の rule levels は 0 から 10 までの11段階で、最高レベルは 10。--level max は「そのバージョンの最高レベル」を指すエイリアスなので、max を指定しておくと PHPStan を上げたときに自動で追随します。
| レベル | 増える主な指摘 |
|---|---|
8 | nullable な値へのメソッド・プロパティアクセス |
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 のコンストラクタは string と int を要求しているのに、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件とも、mixed を mixed 以外の場所へ渡したことへの指摘になっています。
コードのポイント
① 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() だけでは足りない
ConfigLoader に is_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 の $key は is_string() を通った後なので string に絞れています。PHPStan は $values を array<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.parameter は level 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.neon を max に更新すれば、以降は最高レベルで固定できます。
parameters:
level: max
paths:
- src
判断の材料になるのは、既存コードに「型を書いていない箇所」がどれだけ残っているかです。
10(max)から始めてよい場合: 新規プロジェクト、あるいはlevel 6のmissingType.*を既に全て解消しているプロジェクト。型が書かれている限り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はその抜け道を閉じ、「型が無い」ことと「その値を雑に使う」ことを別々に指摘する。