関連記事:
- PHPStan入門(最初のエラー1件を直す)
- AI生成コードを受け入れる最小品質ゲート(PHPStan + PHPUnit + CS Fixer)
- GitHub ActionsでPHPUnit / PHP CS Fixer / PHPStanを回す最小CI
この記事は、level: 5 を通せる小さな PHP プロジェクトを前提に、6 7 8 へ順番に上げながら典型的な壁を越える手順をまとめた続編です。
先に baseline や ignoreErrors で黙らせるのではなく、エラーの意味を読み、コードか型情報を足して先へ進みます。
前提環境
- Windows 11
- WSL2(Ubuntu)
- VS Code(Remote - WSL)
- Docker Desktop(WSL連携有効)
以降のコマンドは、特記がない限り WSL 側ターミナルで実行します。
サービス名は app に固定しています。
1. まず、5 から 8 で何が増えるかを押さえる
PHPStan 公式の rule levels では、今回の範囲は次の意味で切られています。
| レベル | 増える主な指摘 | この記事で扱う例 |
|---|---|---|
5 | メソッドや関数へ渡す引数型の不一致 | ここは通っている前提で始める |
6 | missing typehints | array の中身が不明なまま返している |
7 | partially wrong union types | union の片側にしかないメソッドを呼ぶ |
8 | nullable access | ?Customer に対してそのまま ->email() を呼ぶ |
この表のとおり、5 から先は「型の不一致」だけではありません。型の粒度、union の扱い、null の解消位置まで見られ始めます。
今回のゴールは次の3つです。
level 6で落ちる missing typehints を、array shape か DTO の方向で直せるlevel 7の union type エラーを、分岐か共通契約の整理で直せるlevel 8の nullable access を、早めの分岐で止められる
扱わないものも先に切っておきます。
level 910phpstan-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
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.neon の level を 6 に上げます。
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.
------ -----------------------------------------------------------------
このエラーは「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
この段階では 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.neon を level: 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.
------ -----------------------------------------------------------
ここで見直すべきなのは、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
この修正で PHPStan は通りますが、重要なのは instanceof 自体ではありません。必要な振る舞いが分岐でしか書けないなら、共通 interface を切るか、呼び出し側の責務を分ける余地があります。
level 7 は、union type を増やしただけで設計した気になっている場所を止める段階です。
コードのポイント
① instanceof による型の絞り込み
instanceof CsvSource で型を絞ると、その分岐内で $source は CsvSource として確定します。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.neon を level: 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.
------ ----------------------------------------------------------------
?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
戻り値を nullable にして呼び出し元へ返す設計もありえます。ただ、どこで null を解消するのかは早めに決めたほうが読みやすいです。
level 8 で詰まるときは、次の順で見ると早いです。
find*first*get*の戻り値が?Typeになっていないか- その null をどの層で処理するべきか
- 例外、早期 return、別メソッド分割のどれが自然か
コードのポイント
① ?Customer の nullable 戻り値型宣言
?Customer は Customer|null の省略表記で、null を返す可能性があることを型で宣言します。level 8 はこの nullable な戻り値を受け取る側に、null の解消を求めます。
public function findById(int $customerId): ?Customer
② === null チェックによる早期ガード
$customer === null の分岐内で throw(または return)があると、PHPStan はそこ以降の $customer を Customer 型として確定します。null の可能性が排除されるため、strtolower($customer->email()) が安全に呼べます。
if ($customer === null) {
throw new RuntimeException('Customer not found.');
}
return strtolower($customer->email());
6. 既存案件で 5 -> 8 を上げるときの順番
小さなサンプルでは1ファイルずつ直せます。既存案件では量が増えるため、進め方は次の順にそろえると崩れにくくなります。
phpstan.neon.distを VCS 管理し、ローカル上書き用のphpstan.neonは.gitignore側に分ける。pathsを、自分たちで直せるsrctestsなどへ絞る。levelを1つだけ上げ、最初に多いカテゴリを1種類だけ潰す。arrayの value type、union type、nullable の順で片づける。- それでも残るものだけを、最後に 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 が続きます。