Skip to content
intermediate Phase 15 · Performance and Deployment

Shipping with a CI Pipeline

Run tests and static analysis on every change.

45m
0 problems
Topic Progress 0%

The First Green Run: A Workflow That Actually Installs PHP and Runs Your Suite

Continuous integration for a PHP project is one promise: every push runs the real suite on a clean machine, so a regression surfaces before merge rather than in production. The workflow below is the working skeleton — read it as four decisions, not boilerplate:

name: CI
on:
  push: { branches: [main] }
  pull_request:

jobs:
  tests:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        php: ['8.2', '8.3']
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
          extensions: mbstring, pdo_mysql, intl
          coverage: pcov
          tools: composer:v2
      - name: Resolve Composer cache directory
        id: composer-cache
        run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
      - uses: actions/cache@v4
        with:
          path: ${{ steps.composer-cache.outputs.dir }}
          key: composer-${{ hashFiles('composer.lock') }}
          restore-keys: composer-
      - run: composer install --prefer-dist --no-interaction --no-progress
      - run: vendor/bin/phpunit --coverage-text

Setup is explicit. shivammathur/setup-php@v2 installs the exact PHP version with the extensions the suite needs (pdo_mysql for tests against MySQL, pcov for coverage) — the runner's preinstalled PHP is not yours to assume. fail-fast: false keeps the 8.2 job running when 8.3 fails, so one broken version does not hide the state of the other.

