公開日 2026-03-17

GitHub ActionsでPHPUnit / PHP CS Fixer / PHPStanを回す最小CI

ローカルで通しているPHPの品質チェックをGitHub Actionsに載せる最小CIを構築する。

目次

  1. 前提環境
  2. 1. ゴールと非対象
  3. 2. ローカル用の最小デモ環境を作る
  4. 3. 3ツールと composer scripts をそろえる
  5. 4. ローカルで 4つの確認コマンドが通ることを確認する
  6. 5. .github/workflows/ci.yml を作る
  7. コードのポイント
  8. 6. 失敗ログの見方を覚える
  9. 7. Docker を使う場合と使わない場合を切り分ける
  10. 8. まとめと次の一歩

対象読者: WSL2 + Docker で PHP 開発中で、ローカルの composer validate --strictcomposer format:check / composer analyse / composer test を GitHub Actions に載せたい方

主線は GitHub-hosted runner + actions/checkout + shivammathur/setup-php に固定し、PHPUnit / PHPStan / PHP CS Fixer を 1 本の workflow へ載せます。

ローカル確認は既存シリーズに合わせて WSL2 + Docker で進めます。
一方で、CI 側は最初から Docker を持ち込まず、GitHub-hosted runner の PHP で十分なケースに絞ります。
PostgreSQL や独自 extension が必要なケースは、最後に「Docker を使うべき条件」として切り分けます。

前提環境

  • Windows 11
  • WSL2(Ubuntu)
  • VS Code(Remote - WSL)
  • Docker Desktop(WSL 連携有効)
  • GitHub.com 上に作成済みのリポジトリ(origin 設定済み)

以降のコマンドは、特記がない限り WSL 側ターミナルで実行します。
サービス名は app に固定します。

1. ゴールと非対象

この記事で到達する状態:

  • composer validate --strict / composer format:check / composer analyse / composer test を GitHub Actions で自動実行できる
  • .github/workflows/ci.yml を全文で理解しながら置ける
  • CI が落ちたときに、どの step を見ればよいか判断できる

3 ツールの役割は次のとおりです。

  • PHP CS Fixer: 整形崩れを落とす
  • PHPStan: 型不整合や静的に見える危険を落とす
  • PHPUnit: 既知の振る舞い崩れを落とす

この記事で扱わない内容:

  • PostgreSQL などのサービス付き CI
  • docker compose をそのまま実行する CI
  • 複数 PHP バージョン matrix
  • dependency cache の最適化
  • self-hosted runner / GitHub Enterprise Server 固有の設定
  • artifact upload や coverage レポート連携

2. ローカル用の最小デモ環境を作る

WSL 側のシェルで作業します。
Windows 側から始める場合は wsl で Ubuntu に入り、以下を実行してください。

# Windows側から始める場合のみ実行
# wsl
mkdir -p ~/projects/php-github-actions-minimal-ci-demo
cd ~/projects/php-github-actions-minimal-ci-demo
git init
git branch -M main
mkdir -p docker/php src tests
code .

新規フォルダから始めるので、ここでローカル Git リポジトリも初期化しておきます。 まだ GitHub 上にリポジトリを作成していない場合は、gh コマンドで作成して origin を設定します。

gh repo create php-github-actions-minimal-ci-demo --public
git remote add origin https://github.com/<ユーザー>/php-github-actions-minimal-ci-demo.git

gh が入っていない場合は、GitHub のウェブ画面からリポジトリを作成し、表示される git remote add コマンドをそのまま実行してください。リポジトリ名は上記を参考にしてください。 以降は、git push origin main でリモートへ送れる状態を前提に進めます。

compose.yml を作成します。

services:
  app:
    build:
      context: .
      dockerfile: docker/php/Dockerfile
    working_dir: /workspace
    volumes:
      - ./:/workspace
    command: ["sleep", "infinity"]

docker/php/Dockerfile を作成します。
今回は php:8.5-cli を土台にし、ローカル側では後で php -m を使って PHPUnit が使う拡張を確認します。

FROM php:8.5-cli

