Practical guide

How to set up CI checks for a PHP project

Run the same checks on every pull request in a clean, reproducible, and secure environment.

25 minutes · CI/CD

In short

CI is an automated merge condition

CI should quickly answer whether this exact commit can be installed, analyzed, and tested. Success on one developer laptop is insufficient.

Checks live in Git, use composer.lock, and are required on the protected branch. Deployment is a separate later phase with different permissions.

Prepare

Align local and CI commands

First expose every check as a Composer or Make target that developers can also run.

  • Committed composer.json, composer.lock, and configuration for PHPUnit, PHPStan, and CodeSniffer.
  • One explicit supported PHP version and the required extension list.
  • An isolated test database or other service containers with versions close to production.
  • Protected-branch rules, least-privilege CI tokens, and an owner for failing checks.

Steps 1 to 3

Compose the pipeline from small checks

Return fast failures first and run expensive independent tests in parallel.

1. Install exactly locked dependencies

  1. Trigger the workflow on pull_request and pushes to the main branch. Cancel older runs of the same branch with a concurrency group.
  2. Check out the exact commit, configure the supported PHP and extensions, and run composer validate --strict.
  3. Install with composer install, never update. Cache Composer downloads by OS, PHP, and composer.lock hash, not an unverified vendor directory.
  4. The first job also runs PHP syntax or project validation so trivial errors stop the pipeline quickly.
composer validate --strict && composer install --no-interaction --prefer-dist
Official Composer validate documentation

2. Run style, analysis, and tests separately

  1. CodeSniffer checks the committed standard without automatic fixes; PHPStan uses the same paths, level, and baseline as local runs.
  2. Split PHPUnit into a fast unit suite and an integration suite with a database service. Run real migrations on an empty schema before tests.
  3. Independent jobs run in parallel with individual timeouts. Choose fail-fast deliberately so useful diagnostics do not vanish after one failure.
  4. Maintain one aggregate required check or stable job names; a workflow rename must not accidentally bypass merge protection.
vendor/bin/phpcs && vendor/bin/phpstan analyse && vendor/bin/phpunit
Official GitHub Actions workflow syntax

3. Publish diagnostics and restrict permissions

  1. Store JUnit reports, coverage, and relevant logs as artifacts even on failure, but exclude passwords and personal data.
  2. Default permissions to contents: read. Do not expose secrets to untrusted fork code or interpolate its values directly into shell scripts.
  3. Pin third-party actions to a reviewed version or commit SHA and update them regularly. Treat cache as untrusted input.
  4. Measure queue and runtime. Optimize or split a slow suite while retaining the same required quality for every change.
permissions: { contents: read }
Official GitHub Actions security reference

Step 4

Verify the pipeline with intentional failures

Every required gate must truly block a pull request and show its cause.

  1. Break formatting, a type, and a test

    The matching jobs fail independently with readable logs. After reverting, the entire commit is green.

    composer ci
  2. Start with a clean database

    Migrations and integration tests need no hand-made local tables or data from a previous run.

  3. Check merge rules

    A pull request cannot merge without every required check, and a fork workflow has no production secrets or write token.

Troubleshooting

Common problems

CI and local runs report different errors

Align PHP, extensions, locale, time zone, composer.lock, and commands in one Composer or Make target.

php -v && composer check-platform-reqs
The pipeline is inconsistent after dependency changes

The cache key must include the composer.lock hash. Restoring cache never replaces composer install and validation.

Integration tests are flaky

Give every job an isolated database, deterministic fixtures, a service health check, and no order dependency.

A fork pull request can see a secret token

Restrict permissions, withhold secrets from untrusted triggers, and never run foreign code in privileged pull_request_target.

Done

Every commit passes the same quality gate.

Composer, CodeSniffer, PHPStan, and PHPUnit run reproducibly, results are required for merge, and the workflow uses minimum permissions.

Request a call

I will call you on the next working day between 9:00 and 17:00.

You can also call me directly.

+420 605 181 728

Leave your phone number and send a callback request.

By sending, you agree to processing your data in order to handle your request.