DEV Community

Cover image for composer audit in 2026: Catch Vulnerable PHP Deps Before They Ship
Gabriel Anhaia
Gabriel Anhaia

Posted on

composer audit in 2026: Catch Vulnerable PHP Deps Before They Ship


You didn't pick most of your dependencies. You installed a Guzzle-based SDK, and Guzzle pulled in guzzlehttp/psr7, which pulled in ralouphie/getallheaders. Your composer.lock has 140 packages. You chose maybe 20 of them.

That's where the risk lives. When a CVE lands on a transitive package three levels down, nobody on your team gets an email. The code keeps working. The vulnerability ships to production, and you find out when a scanner flags it or an attacker does.

composer audit is the tool that closes that gap. It was added in Composer 2.4 and it has been in every release since. If your CI pipeline isn't running it, a known CVE can pass every test you have and deploy clean. Wired in properly, it reads the advisory database, audits the lock file in CI, fails the build on a finding, and lets you ignore the false positives without going blind to the real ones.

What composer audit actually checks

composer audit reads the packages in your composer.lock and compares each version against a database of published security advisories. It does not scan your code. It matches installed versions against known-vulnerable version ranges.

The advisory data comes from the Packagist advisory API, which aggregates the community FriendsOfPHP/security-advisories database and the GitHub Advisory Database. Each advisory carries an ID, a CVE where one exists, the affected package, the vulnerable version constraint, and a severity. You can browse the same data at packagist.org/security-advisories.

Run it against a project:

composer audit
Enter fullscreen mode Exit fullscreen mode

Output looks like this when something matches:

Found 1 security vulnerability advisory affecting
1 package:
+-------------------+-------------------------------------+
| Package           | guzzlehttp/psr7                     |
| CVE               | CVE-2023-29197                      |
| Title             | Improper header parsing             |
| Severity          | high                                |
| Affected versions | >=2,<2.4.5|>=1,<1.9.1               |
+-------------------+-------------------------------------+
Enter fullscreen mode Exit fullscreen mode

That's illustrative sample output, but the fields map one-to-one onto what Packagist publishes for each advisory. You can read the real record for this one at GHSA-wxmh-65f7-jcvw.

The exit code is the whole point

When composer audit finds an advisory, it exits with a non-zero status. That single fact is what makes it a CI gate rather than a report you skim once a quarter.

composer audit
echo $?   # non-zero when advisories are found, 0 when clean
Enter fullscreen mode Exit fullscreen mode

There's also an implicit audit you're already getting. Since 2.4, composer update runs an audit automatically after resolving and prints a summary. That's informational; it warns, it doesn't stop you. The build gate is the explicit composer audit call with its exit code, and that's the one you want in a pipeline.

You can shape the output for machines:

composer audit --format=json
composer audit --format=summary
Enter fullscreen mode Exit fullscreen mode

The json format is what you pipe into jq for a dashboard or a Slack notification. summary gives you a one-line count for a log.

--locked: audit what's pinned, not what's installed

In CI you often don't want a full composer install before the security check runs. Installing pulls the whole dependency tree, runs scripts, and takes time. You want to audit the exact versions your lock file pins, and nothing more.

That's what --locked does. It reads advisories against composer.lock directly, without needing a populated vendor/:

composer audit --locked --no-dev
Enter fullscreen mode Exit fullscreen mode

Two flags worth pairing:

  • --locked audits the lock file, so the check reflects what will actually deploy, not what happens to be installed on the runner.
  • --no-dev drops your dev dependencies (PHPUnit, PHPStan, Rector) from the check. A CVE in a test-only package never reaches production, so failing prod deploys on it is noise. Audit dev separately if you care.

This is the command I'd put at the top of a security job. It's fast, it needs no install step, and it describes the artifact you ship.

Failing the build on a CVE

Here's a GitHub Actions job that fails the moment a production dependency has a known advisory:

# .github/workflows/security.yml
name: Security

on:
  pull_request:
    paths:
      - 'composer.json'
      - 'composer.lock'
  schedule:
    - cron: '0 6 * * 1'   # Monday 06:00 UTC

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          tools: composer:v2

      - name: Audit locked dependencies
        run: composer audit --locked --no-dev
Enter fullscreen mode Exit fullscreen mode

Two triggers matter here. The pull_request path filter catches a new or bumped dependency the moment it enters a PR. The schedule cron catches the case that PR filters can't: an advisory published after you merged. The code didn't change, but the world did. A weekly run turns a green build red when a CVE drops on a package you shipped months ago, which is exactly when you want to hear about it.

No composer install in that job. --locked means the audit runs against the lock file straight after checkout.

Ignoring false positives responsibly

Not every advisory applies to you. An advisory might affect a package's SOAP client while you only touch its HTTP client. The vulnerable code path is real, but you never call it. Blocking every deploy on it trains your team to bypass the gate, which is worse than not having one.

Composer lets you suppress specific advisories in composer.json. Do it with a reason attached:

{
    "config": {
        "audit": {
            "ignore": {
                "CVE-2024-12345": "Affects the SOAP transport only; we use the HTTP client. Re-review after 2026-09-01."
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The reason string is the part that keeps this honest. A bare list of ignored IDs rots. Six months later nobody remembers why CVE-2024-12345 is on the ignore list, whether it still applies, or if the package has been patched since. A one-line reason plus a review date turns a silent suppression into a documented decision someone can revisit.

The rule I'd hold the team to: an ignore entry needs a reason and a date. If you can't write why it's safe to ignore, you haven't finished investigating it. Upgrade the package instead.

Severity gates and abandoned packages

Two more knobs are worth knowing.

If you want to fail on high-severity findings but merely warn on low ones, recent Composer versions support filtering by severity:

composer audit --locked --no-dev \
  --ignore-severity=low --ignore-severity=medium
Enter fullscreen mode Exit fullscreen mode

That fails the build on high and critical advisories while letting the lower ones through as reports. It's a reasonable posture for a large legacy tree where fixing every low-severity item at once isn't realistic. Tighten the threshold over time.

Composer's audit also reports abandoned packages, which is a different flavor of supply-chain risk. An abandoned package won't get a patch when the next CVE lands. You control how strict that check is:

composer audit --locked --abandoned=report
Enter fullscreen mode Exit fullscreen mode

report lists them without failing, fail treats an abandoned package as a build breaker, and ignore silences it. Start with report so you can see what's rotting, then move packages off the list before you flip to fail.

Make the gate the boring default

The whole point is that this runs without anyone thinking about it. A developer bumps a dependency in a PR, the audit job runs against the lock file, and a known CVE turns the check red before a human reviews the diff. A weekly cron catches the advisories that land after merge. Ignored entries carry a reason and an expiry, so the list stays a decision log instead of a graveyard.

None of this scans your own code. It catches the 120 packages you didn't choose, which is where the boring, exploitable, already-cataloged vulnerabilities actually hide.


Auditing dependencies is a boundary concern. It belongs at the edge of your build, in CI, on the lock file, not tangled into domain logic that shouldn't know or care which HTTP client you pinned. Keeping that separation clean is the same instinct that keeps a codebase maintainable as the framework and its dependencies churn underneath you, and it's what Decoupled PHP is about: pushing the volatile concerns to the edges so the core outlives them.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)