- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
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
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 |
+-------------------+-------------------------------------+
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
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
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
Two flags worth pairing:
-
--lockedaudits the lock file, so the check reflects what will actually deploy, not what happens to be installed on the runner. -
--no-devdrops 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
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."
}
}
}
}
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
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
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.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)