Continuous Integration for PHP: Lint, Static Analysis, Tests, and Security Audits

Design a complete CI pipeline for a PHP project that installs dependencies correctly, lints and statically analyzes the code, runs the automated test suite, audits dependencies for known vulnerabilities, and produces a deployable build artifact — implemented concretely as a GitHub Actions workflow.

Continuous Integration (CI) is the practice of automatically building, checking, and testing every change to a codebase the moment it's pushed, rather than relying on developers to remember to run checks locally before pushing. For a PHP project, a CI pipeline is the automated gatekeeper that stands between "a developer thinks this change is correct" and "this change is allowed to merge or be deployed" — it installs the exact dependency versions the project expects, checks the code parses and follows agreed style rules, runs a static analyzer that catches an entire class of bugs before the code ever executes, runs the actual automated test suite, checks whether any dependency has a known security vulnerability, and finally produces the actual artifact (a zip, a Docker image) that a separate continuous delivery (CD) pipeline will deploy. This lesson walks through each of those steps in the order a real pipeline runs them, then ties them together into a complete, realistic GitHub Actions workflow file.

What CI Actually Automates, and Why Order Matters

A CI pipeline is triggered automatically by an event — almost always a push to any branch and, separately, the opening or updating of a pull request — and runs a defined sequence of jobs/steps in a clean, ephemeral environment (a fresh container or VM, never a developer's own machine), specifically so results are reproducible and don't depend on whatever happens to be installed on one person's laptop. The output of a CI run is a pass/fail status attached directly to the commit and the pull request, which is what branch protection rules check before allowing a merge.

The steps within a PHP CI pipeline are deliberately ordered from cheapest/fastest to most expensive, so that a change with an obvious problem fails and reports back within seconds rather than minutes: installing dependencies must happen first since every later step needs them; linting (a pure syntax/style check) is nearly instant and catches trivial mistakes before spending time on deeper analysis; static analysis is more expensive than linting but still faster than running a full test suite; the test suite itself is usually the slowest step; and a dependency security audit can run in parallel with the others since it doesn't depend on the application code at all, only on the lockfile.

This ordering isn't just an optimization — it directly shapes developer experience. A pipeline that runs the full (slow) test suite before a trivial linting failure would report wastes CI minutes and developer time waiting for feedback that a much cheaper check could have delivered instantly; a well-designed pipeline gives the fastest possible useful signal at every stage.

• CI runs in a clean, ephemeral environment on every push/PR — never depends on a developer's own machine state. • Pass/fail status attaches to the commit/PR and gates merging via branch protection rules. • Order steps cheapest-and-fastest first: install → lint → static analysis → tests → (audit can run in parallel). • Fast failure on cheap checks (lint) avoids wasting CI time running expensive checks (full test suite) on code that won't even parse cleanly.

Dependency Installation: --no-dev vs Full Dev Dependencies

Every CI job that needs to run PHP code first needs composer install, and the exact flags matter. composer install (no extra flags) installs both the require and require-dev sections of composer.json — this is what CI needs for jobs that run linting, static analysis, and the test suite, since tools like PHPUnit, PHPStan, and php-cs-fixer are themselves dev dependencies. composer install --no-dev installs only the require section — the packages the application actually needs to run in production — and is what the build/packaging step (and production deployments generally) should use, since dev tooling has no business being shipped to a live server, both for security surface reasons (fewer packages that could have vulnerabilities) and simple package size.

Two additional flags matter for CI reliability: --prefer-dist (downloads packaged zips rather than cloning full git histories, faster and what CI should always use) and, critically, running composer install rather than composer update in CI — composer install respects the exact versions pinned in composer.lock, guaranteeing the CI run tests precisely the same dependency versions that will actually be deployed; composer update would resolve to whatever the newest versions currently satisfying composer.json's constraints are, which could silently differ from what a developer tested locally and introduce nondeterminism into CI.

Caching Composer's download cache (or, in GitHub Actions, using actions/cache keyed on the hash of composer.lock) between CI runs meaningfully speeds up the install step, since otherwise every single CI run re-downloads every package from scratch.

• composer install (with dev deps) — needed for lint/static-analysis/test jobs that use dev-only tooling like PHPUnit/PHPStan. • composer install --no-dev — used for the production build/artifact step; dev tooling should never ship to production. • Always composer install, never composer update, in CI — install respects composer.lock exactly, guaranteeing reproducible dependency versions. • Cache Composer downloads keyed on composer.lock's hash to speed up repeated CI runs.

Lint and Static Analysis: Catching Problems Before Runtime

Linting for PHP has two distinct layers. php -l <file> (the -l flag stands for "lint") performs a pure syntax check — does this file parse as valid PHP at all — and is essentially instantaneous; running it across every changed file (or the whole codebase via a loop or parallel-lint, a tool that runs php -l across many files concurrently) catches a typo or a missing brace before spending any further CI time on the change. Separately, a code-style linter like php-cs-fixer (or PHP_CodeSniffer/phpcs) checks that the code follows the project's agreed formatting rules (PSR-12 or a custom ruleset) — in CI this is run in "dry-run"/check mode (php-cs-fixer fix --dry-run --diff), which reports style violations and exits non-zero without actually rewriting files, since CI should verify, not silently modify, a contributor's code.

Static analysis goes considerably deeper than style: tools like PHPStan and Psalm analyze the code's types and logic without executing it, catching an entire category of bugs that would otherwise only surface at runtime — calling a method that doesn't exist on a given type, passing an argument of the wrong type, referencing an array key that a prior check has already proven cannot exist, or dereferencing a value that could be null. Both tools support configurable "levels" (PHPStan's levels run 0 through 9, strictest at the top) so a codebase can adopt static analysis incrementally, starting at a lenient level and ratcheting up over time as more of the codebase becomes properly typed.

Static analysis as a CI gate — meaning the build fails if the analyzer reports any error at the project's configured level — is one of the highest-leverage additions a PHP team can make to their pipeline, because it catches a meaningful fraction of bugs that unit tests alone tend to miss (a unit test only reports a problem in a code path it happens to exercise; a static analyzer inspects every code path structurally, whether or not a test happens to reach it).

• php -l — instant pure syntax check per file; run across all changed/all files before anything else. • php-cs-fixer/phpcs in dry-run/check mode — verifies style compliance (e.g. PSR-12) without modifying files in CI. • PHPStan/Psalm — deep type/logic static analysis, catching bugs (wrong types, undefined methods, possible nulls) without running the code. • Configurable analysis levels let a codebase adopt strictness incrementally; CI should fail the build on any reported error at the configured level.

Running the Test Suite in CI

The test suite step runs PHPUnit (vendor/bin/phpunit, or a project's test runner of choice) against the full dependency set installed earlier, and is usually the single longest-running step in the pipeline. A well-configured CI test run should also generate a code coverage report (via Xdebug or the faster PCOV extension) so coverage trends are visible over time and can optionally be enforced as a gate (failing the build if coverage drops below a threshold), and should run against a real, ephemeral instance of whatever database the application uses in production (CI providers make this easy via "service containers" — GitHub Actions can spin up a temporary MySQL or PostgreSQL container alongside the job, torn down automatically when the job finishes) rather than mocking the database entirely, since integration-level tests that hit a real database catch a category of bugs (query syntax errors, schema mismatches) that pure unit tests with mocks cannot.

Matrix testing — running the same test suite across multiple PHP versions (e.g. 8.1, 8.2, 8.3) and sometimes multiple dependency-version combinations — is especially important for a library published to Packagist that needs to support a range of PHP versions its consumers might be running, since a change that works fine on the latest PHP version can silently break on an older one still within the library's supported range. An application (rather than a published library) usually only needs to test against the single PHP version it actually deploys with, though testing against the next upcoming version too can give early warning of upgrade-blocking issues.

A CI test run should fail the entire pipeline on any test failure, with no exceptions — a common anti-pattern is disabling or skipping a flaky test rather than fixing or removing it, which erodes the entire signal the test suite is meant to provide.

• vendor/bin/phpunit runs the actual automated test suite — usually the pipeline's longest step. • Generate coverage (Xdebug or PCOV) and optionally gate the build on a minimum coverage threshold. • Use CI service containers (e.g. a temporary MySQL/PostgreSQL) for integration tests that need a real database, torn down automatically after the job. • Matrix test across multiple PHP versions for published libraries; a single deployed version is usually enough for an application.

Security Audit: composer audit and Roave Security Advisories

composer audit (built into Composer 2.4+) checks every package version actually locked in composer.lock against a database of known security advisories (sourced from the FriendsOfPHP security advisories database) and reports any dependency with a known vulnerability, along with its severity and the advisory link — this should run as a dedicated CI step, and should fail the build (or at minimum, post a highly visible warning) on any high or critical severity finding, since shipping a dependency with a known, publicly documented vulnerability is a preventable, entirely avoidable risk.

roave/security-advisories is a complementary, proactive approach: it's a special Composer package with no actual code, whose entire purpose is to declare conflict entries in its own composer.json for every package version known to have a security advisory. Requiring it as a dev dependency means composer install/composer update will refuse to resolve a dependency tree that includes any known-vulnerable version *before* the code is even installed, rather than merely reporting the problem after the fact the way composer audit does — the two approaches are complementary, not redundant: Roave prevents an already-known-bad version from ever being installed at all, while composer audit catches a newly-published advisory affecting a version that was already installed and passed Roave's check at install time.

A security audit step is normally placed early in the pipeline (since it doesn't depend on the application code, only the lockfile, it can run immediately after dependency installation, in parallel with linting) and its findings should be treated with the same seriousness as a failing test — a known-vulnerable dependency in production is a real, exploitable risk, not a cosmetic warning to address 'eventually'.

• composer audit — checks composer.lock's exact versions against the FriendsOfPHP security advisories database, reporting known vulnerabilities. • roave/security-advisories (dev dependency) — proactively blocks composer install/update from ever resolving a known-vulnerable version in the first place. • The two are complementary: Roave prevents installing known-bad versions; composer audit catches advisories published after install. • Treat a high/critical security audit finding as a build-failing issue, not a cosmetic warning.

Building Deployable Artifacts for CD

Once lint, static analysis, tests, and the security audit all pass, the final CI step builds the actual artifact that a separate continuous delivery (CD) pipeline will deploy — CI's job is to prove the code is good; CD's job is to actually ship it, and keeping them as distinct concerns (even if they live in the same overall workflow file) keeps each one focused and independently testable. Two common artifact shapes dominate PHP deployments: a deployable zip/tarball containing the application code plus a vendor/ directory built with composer install --no-dev --optimize-autoloader (the optimize-autoloader flag generates a more efficient class autoloader map, worth the extra build time for a production artifact even though it's unnecessary for local development), suitable for platforms that deploy by unpacking an archive onto a server; and a Docker image, built via a Dockerfile and pushed to a container registry (Docker Hub, GitHub Container Registry, AWS ECR), suitable for container-orchestrated deployments (Kubernetes, ECS) and now the dominant approach for new PHP projects.

Whichever artifact shape is used, it should be tagged or named in a way that ties it unambiguously back to the exact commit (and, for a release, the exact Git tag) it was built from — a Docker image tagged with the short git SHA (myapp:a1b2c3d) in addition to a human-friendly tag (myapp:v1.4.0, myapp:latest) lets anyone trace a running production container back to precisely the source code that produced it, which is invaluable during an incident.

A subtlety worth internalizing: the artifact built by CI should be the exact same artifact that gets promoted through staging and then production — rebuilding from source separately for each environment reintroduces the very risk ("the tested code isn't exactly the deployed code") that this whole pipeline exists to eliminate. Build once, deploy the identical artifact everywhere.

• The final CI step builds the actual deployable artifact — a zip/tarball or a Docker image — only after every quality gate has passed. • Production artifacts use composer install --no-dev --optimize-autoloader — no dev tooling, and a faster class autoloader map. • Tag artifacts (Docker images especially) with both the git SHA and a human release tag, so a running instance traces back to exact source. • Build the artifact once in CI and promote that identical artifact through every environment — never rebuild separately per environment.

Core Commands a PHP CI Pipeline Runs, in Order

Each command's non-zero exit code should fail the CI job — CI tools check exit codes, not output text. The final --no-dev install is a separate, later step used only for building the deployable artifact, not for running tests.

composer install --prefer-dist --no-progress
find src tests -name "*.php" -print0 | xargs -0 -n1 php -l
vendor/bin/php-cs-fixer fix --dry-run --diff
vendor/bin/phpstan analyse src --level=8
vendor/bin/phpunit --coverage-text
composer audit
composer install --no-dev --optimize-autoloader   # for the build step only

Code Examples

The 'quality' job matrix-tests against two PHP versions with a real MySQL service container, running syntax lint, style check, static analysis, the test suite with coverage, and a security audit in sequence; the separate 'build' job only runs after 'quality' succeeds (via needs: quality) and only on main, reinstalling with --no-dev to produce a clean deployable artifact tagged with the exact commit SHA.

needs: quality Because roave/security-advisories declares conflict rules for every known-vulnerable package version, simply requiring it as a dev dependency turns 'installing a known-vulnerable version' into an immediate, hard install failure rather than a silent risk discovered later. This complements, but does not replace, composer audit — audit still catches an advisory published after a package was already installed and passed this check at the time.

name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  quality:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        php-version: ['8.2', '8.3']
    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:
      - uses: actions/checkout@v4

      - name: Set up PHP ${{ matrix.php-version }}
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php-version }}
          coverage: pcov

      - name: Cache Composer dependencies
        uses: actions/cache@v4
        with:
          path: vendor
          key: composer-${{ hashFiles('composer.lock') }}

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

      - name: Lint (syntax check)
        run: find src tests -name "*.php" -print0 | xargs -0 -n1 php -l

      - name: Check code style
        run: vendor/bin/php-cs-fixer fix --dry-run --diff

      - name: Static 
…
composer require --dev roave/security-advisories:dev-latest

# From this point on, composer install/update will refuse to resolve
# a dependency tree containing any version with a known security advisory,
# failing the install itself rather than merely warning after the fact.
composer update
# Example failure: "Your requirements could not be resolved to an installable
# set of packages... roave/security-advisories conflicts with guzzlehttp/guzzle[6.3.0]"

A PHP CI Pipeline, Fastest Checks First

Cheap, fast checks run first so a broken change fails quickly; the deployable artifact is only built after every quality gate passes.

push/PR
    │
    ▼
 composer install (with dev deps)
    │
    ├──▶ php -l (syntax lint)         ─┐
    ├──▶ php-cs-fixer --dry-run        │  fast checks,
    ├──▶ phpstan analyse               │  fail early & cheap
    ├──▶ composer audit               ─┘
    │
    ▼
 phpunit (test suite, slowest step)
    │
    ▼  (only if ALL of the above passed, and only on main)
 composer install --no-dev --optimize-autoloader
    │
    ▼
 build artifact (zip / Docker image) → handed to CD pipeline

Important Rules

Always run composer install, never composer update, in CI — reproducibility depends on respecting composer.lock exactly.

Order pipeline steps cheapest-and-fastest first: syntax lint → style check → static analysis → tests → audit.

Fail the entire build on any lint, static-analysis, test, or high/critical security-audit failure — no silent skips.

Only build the production artifact with --no-dev --optimize-autoloader, and only after every quality gate has passed.

Build the deployable artifact exactly once and promote that same artifact through every environment, never rebuilding per environment.

Build Once, Deploy Everywhere

Teams practicing mature continuous delivery build exactly one Docker image per commit in CI, tag it with the commit SHA, push it to a registry, and then have their CD pipeline deploy that identical, already-tested image to staging and, after approval, to production — never rebuilding from source for each environment — so what was tested is provably, byte-for-byte, what is actually running in production.

composer update # run inside CI to "make sure dependencies are current" composer install --prefer-dist --no-progress # respects composer.lock exactly composer update re-resolves dependencies to the newest versions satisfying composer.json's constraints, which can differ from what was tested locally and makes CI results nondeterministic; composer install always reproduces the exact versions pinned in composer.lock.

// CI job runs phpunit first, then linting, to "test the important stuff first"

// CI job runs php -l and php-cs-fixer first (seconds), then phpstan, then phpunit (often the slowest step) last

Running the slowest check first means a trivial syntax typo isn't caught until after the full test suite has already run, wasting CI minutes and delaying feedback that a near-instant lint check could have given immediately.

Why should composer install, not composer update, be used in a CI pipeline? A. composer update is slower to run than composer install B. composer install respects the exact versions pinned in composer.lock, guaranteeing reproducible dependency resolution across runs C. composer update is deprecated in Composer 2 D. composer install automatically fixes any failing tests

B. composer install respects the exact versions pinned in composer.lock, guaranteeing reproducible dependency resolution across runs — composer install installs precisely what composer.lock specifies; composer update would re-resolve to the newest versions satisfying composer.json's constraints, which could differ from what was tested locally and make CI nondeterministic.

Why does a well-designed PHP CI pipeline typically run php -l and a style check before PHPStan and PHPUnit? A. Because PHPUnit cannot run unless php -l passes first, as a hard technical requirement B. To give the fastest possible feedback by failing cheap, near-instant checks before spending time on slower static analysis and test runs C. Because GitHub Actions requires steps to be ordered alphabetically D. Style checks are legally required to run first under PSR-12

B. To give the fastest possible feedback by failing cheap, near-instant checks before spending time on slower static analysis and test runs — Ordering checks from cheapest/fastest to most expensive means an obviously broken change (a syntax error or style violation) fails within seconds rather than after waiting for a much slower static analysis or test suite run.

What is the key functional difference between composer audit and roave/security-advisories? A. They are functionally identical and redundant to use together B. composer audit reports known vulnerabilities in already-installed lockfile versions after the fact; roave/security-advisories proactively blocks installing a known-vulnerable version in the first place C. roave/security-advisories only works with PHPUnit, not Composer D. composer audit modifies composer.lock automatically to fix vulnerabilities

B. composer audit reports known vulnerabilities in already-installed lockfile versions after the fact; roave/security-advisories proactively blocks installing a known-vulnerable version in the first place — composer audit checks composer.lock's already-resolved versions against the advisories database on demand; roave/security-advisories declares conflict rules that make composer install/update refuse to resolve a known-vulnerable version at all, before it's ever installed.

Why should --no-dev be used when building the final production/deployable artifact in CI, but NOT when running the test suite? A. --no-dev makes PHPUnit run faster B. Dev dependencies like PHPUnit and PHPStan are required to run tests and analysis, but should never ship in a production artifact for security surface and size reasons C. --no-dev is required for Docker builds only D. There is no real difference; --no-dev can always be used safely

B. Dev dependencies like PHPUnit and PHPStan are required to run tests and analysis, but should never ship in a production artifact for security surface and size reasons — The test/analysis steps need dev-only tools like PHPUnit and PHPStan installed to run at all, while the production artifact should exclude all dev tooling entirely — it has no legitimate reason to be on a production server and only adds unnecessary security surface and size.

A teammate proposes removing the composer audit step from the CI pipeline because 'it slows down every build for something that rarely finds anything.' Write a short justification for why the step should stay, referencing what it checks and how it complements roave/security-advisories.

composer audit should stay because it checks the exact versions already locked in composer.lock against the current security advisories database on every run — catching advisories disclosed AFTER a package was installed, which roave/security-advisories (a one-time gate at install/update time) cannot catch retroactively. The two tools are complementary: Roave blocks installing a version already known to be vulnerable; composer audit continuously re-checks already-installed versions against newly published advisories. Removing it leaves the pipeline blind to any vulnerability disclosed after the dependency was first added, which is exactly the scenario that matters most for a long-lived, rarely-updated dependency.

Consider the difference between a vulnerability that existed when a package was first installed versus one disclosed later, after installation.