Two hygiene habits belong in the workflow from day one. Declare permissions: { contents: read at the top so the token cannot write anything by accident — a compromised dependency in a build step gets read-only access to the repository, nothing more. And treat third-party actions as dependencies: pin uses: owner/action@v4 to a major tag for maintenance, or to a commit SHA when your threat model includes supply-chain tampering; either way, the pin is reviewable in the diff like any other code.

--prefer-dist is the speed lever. Composer can install each locked package as a downloaded archive (dist) or by cloning the package's version-control repository (source). --prefer-dist takes the zip — tens of seconds for a cold install where source mode can spend minutes in Git handshakes. Same locked code either way; only the transport differs.

Cache the Composer cache, not vendor/. The workflow resolves composer config cache-files-dir and keys it on hashFiles('composer.lock'). When the lock file changes, the key misses and only the changed packages download; restore-keys still restores the closest previous cache instead of starting cold. Caching vendor/ directly is the tempting shortcut that goes stale — a lock change with a partial hit can leave yesterday's dependencies in today's build, and no test failure will explain it.

Warm-run behavior is worth picturing, because it explains every cache decision: with a hit, the cache step restores Composer's downloaded archives and the install step verifies the lock file against them — no round trips to Packagist, install measured in seconds. On a miss, only the packages absent from the restored cache download; --prefer-dist plus a full cache is what takes a typical cold install from minutes to under half a minute. Two hygiene steps cost one line each and pay for themselves: composer validate --strict catches a stale or malformed lock file before the suite can, and composer audit fails the build when a dependency ships with a known vulnerability.

Local parity is the payoff. The pipeline is now composer install && vendor/bin/phpunit — exactly what every developer runs, which is what kills "works on my machine": if it fails on CI, checking out the same commit and running the same two commands reproduces it. Green means the suite passed on every supported PHP version; from here, quality adds layers — static analysis, style, and the question of whether the suite would actually catch a bug, which is what Infection asks in Chapter 2.

Mutation Testing: Your Suite Is Green at 85% Coverage and Still Wrong

Line coverage answers "was each line executed?" — nothing about whether your tests would notice if it were wrong. assertEquals(1, 1) executes everything and asserts nothing. Mutation testing closes that gap by attacking your code the way a bug would.

Infection takes your source and produces mutants — tiny, semantically plausible mutations: == becomes !=, && becomes ||, $i++ becomes $i--, return $total becomes return 0, a >= loses its =. Each mutant is tested against your suite. If the suite still passes, the mutant survived — and a surviving mutant is indistinguishable from a real bug shipped to production. A dead giveaway for interviews: the >= → > survivor in a discount calculator is an off-by-one your assertions never pinned down.

Configure it once:

{
  "source": { "directories": ["src"] },
  "minMsi": 80,
  "minCoveredMsi": 90,
  "timeout": 10,
  "logs": { "html": "infection.html", "junit": "infection.xml" }
}

Then run it:

vendor/bin/infection --threads=8 --only-covered --min-msi=80

The numbers are the gate. minMsi is the mutation score — killed mutants as a percentage of all non-timed-out mutants — and 80 is a common floor for an existing suite. minCoveredMsi scores only mutants in code your tests reach, which separates two different problems: uncovered code (a coverage gap) and covered-but-unasserted code (an assertion gap). --only-covered skips the first kind so the run spends its time on the second; timeout kills mutants that loop forever.

Two practical realities shape the CI job. First, Infection needs coverage data, and the driver choice dominates runtime: pcov is purpose-built for coverage and is typically several times faster than xdebug on the same suite — that is why the workflow in Chapter 1 installs coverage: pcov. Second, Infection is slow by nature — every mutant reruns the covering tests — so scope it: on pull requests, --filter to changed source paths; run the full scored suite nightly. The gate that ratchets — never let today's score drop below yesterday's — is how a team of ten keeps a suite honest without a big-bang cleanup.

Reading the output is half the skill. Infection prints a per-file breakdown and closes with a line like Mutation Score Indicator (MSI) 84% (706/840) — 706 mutants killed out of 840 valid ones. An escaped == → != in src/Invoice/DiscountCalculator.php tells you exactly which file to open, and the HTML log shows the mutant's diff beside the surviving test run, so the missing assertion is usually obvious the moment the two appear side by side. One scoring subtlety: a mutant that exceeds the timeout counts as killed but flags for review anyway — a test suite that hangs when a boundary operator flips is a production hang looking for a trigger.

Budget the job honestly, too. A several-hundred-mutant run against a real suite takes tens of minutes even with threads, which is why the pull-request job --filters to changed source and the nightly job enforces the full score — both failing on minMsi, so the ratchet works at both granularities.

Where does the time go in practice? On the first full run, dozens of survivors usually cluster in business-logic classes nobody wrote assertions for: a status-transition guard, a rounding rule, a permission check. Each surviving mutant gets a test that kills it, and the suite you already had quietly becomes a suite that actually protects the code. Pair it with PHPUnit and the coverage gate from php-phpunit-coverage and you have three independent signals — lines executed, tests passing, mutants killed — instead of one green checkmark that means "nothing crashed."

Making Green Mean Something: Required Checks, the MySQL Matrix, and the Release Artifact

A pipeline that runs but gates nothing is theater. Three mechanisms give the green checkmark authority.

Required status checks. Branch protection on main lists the checks that must pass before merge — tests (8.2), tests (8.3), static-analysis, mutations — so nobody can merge around a failure. Pair it with a concurrency group so a superseded push does not waste runners or leave confusing red Xs on abandoned commits:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

Test against the real database. Integration suites need MySQL, and GitHub gives you one as a job service:

    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: root
          MYSQL_DATABASE: app_test
        ports: ['3306:3306']
        options: >-
          --health-cmd="mysqladmin ping"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=5
    steps:
      - run: vendor/bin/phpunit
        env:
          DB_HOST: 127.0.0.1
          DB_PORT: 3306
          DB_DATABASE: app_test

The --health-cmd line is the difference between a reliable pipeline and a flaky one: without it, PHPUnit starts the moment the container exists but before MySQL accepts connections, and the first-run failure "Connection refused" gets misread as an application bug. Failures should also localize: lint (php-cs-fixer fix --dry-run --diff) and static analysis (vendor/bin/phpstan analyse --level=6) as separate jobs, so a style nit does not hide behind a test failure.

Ship what you tested. The deploy job does not rebuild dependencies from scratch — it transforms the tested ones:

  deploy:
    needs: [tests, static-analysis, mutations]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: composer install --no-dev --prefer-dist --optimize-autoloader
      - uses: actions/upload-artifact@v4
        with: { name: release, path: dist }

Note the deliberate asymmetry: test jobs install with dev dependencies (PHPUnit, PHPStan, and Infection live there), while the artifact is built --no-dev so production never carries test code — and --optimize-autoloader flattens the class map for production autoloading.

Two finishing controls separate a pipeline from a script. environment: production on the deploy job attaches environment rules — required reviewers, wait timers, and secrets scoped so that job alone can read them, meaning a malicious or merely careless pull request cannot print deploy credentials to a log. And the artifact stays immutable: one upload per run, downloaded by the deploy step, never rebuilt. Rollback then costs nothing intellectually — redeploy the previous run's artifact — because what runs in production is byte-identical to what that labeled run tested. Matrix thinking extends past PHP versions as well: adding a second axis (MySQL 8.0 vs 8.4, Redis 7 vs 8) costs runner minutes and buys advance warning of the day your database upgrades underneath you.

Credentials go in ${{ secrets.* }}, which GitHub masks in logs, and production deploys attach to an environment with required reviewers. Name the artifact with the commit SHA (release-${{ github.sha }}) so any production process can answer "which build is running, and what was tested with it" by reading its own version string — the pair of habits is what makes an incident review short.

The last ingredient is cultural, and interviewers probe it: a green pipeline is the team's shared signal that a change is safe to release — it is worth exactly as much as everyone's discipline about it. When someone reruns a flaky job until it passes, the signal dies for everyone. Fix the flake or quarantine it explicitly; never let green mean "we got lucky this time."

You now have a complete loop: the suite and mutation gate protect the code, the artifact carries it, and the Nginx/FPM stack from Deploying behind Nginx and PHP-FPM serves it. What remains is composing all of it into one project — Scoping the Capstone API starts that.

Quiz

1. A workflow caches dependencies with actions/cache keyed on hashFiles('composer.lock'). What does this guarantee when the lock file changes?

Question 1 options

2. What does composer install --prefer-dist actually change on CI?

Question 2 options

3. Infection reports a mutant that changed >= to > and your suite still passes. What does that prove?

Question 3 options

4. A CI job with a MySQL:8.0 service container fails intermittently with "Connection refused" on the first run and passes on re-run. Most likely cause and fix?

Question 4 options

Flashcards

Question

Why cache Composer's cache-files-dir keyed on composer.lock instead of caching vendor/ directly?

Answer

Keying the cache directory on hashFiles('composer.lock') invalidates exactly when dependencies change, and restore-keys fall back to the nearest previous cache so only changed packages download. Caching vendor/ risks stale dependencies surviving a lock change, silently building yesterday's code.

Question

What does --prefer-dist do in composer install, and when would you not want it?

Answer

It installs each locked package from its distribution archive (zip) rather than cloning the package's VCS repository — same locked code, far faster on CI and fresh machines. Source installs are reserved for debugging a package or when dist archives are unavailable.

Question

How does mutation testing work, and what do minMsi and an escaped mutant mean?

Answer

Infection rewrites source into mutants (== → !=, ++ → --, boundary changes) and reruns the suite; a mutant the tests still pass against escaped, proving an assertion gap. minMsi is the floor on killed/(total non-timeout) mutants — typically 80 — enforced so the score can only ratchet upward.

Question

Why use pcov rather than xdebug for coverage on CI, and what does Infection need it for?

Answer

pcov is a dedicated coverage collector, typically several times faster than xdebug on the same suite, which matters because Infection reruns covering tests for every mutant. setup-php sets it with coverage: pcov; xdebug's coverage driver makes equivalent runs dramatically slower.

Question

Why does a MySQL service container in CI need --health-cmd, and where does --no-dev belong in the pipeline?

Answer

Without a health check the job starts tests while mysqld is still booting, causing intermittent "Connection refused" — the health-cmd gates the job on database readiness. --no-dev belongs only in the deploy/artifact build, so tests get phpunit/phpstan/infection but the shipped release does not.

Revision Notes

Key Takeaways

  • 1. CI for PHP is composer install plus vendor/bin/phpunit on a clean runner per PHP version — anything your machine does that this loop does not, ships as a surprise
  • 2. Cache Composer's cache directory keyed on composer.lock with restore-keys; --prefer-dist makes cold installs finish in seconds — caching vendor/ trades correctness for speed
  • 3. Coverage counts executions, not assertions — Infection kills mutants, minMsi gates the score, and a surviving boundary mutant is a shipped off-by-one
  • 4. Service containers need health checks; without mysqladmin ping gating, "Connection refused" flakes masquerade as application bugs
  • 5. The deploy artifact is the tested tree minus dev dependencies (--no-dev --optimize-autoloader), and green only means something while nobody reruns flakes until they pass

Interview Tips

  • • "Walk me through your CI pipeline." — matrix over PHP versions, setup-php with pinned extensions, composer cache keyed on the lock file, then tests, static analysis, mutation score — and say what each gate catches that the others cannot
  • • "How would you speed up a slow CI run?" — composer cache dir + lock-keyed actions/cache, --prefer-dist, pcov over xdebug, parallel jobs, and --filter to changed paths for mutation testing
  • • "What is mutation testing and why isn't coverage enough?" — define mutants and escaped mutants, give the >= → > off-by-one example, and name minMsi as the ratcheting gate
  • • "How do you keep CI green without lying to yourself?" — required status checks, health-checked service containers to kill flakes at the source, concurrency cancellation for superseded pushes — and the rule that rerunning until green destroys the signal

Cheat Sheet

Shipping with a CI Pipeline

Workflow shape: checkout → shivammathur/setup-php@v2 (php-version: matrix, extensions, coverage: pcov) → actions/cache on $(composer config cache-files-dir) keyed composer-${{ hashFiles('composer.lock') }} with restore-keys → composer install --prefer-dist --no-interaction --no-progress → vendor/bin/phpunit.

Rules: cache the Composer cache, never vendor/; fail-fast: false keeps the version matrix honest; concurrency group with cancel-in-progress: true.

Quality gates: phpstan analyse --level=6 and php-cs-fixer --dry-run as separate jobs; Infection (infection.json5: minMsi 80, --only-covered, --filter on PRs, nightly full run).

Services: MySQL:8.0 with --health-cmd="mysqladmin ping" so tests wait for readiness; DB_HOST=127.0.0.1.

Release: needs: [tests, static-analysis, mutations] → composer install --no-dev --prefer-dist --optimize-autoloader → upload-artifact. Secrets via ${{ secrets.* }}; deploys attach to protected environments.

Branch protection: required status checks on main — green is the release signal; fix flaky tests, never rerun them green.

Continue Learning