Ctrl + S で PHP が PSR-12 に整う状態まで持っていきます。
整形ルールは VS CodeでPHPコード整形をそろえる の php-cs-fixer をそのまま使い、実行する主体をエディタ側へ移すところだけを扱います。
Xdebug、CI での整形強制、allow-risky なルール運用は範囲外です。
前提環境
- Windows 11
- WSL2(Ubuntu)
- VS Code(Remote - WSL)
- Docker Desktop(WSL連携有効)
PHP はコンテナの中だけにあり、WSL 側には入っていない構成が前提。
手順は 2 章の最小構成から始めるので、前提記事を適用済みのプロジェクトがなくても追えます。
1. 整形を担当する拡張を1つに決める
PHP を整形できる拡張は複数あります。どれに任せるかは editor.defaultFormatter で名指しし、指定がなければ保存しても整形は走りません。まず整形係を1つ決めます。
導入するのは junstyle.php-cs-fixer です。CLI と同じ php-cs-fixer を呼び、.php-cs-fixer.dist.php を自動で見つけて --config に渡す拡張。整形の実体はプロジェクトの vendor/bin/php-cs-fixer 側にあり、拡張が担うのは呼び出しだけなので、ルールの正本がエディタとCLIで二重になりません。
この構成で同居しやすい代表は次の2つ。どちらも整形係にはしません。
- Prettier(
esbenp.prettier-vscode): PHP を整形しない。@prettier/plugin-phpを入れない限り対象言語に PHP が含まれず、[php]の整形係に指定しても保存時は無反応になる。エラーも出ない - Intelephense(
bmewburn.vscode-intelephense-client): 補完と診断のために入れる。整形機能も持つが、この記事ではphp-cs-fixerを名指しするので整形には関与しない
PHP を整形できる拡張は他にもあります。ただ [php] の整形係を名指しする構成なので、別の整形系拡張が入っていても整形には関与しない側に回るだけです。JS や CSS のために Prettier を使っているなら、そのまま残してください。
検索すると分かるとおり、名前の似た拡張が並びます。発行者が junstyle のものを選んでください。
2. 作業ディレクトリとコンテナを用意する
ここから先のコマンドは、特記がない限り WSL ターミナルで実行します。
Windows 側の PowerShell から始める場合は、先に WSL へ入ります。
wsl
作業用ディレクトリを作成し、VS Code で開きます。
mkdir -p ~/projects/vscode-php-format-on-save-demo/src
cd ~/projects/vscode-php-format-on-save-demo
code .
compose.yml を作成します。
~/projects/vscode-php-format-on-save-demo/compose.yml
services:
app:
image: composer:2
working_dir: /workspace
volumes:
- ./:/workspace
command: ["sleep", "infinity"]
整形の確認に絞るため、イメージは最小構成の composer:2 にしてあります。実プロジェクトでは、アプリ本体と同じ PHP バージョンのイメージに置き換えてください。volumes の1行がこの記事の要で、プロジェクトが /workspace としてコンテナから見える状態を作ります。5章の橋渡しはこの対応関係だけを頼りにしています。
composer.json を作成します。
~/projects/vscode-php-format-on-save-demo/composer.json
{
"name": "example/vscode-php-format-on-save-demo",
"type": "project",
"require": {},
"require-dev": {}
}
コンテナを起動し、php-cs-fixer を dev 依存として追加します。docker compose は実行中のディレクトリの Compose ファイルを見るので、以降もこのディレクトリで実行してください。別の場所だと no configuration file provided になります。
docker compose up -d
docker compose exec app composer require --dev friendsofphp/php-cs-fixer
docker compose exec app vendor/bin/php-cs-fixer --version
バージョンはこう表示されます。
PHP CS Fixer 3.95.15 Adalbertus by Fabien Potencier, Dariusz Ruminski and contributors.
PHP runtime: 8.5.3
整形ルールを .php-cs-fixer.dist.php に作成します。拡張はこのファイル名を自動で探すので、置き場所はプロジェクト直下。
~/projects/vscode-php-format-on-save-demo/.php-cs-fixer.dist.php
<?php
$finder = PhpCsFixer\Finder::create()
->in(__DIR__ . '/src')
->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);
@PSR12 を土台に、配列記法と引用符だけをそろえた最小構成。setRiskyAllowed(false) は既定値と同じですが、risky なルールを使わない設定だと読んで分かるように明示しています。このファイルが、6章と7章で「エディタと CLI が同じルールを見ている」と言える根拠になります。
整形前のサンプルを作成します。
~/projects/vscode-php-format-on-save-demo/src/Sample.php
<?php
class Sample{
public function run( ) : array {
return array( "status" => "ok" );
}
}
3. 拡張を WSL 側に入れる
WSL ターミナルから導入します。
code --install-extension junstyle.php-cs-fixer --force
code --install-extension bmewburn.vscode-intelephense-client --force
導入先を確認します。
ls ~/.vscode-server/extensions
bmewburn.vscode-intelephense-client-1.18.5
extensions.json
junstyle.php-cs-fixer-0.3.21
~/.vscode-server/extensions に並んでいれば、拡張は WSL 側で動いています。この置き場所が次章の前提になります。ウィンドウ左下が WSL: Ubuntu になっていることも合わせて確認してください。Windows 側からフォルダを開くと拡張も Windows 側で動き、以降の手順が噛み合いません。
拡張ビューで @installed php を検索すると、どちら側に入っているかを一覧で確認できます。
php cs fixer と PHP Intelephense にはリモート側のバッジが付いています。比較用に写っている PHP Debug は Windows 側にしか入っていない状態で、「WSL: Ubuntu にインストールする」ボタンが出たまま。この見分けが、次章の前提になります。
4. エディタとコンテナの間にある段差
拡張は WSL 側で動き、php-cs-fixer はコンテナの中にあります。この二つは、そのままでは繋がりません。理由は2つあります。
1つ目は、WSL 側に PHP が無いことです。
command -v php || echo "php: not found"
php: not found
vendor/bin/php-cs-fixer 自体は、バインドマウント越しに WSL からも見えます。ただし先頭行が #!/usr/bin/env php なので、WSL 側に PHP が無ければ起動しません。拡張が同梱する php-cs-fixer.phar を指定した場合も、phar を動かすのは PHP。同じ場所で止まります。
2つ目は、拡張が渡すファイルの場所です。
拡張は編集中のバッファを一時ファイルへ書き出し、そのパスを php-cs-fixer へ渡します。実際の起動内容は次のとおり。
cwd: /home/<user>/projects/vscode-php-format-on-save-demo/src
fix
--using-cache=no
--format=json
--config=/home/<user>/projects/vscode-php-format-on-save-demo/.php-cs-fixer.dist.php
--path-mode=override
/tmp/pcf-tmp0.4091961245219523/Sample.php
--config に .php-cs-fixer.dist.php が入っているのは好都合で、CLI と同じルールが適用されます。拡張は候補のファイル名を .vscode/ から先に探し、次にプロジェクト直下を見ます。候補が直下の1つだけなら、CLI が読むものと同じファイルになります。噛み合わないのは最後の引数。対象は編集中のファイルそのものではなく、/tmp の下に作られたコピーです。
app コンテナがマウントしているのはプロジェクトディレクトリだけ。コンテナから /tmp のこのパスは見えないため、docker compose exec へ素直に渡すと断られます。
The path "/tmp/pcf-tmp0.4091961245219523/Sample.php" is not readable.
終了コードは 16(設定エラー)。エディタ側には整形されなかった結果だけが返ります。
一時ファイルの置き場所は php-cs-fixer.tmpDir で変更できます。ただしこの設定に ${workspaceFolder} は展開されず、${workspaceFolder} という名前のディレクトリが実際に作られます。マシン固有の絶対パスを settings.json に書くことになるため、ここでは使いません。
${workspaceFolder} を解決するのは php-cs-fixer.executablePath だけ。php-cs-fixer.config に "${workspaceFolder}/.php-cs-fixer.dist.php" と書くと、拡張はその文字列を解決できずに設定ファイルを見失い、--config の代わりに php-cs-fixer.rules の既定値(--rules=@PSR12)へ落ちます。エディタと CLI のルールが黙って分かれる形になるので、設定ファイルの位置は自動発見に任せてください。
5. 橋渡しスクリプトを置く
/tmp のコピーをプロジェクト内へ移してからコンテナに渡し、結果を書き戻すスクリプトを作成します。
~/projects/vscode-php-format-on-save-demo/tools/php-cs-fixer-docker.sh
#!/usr/bin/env bash
set -u
PROJECT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT" || exit 1
STAGE="$(mktemp -d "$PROJECT/.php-cs-fixer-editor.XXXXXX")" || exit 1
trap 'rm -rf "$STAGE"' EXIT
args=()
target=""
staged=""
for a in "$@"; do
case "$a" in
--config=*)
args+=("--config=/workspace/$(realpath --relative-to="$PROJECT" "${a#--config=}")")
;;
/*)
if [ -f "$a" ]; then
target="$a"
staged="$STAGE/$(basename "$a")"
cp -- "$a" "$staged" || exit 1
args+=("/workspace/$(basename "$STAGE")/$(basename "$a")")
else
args+=("$a")
fi
;;
*)
args+=("$a")
;;
esac
done
err="$(mktemp)"
out="$(docker compose exec -T app vendor/bin/php-cs-fixer "${args[@]}" 2>"$err")"
rc=$?
if [ "$rc" -ne 0 ]; then
cat "$err" >&2
rm -f "$err"
exit "$rc"
fi
rm -f "$err"
[ -n "$target" ] && { cp -- "$staged" "$target" || exit 1; }
printf '%s\n' "$out"
実行権限を付けます。付け忘れると拡張から起動できません。
chmod +x tools/php-cs-fixer-docker.sh
Git 管理下のプロジェクトへ持ち込む場合は、中継用ディレクトリを管理対象から外します。
~/projects/vscode-php-format-on-save-demo/.gitignore
/.php-cs-fixer-editor.*/
コードのポイント
① 中継場所を実行ごとに分ける
STAGE="$(mktemp -d "$PROJECT/.php-cs-fixer-editor.XXXXXX")" || exit 1
trap 'rm -rf "$STAGE"' EXIT
保存のたびにこのスクリプトが起動するため、中継用ディレクトリを固定名にすると「すべて保存」で複数の実行が同じ場所を奪い合います。先に終わった実行の後片付けが、まだ整形中の実行のファイルを消す並びです。mktemp -d で実行ごとに別の場所を取り、trap で自分が作った分だけを消せば、同時に何本走っても干渉しません。同名のファイルが別ディレクトリにあっても混ざらないのは、この分離のためです。
② 引数の中の絶対パスをコンテナ側へ読み替える
--config=*)
args+=("--config=/workspace/$(realpath --relative-to="$PROJECT" "${a#--config=}")")
;;
--config には WSL の絶対パスが入ります。プロジェクトからの相対パスへ直してから /workspace を頭に付けると、コンテナ内の同じファイルを指します。バインドマウントの左右を対応づけているだけです。
③ 一時ファイルをマウントの内側へ移す
/*)
if [ -f "$a" ]; then
target="$a"
staged="$STAGE/$(basename "$a")"
cp -- "$a" "$staged" || exit 1
args+=("/workspace/$(basename "$STAGE")/$(basename "$a")")
拡張が渡す一時ファイルは常に絶対パスなので、/*) に限って中継します。fix のようなサブコマンドを取り違えないためで、プロジェクト直下に fix という名前のファイルがあっても壊れません。複製をコンテナから見える /workspace/... へ差し替えると、php-cs-fixer は渡されたファイルを直接書き換えるため、整形結果が入るのは複製の側。
④ 失敗したときだけ標準エラーを返す
err="$(mktemp)"
out="$(docker compose exec -T app vendor/bin/php-cs-fixer "${args[@]}" 2>"$err")"
rc=$?
if [ "$rc" -ne 0 ]; then
cat "$err" >&2
rm -f "$err"
exit "$rc"
fi
php-cs-fixer は成功時も注意書きを標準エラーへ出すため、標準出力へ混ぜると拡張の JSON 解析が壊れます。かといって捨てると、コンテナが停止しているときの service "app" is not running まで道連れ。失敗時だけ返せば、JSON を汚さずに理由が拡張の出力へ届きます。
⑤ 結果を拡張が読む場所へ戻す
[ -n "$target" ] && { cp -- "$staged" "$target" || exit 1; }
printf '%s\n' "$out"
拡張はプロセス終了後に、自分が渡した /tmp のパスを読み直して差分を適用します。書き戻さなければ、整形は起きてもエディタ上は変わらないまま。標準出力の JSON をそのまま返しているのも同じ理由で、files が空だと拡張は「変更なし」と判断します。
なお、このスクリプトは拡張から保存1回につき1ファイルを渡される前提で書いています。手動で複数ファイルを渡す用途には向きません。
6. 保存時整形を有効にする
.vscode/settings.json を作成します。
~/projects/vscode-php-format-on-save-demo/.vscode/settings.json
{
"editor.formatOnSave": true,
"[php]": {
"editor.defaultFormatter": "junstyle.php-cs-fixer"
},
"php-cs-fixer.executablePath": "${workspaceFolder}/tools/php-cs-fixer-docker.sh"
}
editor.defaultFormatter を [php] の中へ書くのは、PHP の整形係だけを指定するため。この行を省くと保存しても整形は走りません。整形できる拡張が php-cs-fixer 1つしか入っていない場合でも同じで、保存は成立するのに内容だけが変わらない状態になります。executablePath では ${workspaceFolder} が展開されるため、マシン固有のパスは入らずに済みます。
src/Sample.php を開きます。保存前は2章で作ったままの状態です。
Ctrl + S を押すと、内容がこう変わります。
<?php
class Sample
{
public function run(): array
{
return [ 'status' => 'ok' ];
}
}
配列の内側に空白が残るのは、@PSR12 に配列の空白を詰めるルールが含まれないため。消したい場合は trim_array_spaces を .php-cs-fixer.dist.php へ足します。判断の置き場所はエディタ側ではなく、CLI と共通のルールファイルです。
7. CLI と結果が一致することを確認する
エディタで整形したファイルを、CLI 側の確認コマンドに掛けます。
docker compose exec app vendor/bin/php-cs-fixer fix --dry-run --diff src
Found 0 of 1 files that can be fixed in 0.000 seconds, 16.00 MB memory used
終了コードは 0。同じバイナリと同じ .php-cs-fixer.dist.php を通しているため、保存時整形を入れても CLI 側の確認結果は動きません。前提記事の「最終確認は CLI で統一する」運用は、そのまま残せる形です。
8. editor.formatOnSave を使わない経路
拡張には php-cs-fixer.onsave という独自の保存フックもあります。
{
"editor.formatOnSave": false,
"php-cs-fixer.onsave": true,
"php-cs-fixer.executablePath": "${workspaceFolder}/tools/php-cs-fixer-docker.sh"
}
こちらは editor.defaultFormatter を指定せずに PHP だけを整形します。他言語の保存時整形を切ったまま PHP だけ動かしたいときに向きます。
両方を true にしても効果は重なりません。拡張側のフックは editor.formatOnSave が false のときだけ動くよう作られていて、true のときは VS Code 標準の整形経路へ譲ります。どちらか一方を選ぶ設定です。
9. つまずいたときの確認
| 症状 | 主な原因 | 確認 | 対処 |
|---|---|---|---|
| 保存しても何も起きない | フォルダが制限モード | ウィンドウ左下の 制限モード 表示 | フォルダを信頼する |
| 保存しても何も起きない | [php] の整形係が未指定 | settings.json に [php] の editor.defaultFormatter があるか | 6章の設定を入れる |
| 保存しても何も起きない | 整形係が別の拡張になっている | [php] の editor.defaultFormatter の値 | junstyle.php-cs-fixer へ変える |
General error (or PHP minimal requirement not matched) がステータスバーに出る | app コンテナが停止している | docker compose ps | docker compose up -d を実行する |
| 同上 | スクリプトに実行権限がない | ls -l tools/php-cs-fixer-docker.sh | chmod +x tools/php-cs-fixer-docker.sh を実行する |
| 整形はされるが CLI と結果が食い違う | 別の設定ファイルを読んでいる | .vscode/ に .php-cs-fixer.dist.php が無いか | 候補ファイルをプロジェクト直下の1つに寄せる |
| 拡張が有効にならない | Windows 側でフォルダを開いている | ウィンドウ左下が WSL: Ubuntu か | WSL ターミナルで code . を実行して開き直す |
WSL ターミナルから code . しても Windows 側で開く | Remote - WSL が未導入 | 拡張ビューで ms-vscode-remote.remote-wsl を検索 | Windows 側の VS Code へ Remote - WSL を導入する |
ステータスバーの General error ... は、スクリプトが起動しなかったときにも、起動して失敗したときにも出ます。切り分けは拡張の出力パネル(php cs fixer)にあり、コンテナが停止していれば service "app" is not running がそこに残ります。
制限モードは切り分けの最初に見てください。junstyle.php-cs-fixer は信頼していないフォルダでの動作を宣言していないため、制限モードでは拡張ごと無効になります。設定を何度見直しても、信頼するまで保存時整形は沈黙します。
10. Laravel の場合と次の一歩
Laravel では php-cs-fixer の代わりに Pint を使えます。Pint は php-cs-fixer のラッパーで、設定を書かなくても Laravel のスタイルに合わせてくれます。導入と CI での回し方は Laravelで品質ゲートを敷く を参照してください。
次の一歩:
.vscode/extensions.jsonに整形係を含めて、チームの推奨拡張をそろえる(VS Codeで始めるPHP開発環境).php-cs-fixer.dist.phpのルールを増やし、影響を CLI の--dry-run --diffで確認してから共有する- CI で
fix --dry-runを回し、整形されないまま入ったファイルを検出する