RUN apt-get update \
    && apt-get install -y --no-install-recommends git unzip \
    && rm -rf /var/lib/apt/lists/*

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

WORKDIR /workspace

composer.json はこの時点で scripts まで入れておきます。
こうしておくと、後で composer validate --strict を通すときに composer.lock の整合を崩しにくくなります。

{
  "name": "example/php-github-actions-minimal-ci-demo",
  "description": "Minimal PHP CI demo for GitHub Actions article",
  "type": "project",
  "license": "MIT",
  "require": {
    "php": "^8.5"
  },
  "require-dev": {},
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  },
  "autoload-dev": {
    "psr-4": {
      "Tests\\": "tests/"
    }
  },
  "scripts": {
    "format:check": "php-cs-fixer fix --dry-run --diff",
    "analyse": "phpstan analyse",
    "test": "phpunit"
  }
}

起動して確認します。

docker compose up -d --build
docker compose exec app php -v
docker compose exec app composer --version
docker compose exec app php -m | grep -E '^(dom|libxml|mbstring|tokenizer|xml|xmlwriter)$'

起動後の確認例:

PHP 8.5.3 (cli)
Composer version 2.9.5
コンテナ起動後のPHPとComposer、PHPUnit前提拡張の確認

PHPUnitmbstring だけでなく XML 系拡張も使います。 今回の php:8.5-cli では dom / libxml / mbstring / tokenizer / xml / xmlwriter が有効でした。 ベースイメージや配布 PHP を変える場合は、同じ php -m の確認を省かないでください。

詰まったとき:

  • コンテナが起動しない: docker compose logs app
  • Dockerfile を変えたのに反映されない: docker compose up -d --build

3. 3ツールと composer scripts をそろえる

次に、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

composer require --dev ... を実行すると、composer.jsonrequire-devcomposer.lock が更新されます。
CI の再現性を保つため、composer.lockcomposer.json と一緒にコミットしてください。

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

.php-cs-fixer.dist.php を作成します。
対象は srctests に固定します。

<?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);

最小のサンプルコードを置きます。
ここでは、入力文字列を整えて挨拶文を返すだけの GreetingService にします。

src/GreetingService.php

<?php

declare(strict_types=1);

namespace App;

final class GreetingService
{
    public function format(string $name): string
    {
        return sprintf('Hello, %s!', trim($name));
    }
}

tests/GreetingServiceTest.php

<?php

declare(strict_types=1);

namespace Tests;

use App\GreetingService;
use PHPUnit\Framework\TestCase;

final class GreetingServiceTest extends TestCase
{
    public function testFormatReturnsTrimmedGreeting(): void
    {
        $service = new GreetingService();

        self::assertSame('Hello, Taro!', $service->format('  Taro  '));
    }
}

この時点で composer.json に追加されるのは、主に require-dev と次の scripts です。

"scripts": {
  "format:check": "php-cs-fixer fix --dry-run --diff",
  "analyse": "phpstan analyse",
  "test": "phpunit"
}

format:check でパスを個別に並べず、Finder 側へ寄せているのは、ローカルでも CI でも同じコマンドをそのまま使えるようにするためです。 パス指定を .php-cs-fixer.dist.php 側に集約しておけば、対象ディレクトリを変えてもコマンドは触らずに済みます。

詰まったとき:

  • vendor/bin が見つからない: docker compose exec app ls -la vendor/bin
  • クラスが見つからない: docker compose exec app composer dump-autoload
  • format:check が末尾改行や改行コードで落ちる: ファイル末尾に改行を入れ、VS Code の右下を LF にして保存し直す

4. ローカルで 4つの確認コマンドが通ることを確認する

CI を書く前に、同じコマンド列をローカルで通します。

docker compose exec app composer validate --strict
docker compose exec app composer format:check
docker compose exec app composer analyse
docker compose exec app composer test

成功時の確認例:

./composer.json is valid

Found 0 of 2 files that can be fixed

 [OK] No errors

OK (1 test, 1 assertion)

ここで composer validate --strict を最初に置くのは、workflow の書き方以前に composer.json / composer.lock の整合が崩れていないかを先に確認するためです。
ローカルで通らないものを、そのまま CI に載せても切り分けが難しくなります。

詰まったとき:

  • composer validate --strict で lock 不整合が出る: まず docker compose exec app composer install --no-interaction --no-progress で再確認する
  • required package is not present in the lock file 系が出る: docker compose exec app composer update vendor/package-name --no-interaction --no-progress で対象パッケージだけ更新する
  • content-hash だけの軽微なずれが出る: docker compose exec app composer update --lock --no-interaction --no-progress でも直せる
  • format:check だけ落ちる: まずファイル末尾の改行有無を確認し、そのうえで LF 保存と引用符・空白を見直す
  • analyse が落ちる: 型不整合が出ているファイルへ戻る
  • test が落ちる: 期待値と実装の文字列を見比べる

5. .github/workflows/ci.yml を作る

ここから GitHub Actions 側です。
この章で実際にやることは、次の 5 手順です。

  1. ローカルのプロジェクトルートで .github/workflows/ci.yml を作る
  2. workflow 全文を貼る
  3. workflow と関連ファイルをコミットする
  4. GitHub へ push する
  5. GitHub の Actions タブで ci の実行結果を確認する

まずディレクトリを作成します。

mkdir -p .github/workflows

.github/workflows/ci.yml を作成します。各 step の実行順と設定の意図を確認しながら置けるよう、cache や matrix は含まない最小構成にしています。

name: ci

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

permissions:
  contents: read

jobs:
  qa:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v6

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.5'
          coverage: none
          extensions: mbstring

      - name: Validate composer.json
        run: composer validate --strict

      - name: Install dependencies
        run: composer install --no-interaction --no-progress --prefer-dist

      - name: Check formatting
        run: composer format:check

      - name: Run PHPStan
        run: composer analyse

      - name: Run PHPUnit
        run: composer test

コードのポイント

permissions: contents: read で最小権限にする

permissions:
  contents: read

checkout に必要なリポジトリ読み取り権限のみを宣言しています。デフォルトでは書き込み権限も付与されるため、明示的に絞ることで仮にサードパーティ action が侵害された場合のリスクを抑えられます。

Checkout repository を最初に置く理由

      - name: Checkout repository
        uses: actions/checkout@v6

setup-php より前にソースコードが存在しないと composer install も動きません。checkout で runner にコードを取得してから PHP セットアップへ進む順序が前提です。actions/checkout@v6 は使用前に リリースページ で最新タグを確認してください。

--prefer-distcomposer.lock を基準に再現する

      - name: Install dependencies
        run: composer install --no-interaction --no-progress --prefer-dist

--prefer-dist を付けると zip パッケージを使い、コミット済みの composer.lock の内容を正確に再現します。--no-interaction--no-progress は対話確認とプログレス表示を無効にして、CI ログを読みやすくするためです。self-hosted runner や独自イメージでは setup-php が入れる mbstring 以外に dom / xml / xmlwriter も確認してください。

次に、workflow を GitHub へ送るための最小手順をそのまま実行します。 ここでは、ローカルで作ったデモ一式をすでに GitHub リポジトリへ push できる前提で進めます。

git add composer.json composer.lock phpunit.xml phpstan.neon .php-cs-fixer.dist.php src tests .github/workflows/ci.yml
git commit -m "Add minimal PHP CI workflow"
git push origin main

デフォルトブランチが main ではない場合は、workflow の branches: [main]git push origin main の両方を自分のブランチ名へ読み替えてください。 GitHub に push できたら、ブラウザで対象リポジトリの Actions タブを開き、ci workflow が起動していることを確認します。

GitHub Actionsのci workflowが全step成功している画面

この workflow を push する前に、少なくとも次のファイルは一緒にコミットしてください。

  • composer.json
  • composer.lock
  • phpunit.xml
  • phpstan.neon
  • .php-cs-fixer.dist.php
  • src/
  • tests/
  • .github/workflows/ci.yml

6. 失敗ログの見方を覚える

GitHub Actions で落ちたときは、次の順に辿ります。

  1. GitHub の Actions タブを開く
  2. 失敗した workflow run を開く
  3. qa job を開く
  4. 赤くなっている step を開いてログを読む

job 全体をぼんやり眺めるより、落ちた step を先に開くほうが早いです。
step 名とローカル再実行コマンドを対応づけると、復旧もしやすくなります。

step 名まずローカルで再実行するコマンド
Validate composer.jsondocker compose exec app composer validate --strict
Check formattingdocker compose exec app composer format:check
Run PHPStandocker compose exec app composer analyse
Run PHPUnitdocker compose exec app composer test

Validate composer.json が落ちる場合は、CI より先に composer.json / composer.lock の整合を見直します。

代表的な失敗例:

Check formatting が落ちた例

1) src/GreetingService.php
   ---------- begin diff ----------
@@ -8,6 +8,6 @@
-        return sprintf("Hello, %s!", trim($name));
+        return sprintf('Hello, %s!', trim($name));
...
Script php-cs-fixer fix --dry-run --diff handling the format:check event returned with error code 8

これは、整形ルールに合っていないだけです。
今回のようにファイル末尾の改行がないだけでも落ちるので、まず末尾に 1 行改行を入れて保存し直します。 そのうえでローカルで composer format:check を再実行します。

Run PHPStan が落ちた例

Line   tests/GreetingServiceTest.php
16     Parameter #1 $name of method App\GreetingService::format() expects string, int given.

[ERROR] Found 1 error

これは、型の食い違いです。
対象ファイルへ戻り、引数型や戻り値型を見直します。

Run PHPUnit が落ちた例

There was 1 failure:

1) Tests\GreetingServiceTest::testFormatReturnsTrimmedGreeting
Failed asserting that two strings are identical.
--- Expected
+++ Actual
@@ @@
-'Hello, Taro'
+'Hello, Taro!'

これは、実装と期待値の振る舞いがずれています。
仕様としてどちらが正しいかを確認し、実装かテストのどちらを直すか判断します。

7. Docker を使う場合と使わない場合を切り分ける

ローカルが Docker でも、CI まで必ず Docker にする必要はありません。
まずは、次の表で判断すると整理しやすいです。

条件今回の非Docker主線で十分Docker CI へ進む
アプリの確認対象PHPUnit / PHPStan / PHP CS Fixer だけDB 接続、Redis、メール、外部サービス起動待ちが必要
PHP 実行環境setup-php の PHP と標準拡張で足りる独自 extension、OS パッケージ、システム依存がある
ローカルとの差分少し違っても困らないDockerfile と CI の差を減らしたい
workflow の複雑さ最初は読みやすさを優先したいservicesdocker compose の手順を含めたい

次の条件に当てはまるなら、今回の主線で十分です。

  • GitHub-hosted runner の PHP だけで品質チェックが完結する
  • DB や Redis を立てなくても test が走る
  • GitHub-hosted runner と setup-php の組み合わせで必要な PHP 拡張を満たせる

逆に、次の条件なら Docker CI 記事へ進んだほうが自然です。

  • PostgreSQL / MySQL / Redis などのサービス起動が必要
  • apt-get や独自 extension を CI でもそろえたい
  • ローカルの docker compose と CI の差をできるだけ減らしたい

8. まとめと次の一歩

本記事で、次の状態を作れました。

  • ローカルの composer validate --strict と 3 つの品質チェックを整理できる
  • GitHub Actions の最小 workflow を全文で置ける
  • CI が落ちたときに、step 単位でログを読める

最初の CI は、速さよりも読みやすさを優先したほうが扱いやすいです。
まずはローカルで通る 3 コマンドに composer validate --strict を加えた workflow からで十分です。

ローカルの受け入れ基準から整理したい場合は、AI生成コードを受け入れる最小品質ゲート(PHPStan + PHPUnit + CS Fixer) が先に役立ちます。
DB 付きや docker compose を使う CI へ進む場合は、GitHub Actionsで docker compose を使うPHP CI(PostgreSQL付き) が次のステップになります。

シリーズ 3/4

このシリーズ

PHPのテストと品質改善

  1. 1. PHPUnit入門(最初のテスト1本)
  2. 2. PHPStan入門(最初のエラー1件を直す)
  3. 3. GitHub ActionsでPHPUnit / PHP CS Fixer / PHPStanを回す最小CI 現在の記事
  4. 4. PHPUnitでDBテストを始める(PostgreSQL + Docker)