PHP CS Fixer / PHPStan / PHPUnit を並べて、AI生成コードの最低限の受け入れゲートを作ります。
狙いは「整形崩れ」「明白な型不整合」「既知の振る舞い崩れ」をローカルで先に落とし、人間レビューに回す差分を絞ることです。
AI生成コードは、整形崩れ、nullable の扱いミス、既存仕様に対する小さな退行が混ざりやすいです。
人間レビューで毎回そこから拾うのではなく、まず機械で検出できるものを先にふるいにかけます。
AIが出した差分をそのままレビューへ流さないための、最小の受け入れ基準として扱います。
確認は composer format:check、composer analyse、composer test を主線にします。
最後に日常運用用の composer qa も作ります。
GitHub Actions や pre-commit は別記事に分けます。
前提環境
- Windows 11
- WSL2(Ubuntu)
- VS Code(Remote - WSL)
- Docker Desktop(WSL連携有効)
以降のコマンドは、特記がない限り WSL 側ターミナルで実行します。
サービス名は app に固定します。
本記事では新規デモのため PHP 8.5 を使います。
既存案件で 8.3 / 8.4 を使っている場合は、composer.json と Dockerfile の PHP バージョンを読み替えてください。
1. ゴールと非対象
この記事で到達する状態:
PHP CS Fixer/PHPStan/PHPUnitをローカル品質ゲートとして並べられる- AI生成コードを 1 回落としてから修正し、
composer qaが通る状態まで持っていける - 人間レビュー前に機械で落とす範囲を説明できる
3 ツールの役割は次のとおりです。
PHP CS Fixer: 整形崩れを落とすPHPStan: 明白な型不整合や静的に見える危険を落とすPHPUnit: 期待している振る舞い崩れを落とす
この記事で扱わない内容:
- GitHub Actions による自動実行
- pre-commit / Husky などのフック運用
- Rector、Larastan、Psalm などの追加ツール
- セキュリティレビューや包括的な人間レビュー手順
ここで作る品質ゲートは万能ではありません。
要求妥当性、例外処理、設定、外部I/O、副作用、セキュリティは最終的に人間レビューへ残ります。
それでも、そこへ行く前に「明らかに崩れている差分」を落とせるだけで、人間レビューを例外処理や副作用の確認に使いやすくなります。
2. 新規デモ環境を作る
WSL 側のシェルで作業します。
Windows 側から始める場合は wsl で Ubuntu に入り、以下を実行してください。
# Windows側から始める場合のみ実行
# wsl
mkdir -p ~/projects/php-ai-code-quality-gate-demo
cd ~/projects/php-ai-code-quality-gate-demo
mkdir -p docker/php src tests
code .
compose.yml を作成します。
services:
app:
build:
context: .
dockerfile: docker/php/Dockerfile
working_dir: /workspace
volumes:
- ./:/workspace
command: ["sleep", "infinity"]
docker/php/Dockerfile を作成します。
mbstring は PHPUnit 実行要件に備えて先に入れておきます。
FROM php:8.5-cli
RUN apt-get update \
&& apt-get install -y --no-install-recommends git unzip libonig-dev \
&& docker-php-ext-install mbstring \
&& rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /workspace
composer.json は最小状態で始めます。
{
"name": "example/php-ai-code-quality-gate-demo",
"type": "project",
"require": {
"php": "^8.5"
},
"require-dev": {},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
}
}
composer.json の require に php を先に入れておくのは、このデモの最低対応 PHP を明示するためです。
PHP CS Fixer の No PHP version requirement found in composer.json 警告も防げます。
起動して確認します。
docker compose up -d --build
docker compose exec app php -v
docker compose exec app composer --version
執筆時点の確認例:
PHP 8.5.3 (cli)
Composer version 2.9.5
以後のコマンドは docker compose exec app ... に統一します。
Dockerfile を変更したときは、docker compose up -d --build を再実行してください。
詰まり時:
- コンテナが起動しない:
docker compose logs app - イメージ変更が反映されない:
docker compose up -d --build
3. 3ツールを導入して最小設定を置く
次に、3 ツールを dev 依存として導入します。
docker compose exec app composer require --dev phpunit/phpunit:^13 phpstan/phpstan:^2.1 friendsofphp/php-cs-fixer:^3.89
docker compose exec app composer dump-autoload
docker compose exec app vendor/bin/phpunit --version
docker compose exec app vendor/bin/phpstan --version
docker compose exec app vendor/bin/php-cs-fixer --version
執筆時点で実際に解決されたバージョン例:
friendsofphp/php-cs-fixer 3.94.2
phpstan/phpstan 2.1.40
phpunit/phpunit 13.0.5
記事では patch 番号を固定せず、レンジを ^13 / ^2.1 / ^3.89 にしています。
ただし、実際にどの patch が入ったかは composer show --direct で確認できます。
phpunit.xml を作成します。
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true">
<testsuites>
<testsuite name="default">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>
phpstan.neon を作成します。
parameters:
level: 5
paths:
- src
- tests
今回は最小品質ゲートに集中するため、level: 5 のまま始めます。
最初から厳しくしすぎると、設定調整や既存コード対応が主題になりやすいため、導入しやすい level: 5 から始めます。
strict rules やフレームワーク専用設定は入れません。
.php-cs-fixer.dist.php を作成します。
対象は src と tests に限定します。
<?php
$finder = PhpCsFixer\Finder::create()
->in(__DIR__ . '/src')
->in(__DIR__ . '/tests')
->name('*.php')
->ignoreDotFiles(true)
->ignoreVCS(true);
return (new PhpCsFixer\Config())
->setRiskyAllowed(false)
->setRules([
'@PSR12' => true,
'array_syntax' => ['syntax' => 'short'],
'single_quote' => true,
'no_trailing_whitespace' => true,
])
->setFinder($finder);
詰まり時:
vendor/binが見つからない:docker compose exec app ls -la vendor/bin- 依存解決に失敗する:
docker compose exec app php -vとdocker compose exec app composer --versionを再確認
4. composer scripts で最小品質ゲートを作る
次に、読者向けの操作入口を composer.json の scripts に固定します。
qa に format:fix を含めないのがポイントです。
format:check: 非破壊チェックformat:fix: 手動修正時の補助analyse: PHPStan 実行test: PHPUnit 実行qa:format:check->analyse->test
順番は、まず軽く直せる整形崩れを落とし、その次に静的解析、最後に実行コストの高いテストを見るためです。
PHP CS Fixer には check 専用コマンドがないため、fix --dry-run --diff を非破壊チェックとして使います。
この時点の composer.json は次の最終版に更新します。
{
"name": "example/php-ai-code-quality-gate-demo",
"type": "project",
"require": {
"php": "^8.5"
},
"require-dev": {
"phpunit/phpunit": "^13",
"phpstan/phpstan": "^2.1",
"friendsofphp/php-cs-fixer": "^3.89"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"format:check": "@php vendor/bin/php-cs-fixer fix --dry-run --diff",
"format:fix": "@php vendor/bin/php-cs-fixer fix",
"analyse": "@php vendor/bin/phpstan analyse",
"test": "@php vendor/bin/phpunit",
"qa": [
"@format:check",
"@analyse",
"@test"
]
}
}
Docker / WSL 環境では、Composer scripts の中でも vendor/bin/... を明示しておくほうが安定します。
phpunit や phpstan を裸のコマンド名で書くと、環境によっては sh: 1: phpunit: not found のように解決できないことがあります。
qa を非破壊チェックだけで構成しておくと、CI に載せるときも考え方がぶれません。
composer.json の require に php を残しておくと、PHP CS Fixer が参照する最低対応 PHP バージョンも明示できます。
整形を直したいときだけ、明示的に composer format:fix を実行します。
詰まり時:
composer analyseやcomposer testが見つからない: scripts のキー名と文字列を確認qaが想定順で動かない:qa配列の順番を確認composer format:checkでNo PHP version requirement found in composer.jsonと出る:composer.jsonのrequireにphpで最低対応 PHP バージョンを明示するsh: 1: phpunit: not foundのように出る: scripts を@php vendor/bin/phpunit/@php vendor/bin/phpstan analyse/@php vendor/bin/php-cs-fixer ...の形にする
5. AI生成コードを置いて失敗を作る
あえて崩れた初期コードを置きます。
例として DraftReplyBuilder を「AI生成コードの初期版」と見立てます。
この失敗版は、3種類の失敗を1回で見せるために意図的に人工的な崩れを混ぜています。
特に buildPriorityLabel() は、型エラーを再現するための補助メソッドです。
src/DraftReplyBuilder.php を作成します(失敗版)。
<?php
declare(strict_types=1);
namespace App;
final class DraftReplyBuilder{
public function build(?string $name): string{
$normalized = $this->normalizeName($name ?? '');
if ($normalized === '') {
if ($name === null) {
$priorityLabel = $this->buildPriorityLabel($name);
}
return 'Thank you for your message.';
}
return "Thank you, {$normalized}.";
}
private function normalizeName(string $name): string{
return trim($name);
}
private function buildPriorityLabel(int $priority): string{
return "P{$priority}";
}
}
この失敗版には、次の 3 つを入れています。
- PSR-12 崩れ
nullが入り得る値をintが必要なメソッドへ渡している静的解析エラー- 末尾
!が欠けているため、テストが失敗する戻り値
tests/DraftReplyBuilderTest.php を作成します。
<?php
declare(strict_types=1);
namespace Tests;
use App\DraftReplyBuilder;
use PHPUnit\Framework\TestCase;
final class DraftReplyBuilderTest extends TestCase
{
public function testBuildReturnsReplyWithExclamation(): void
{
$builder = new DraftReplyBuilder();
$actual = $builder->build('Taro');
$this->assertSame('Thank you, Taro!', $actual);
}
}
この段階では、まだ qa は通りません。
次章で 3 つの失敗を個別に読みます。
詰まり時:
Class "App\\DraftReplyBuilder" not found:docker compose exec app composer dump-autoload- テストが検出されない:
phpunit.xmlのtestsuiteパスを確認
6. format:check / analyse / test の失敗を読む
qa 一発ではなく、3 つを個別に実行します。
composer qa は先頭から順に実行され、どこか 1 つで失敗するとそこで止まります。
そのため、最初は format:check / analyse / test を個別に見たほうが、どこで落ちたか把握しやすいです。
docker compose exec app composer format:check
docker compose exec app composer analyse
docker compose exec app composer test
format:check の失敗例:
1) src/DraftReplyBuilder.php
---------- begin diff ----------
-final class DraftReplyBuilder{
-public function build(?string $name): string{
+final class DraftReplyBuilder
+{
+ public function build(?string $name): string
ここで見ているのは、あくまで整形です。
ロジックの正しさや型安全性までは見ていません。
analyse の失敗例:
Line src/DraftReplyBuilder.php
12 Parameter #1 $priority of method
App\DraftReplyBuilder::buildPriorityLabel() expects int, null given.
argument.type
ここで見ているのは、実行前に分かる明白な型不整合です。
今回は null 分岐の中で int が必要なメソッドへ null を渡しているため、PHPStan が落としてくれます。
test の失敗例:
Failed asserting that two strings are identical.
--- Expected
+++ Actual
@@ @@
-'Thank you, Taro!'
+'Thank you, Taro.'
ここで見ているのは、既知の期待値とのズレです。
整形が通っても、静的解析が通っても、期待している振る舞いは別途テストしないと守れません。
3つは似た品質チェックではなく、見る対象がそれぞれ違います。
整理すると、次の役割分担になります。
| コマンド | 何を落とすか |
|---|---|
composer format:check | 整形崩れ |
composer analyse | 明白な型不整合や静的に見える危険 |
composer test | 既知の振る舞い崩れ |
どれか 1 つだけでは足りません。
AI生成コードを受け入れる最小ゲートとしては、この 3 本を並べると役割が分かりやすいです。
7. 修正して composer qa を通す
次に DraftReplyBuilder.php を修正版へ更新します。
<?php
declare(strict_types=1);
namespace App;
final class DraftReplyBuilder
{
public function build(?string $name): string
{
$normalized = $this->normalizeName($name);
if ($normalized === '') {
return 'Thank you for your message.';
}
return "Thank you, {$normalized}!";
}
private function normalizeName(?string $name): string
{
return trim((string) $name);
}
}
必要なら整形もかけます。
docker compose exec app composer format:fix
その後、最終確認として qa を実行します。
docker compose exec app composer qa
成功例:
Found 0 of 2 files that can be fixed
[OK] No errors
OK (1 test, 1 assertion)
Found 0 of 2 files は、2ファイルを確認した結果、修正対象が 0 件だったという意味なので成功です。
これで、AI生成コードは「人間レビューへ渡す前に最低限通しておきたい品質ゲート」を越えた状態になりました。
qa が通ることは「公開可」ではなく、「人間レビューで見るべき論点を絞れた」という意味です。
詰まり時:
- 修正後も
analyseが落ちる: nullable の扱いとメソッド引数型を見直す - 修正後も
testが落ちる: 期待値と実装の文字列を見比べる
8. 人間レビュー前にどこまで機械で落とすか
最後に、機械ゲートと人間レビューの境界を整理します。
| 機械ゲートで先に落とすもの | 例 |
|---|---|
| 整形崩れ | PSR-12 崩れ、配列記法、空白、引用符 |
| 明白な型不整合 | null を受け取れない引数へ渡す、戻り値型との不整合 |
| 既知の振る舞い崩れ | 期待する文字列、戻り値、既存の仕様からの退行 |
| 人間レビューに残すもの | 例 |
|---|---|
| 要求妥当性 | そもそもこの返信文でよいか、問い合わせ種別ごとの仕様に合っているか |
| セキュリティ | SQL 条件の抜け、権限チェック漏れ、入力値の扱い、外部API呼び出し、ログ出力 |
| 例外処理 | 例外時にログだけ出して握りつぶしていないか、通知や再試行方針が妥当か |
| 設定と運用 | 環境変数、タイムアウト、監視、デプロイ方法 |
| 外部I/O と副作用 | DB更新、メール送信、Webhook、課金処理が二重実行されないか |
機械ゲートは「明らかに崩れた差分を先に落とす」ためのものです。
レビューそのものを不要にするものではありません。
GitHub Actions で同じゲートを CI に載せる話は、別記事に分けたほうが主線がはっきりします。
この記事の形で、まずローカルの受け入れ基準を揃えることをおすすめします。
深掘りしたい場合は、次の記事がつながります。
次の一歩:
composer qaをチームのローカル受け入れ基準として固定する- 主要な振る舞いをテストへ増やす
- GitHub Actions で同じ順序のチェックを自動化する