公開日 2026-09-21

VS Codeで保存時にPHPを自動整形する(拡張の選定とコンテナ内php-cs-fixerへの橋渡し)

VS Codeの保存時整形をPHPで動かす手順。整形係にする拡張の選び方、editor.formatOnSaveとdefaultFormatterの設定、コンテナ内のphp-cs-fixerをエディタから呼ぶ橋渡しまで。

目次

  1. 前提環境
  2. 1. 整形を担当する拡張を1つに決める
  3. 2. 作業ディレクトリとコンテナを用意する
  4. 3. 拡張を WSL 側に入れる
  5. 4. エディタとコンテナの間にある段差
  6. 5. 橋渡しスクリプトを置く
  7. コードのポイント
  8. 6. 保存時整形を有効にする
  9. 7. CLI と結果が一致することを確認する
  10. 8. editor.formatOnSave を使わない経路
  11. 9. つまずいたときの確認
  12. 10. Laravel の場合と次の一歩

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 を使っているなら、そのまま残してください。

拡張ビューで php-cs-fixer を検索した結果。junstyle の php cs fixer が先頭に並ぶ

検索すると分かるとおり、名前の似た拡張が並びます。発行者が 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 には WSL のバッジが付き、PHP Debug には「WSL: Ubuntu にインストールする」ボタンが出ている

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} が展開されるため、マシン固有のパスは入らずに済みます。

.vscode/settings.json を開いた画面。formatOnSave と php スコープの defaultFormatter、executablePath の3つが入っている

src/Sample.php を開きます。保存前は2章で作ったままの状態です。

保存前の Sample.php。class Sample{ や array( "status" => "ok" ) が詰まったまま並んでいる

Ctrl + S を押すと、内容がこう変わります。

保存後の Sample.php。PSR-12 のインデントと短い配列記法、シングルクォートに整っている
<?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 psdocker compose up -d を実行する
同上スクリプトに実行権限がないls -l tools/php-cs-fixer-docker.shchmod +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で品質ゲートを敷く を参照してください。

次の一歩:

  1. .vscode/extensions.json に整形係を含めて、チームの推奨拡張をそろえる(VS Codeで始めるPHP開発環境)
  2. .php-cs-fixer.dist.php のルールを増やし、影響を CLI の --dry-run --diff で確認してから共有する
  3. CI で fix --dry-run を回し、整形されないまま入ったファイルを検出する