Dependency and Supply-Chain Security — Composer Audit, Lock Files, and Update Strategy
Understand how to find known vulnerabilities in your Composer dependencies with composer audit, why committing composer.lock is essential for reproducible and auditable builds, how to recognize vulnerable or abandoned packages, and how to balance the risk of staying on stale dependencies against the risk of updating too aggressively.
A PHP application is rarely just the code its own team wrote — a typical project pulls in dozens or hundreds of transitive Composer dependencies, and every one of them is code you're trusting to run in your application with the same privileges your own code has. Supply-chain security is about managing that trust: knowing which of your dependencies have known vulnerabilities, keeping builds reproducible so 'it works on my machine' doesn't hide a different (and possibly vulnerable) dependency tree in production, recognizing when a package has stopped being maintained, and having a deliberate strategy for updating rather than either freezing dependencies forever or blindly auto-updating everything.
composer audit: finding known vulnerabilities in your dependency tree
Composer has a built-in audit command — composer audit — that checks every package in your composer.lock against a database of known security advisories (sourced from the FriendsOfPHP/security-advisories database and Packagist) and reports any installed version that matches a known vulnerability, along with its CVE identifier, severity, and the version range that fixes it. It requires no extra installation and no third-party account; it's part of Composer itself from version 2.4 onward, which makes it the first thing worth running on any existing project and something worth wiring into CI so a newly disclosed vulnerability in an existing dependency gets caught on the next build, not months later.
composer audit checks what's actually locked and installed, not just what's listed as a version constraint in composer.json — which is exactly why it depends on composer.lock existing and being accurate. Running it regularly (and in CI on every build, not just occasionally by hand) closes the gap between 'a vulnerability was publicly disclosed' and 'someone on the team actually notices,' which for an unaudited project can otherwise be months or never.
• composer audit — built into Composer 2.4+, no extra setup, checks installed versions against known security advisories. • Reports CVE/advisory ID, affected package, severity, and the version that fixes it. • Should run in CI on every build/PR, not just occasionally by a developer running it manually. • Checks what's actually locked (composer.lock), which is why an accurate, committed lock file matters for audit results to mean anything.
composer.lock: why it must be committed
composer.json declares version constraints (e.g. "guzzlehttp/guzzle": "^7.0", meaning any 7.x version) — it does not pin an exact version. composer.lock, generated by running composer install or composer update, records the exact version of every package (direct and transitive) that was actually resolved and installed at that moment. Without a committed lock file, two different installs of the same composer.json — your laptop today, a teammate's laptop next week, the CI server, the production deploy — can each resolve a different set of exact versions within the same constraints, including versions that didn't even exist when the constraint was first written, since new releases matching "^7.0" keep being published over time.
This matters for security in two direct ways. First, reproducibility: an audit or security review of 'what code is actually running in production' is only meaningful if you can point at an exact, committed set of versions — composer.lock is that record. Second, it closes a genuine attack surface: without a lock file, a newly published malicious or compromised package version that satisfies an existing constraint could get pulled into a build automatically the next time someone runs composer install, with no one having reviewed or approved that specific version. The rule is simple and has no real exception for an application (as opposed to a reusable library, where lock files are conventionally not committed): commit composer.lock, and run composer install (which respects the lock file exactly) in CI and production — never composer update, which would re-resolve versions and defeat the entire point.
• composer.json = version ranges/constraints. composer.lock = the exact resolved versions actually installed. • Commit composer.lock for every application — it's what makes builds reproducible and auditable. • CI and production deploys should run composer install (respects the lock exactly), not composer update (re-resolves versions). • A missing lock file means a build can silently pull in a newer — possibly compromised or vulnerable — package version with no review.
Recognizing vulnerable and abandoned packages
A vulnerable package is one with a known, disclosed security flaw in a version your project has installed — composer audit is the direct tool for finding these. An abandoned package is a different but related risk: one whose maintainer has stopped releasing updates, meaning future vulnerabilities (even ones already found by researchers) may never be patched at all. Composer actually flags this directly: if a package's own composer.json declares an 'abandoned' field (pointing at a suggested replacement, or just true), running composer show or composer outdated will display a clear warning, and composer install/update print a notice for any abandoned package in the tree.
Beyond Composer's own signal, practical signs a package may be effectively abandoned include: no commits or releases in a year or more on its GitHub repository, open security-related issues with no maintainer response, and a shrinking or already-small download count relative to actively maintained alternatives for the same purpose. The response isn't necessarily to panic and rip it out immediately — a small, stable, feature-complete utility package with no known vulnerabilities can be safely abandoned in the sense that it simply doesn't need further changes. The response is to evaluate it deliberately: does it handle untrusted input or security-sensitive logic (higher risk if abandoned), and is there an actively maintained fork or alternative worth migrating to before a vulnerability is ever found in it.
• composer show / composer outdated flag packages with an 'abandoned' field in their own composer.json. • Signs of practical abandonment: no commits/releases in a year+, unanswered security issues, declining adoption. • Higher risk for abandoned packages that touch untrusted input or security-sensitive logic (auth, parsing, crypto) than for a stable, narrow utility. • Plan a migration to an actively maintained alternative before a vulnerability forces an urgent one.
Update strategy: the risk of staying stale vs. updating recklessly
Both extremes are genuinely dangerous. Never updating dependencies means accumulating known, disclosed vulnerabilities over time with no plan to patch them — composer audit will show an ever-growing list that nobody acts on, and the gap between 'patch available' and 'patch applied' is exactly the window most real-world exploits target, because attackers specifically scan for applications still running versions with a public CVE. Updating everything automatically and immediately, with no review, carries a different risk: a new release can introduce a breaking change, a regression, or — in rarer but real supply-chain-attack cases — be a compromised release from a hijacked maintainer account, and blindly pulling it into production the moment it's published means adopting that risk with zero review window.
A sound middle ground looks like: running composer audit and composer outdated on a regular cadence (weekly or as part of CI), applying patch and minor updates for actively-used packages fairly promptly since these are lower-risk and often contain exactly the security fixes you want, treating major version updates as their own reviewed piece of work (read the changelog, run the full test suite, don't bundle it silently into an unrelated change), and prioritizing anything composer audit flags as high/critical severity for immediate attention regardless of the regular cadence. Tools like Dependabot or Renovate automate the 'notice there's an update' and 'open a PR for it' steps, but the review-and-merge decision — especially for major versions — should stay a deliberate human one, not fully automatic.
• Staying stale accumulates known, disclosed, exploitable vulnerabilities — attackers actively scan for outdated versions with public CVEs. • Updating blindly and immediately risks breaking changes, regressions, and — rarely — adopting a compromised release with no review window. • Middle ground: regular audit/outdated checks, prompt patch/minor updates, deliberately reviewed major-version updates, immediate action on high/critical audit findings. • Dependabot/Renovate automate detecting and proposing updates; a human still reviews and merges, especially for major versions.
The core Composer security commands
composer install never changes composer.lock; composer update does — that distinction is exactly why CI/production must use install. composer audit's exit code is non-zero when it finds vulnerabilities, which is what makes composer audit in a CI step actually block a merge or deploy. composer outdated --direct limits output to packages your project directly requires, filtering out noisy transitive-dependency updates.
# Check every locked dependency against known security advisories
composer audit
# Same, but fail with a non-zero exit code on any finding (good for CI gating)
composer audit --locked
# See which installed packages have newer versions available, and any abandoned flags
composer outdated --direct
# Always installs from composer.lock exactly — use this in CI/production, never `composer update`
composer install --no-dev --optimize-autoloader
# Re-resolves and updates the lock file — a deliberate, reviewed action, run locally/in a PR
composer update vendor/package-nameCode Examples
Because composer install (not update) is used, this step audits exactly the versions recorded in the committed composer.lock — the same versions that will actually run in production — and composer audit's non-zero exit code on any finding makes this step fail the build automatically, so a newly disclosed vulnerability in an existing dependency gets caught on the very next push rather than discovered by chance. Running this on every push and pull request, not just on a schedule, means the moment a vulnerability affecting an already-locked dependency is disclosed, the next commit to the repo will surface it in CI even if nobody manually re-ran the audit.
run: composer audit --locked Composer surfaces the 'abandoned' signal directly from a package's own metadata rather than requiring manual research on every dependency — composer outdated is worth running on a regular cadence specifically to catch a previously-healthy package that has since been marked abandoned by its maintainer or the community.
Common mistake: Adding a new dependency purely because it looks popular on a search result, without checking composer show ... --all for an abandoned flag or an unmaintained recent-activity history first.
# .github/workflows/security.yml
name: Dependency Security Audit
on: [push, pull_request]
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
- name: Install dependencies from the committed lock file
run: composer install --no-progress --prefer-dist
- name: Audit dependencies for known vulnerabilities
run: composer audit --locked# Before adding a new dependency, check its health
composer show vendor/some-package --all | grep -i abandoned
# After it's part of the project, periodic check across everything installed
composer outdated --direct
# Sample output line for an abandoned package:
# vendor/old-html-parser 2.1.0 2.1.0 Package is abandoned, you should avoid using it.
# Use vendor/new-html-parser instead.composer.json vs composer.lock vs what actually runs
The lock file is what turns 'somewhere in this range' into a reproducible, auditable exact version.
composer.json composer.lock production server
---------------------- ------------------------------ -------------------------
"guzzlehttp/guzzle": "^7.0" guzzlehttp/guzzle: 7.8.1 exact composer install --no-dev
(a RANGE of versions) (the ONE version resolved, -> installs EXACTLY 7.8.1,
recorded and committed) matching every environmentImportant Rules
Always commit composer.lock for an application, and run composer install (not update) in CI and production.
Run composer audit regularly, and wire it into CI so a build fails on newly disclosed vulnerabilities in existing dependencies.
Check composer outdated / composer show --all for an 'abandoned' flag before adopting a new package, and periodically for existing ones.
Treat major version updates as reviewed work (changelog, full test suite) rather than bundling them silently into unrelated changes.
Act immediately on high/critical severity findings from composer audit, regardless of your normal update cadence.
How this looks in an actual project
A mature PHP project runs composer audit as a required CI check on every pull request, so a PR that pulls in a new transitive dependency with a known vulnerability fails the build before merge, and a vulnerability disclosed after merge in an already-installed package shows up on the next push to any branch. Dependabot or Renovate opens automated PRs for outdated packages weekly; patch and minor bumps for well-tested, low-risk dependencies get auto-merged after CI passes, while major version bumps and anything touching authentication or cryptography libraries get a human review. When composer outdated flags a previously-healthy JSON-parsing library as newly abandoned, the team schedules a migration to its actively maintained fork rather than waiting for a vulnerability to force an urgent one.
Ignoring composer.lock means every install — a teammate's machine, CI, production — can resolve a different exact set of dependency versions within the same constraints, defeating reproducibility and letting an unreviewed new package version reach production automatically the moment it's published on Packagist.
Running composer update in a deploy script re-resolves dependency versions against current version constraints, potentially installing a package version that was never tested, reviewed, or even released yet when the deploy was prepared — composer install respects the exact, already-reviewed versions in composer.lock instead.
What does composer audit check, and where does it get the version information to check? A. It checks the PHP interpreter version itself against known CVEs. B. It checks the exact versions recorded in composer.lock against a database of known security advisories. C. It checks composer.json's version constraints for syntax errors. D. It scans your own application source code for SQL injection vulnerabilities.
B. It checks the exact versions recorded in composer.lock against a database of known security advisories. — composer audit compares the exact, locked dependency versions in composer.lock against known security advisories (from the FriendsOfPHP/security-advisories database), which is why an accurate, committed lock file is required for the audit to reflect what's actually installed.
Why should composer.lock be committed to version control for an application? A. It's required for composer.json to be valid. B. It makes composer.json unnecessary. C. It records the exact resolved version of every dependency, making builds reproducible and auditable across every environment. D. It speeds up PHP's own script execution at runtime.
C. It records the exact resolved version of every dependency, making builds reproducible and auditable across every environment. — composer.json only declares version ranges; composer.lock pins the exact versions actually resolved. Without committing it, different installs (laptop, CI, production) can silently resolve different, unreviewed versions within the same constraints.
What command should CI and production deployments run to install dependencies, and why? A. composer update, because it always installs the latest available versions. B. composer install, because it installs exactly the versions recorded in composer.lock without re-resolving them. C. composer require, because it explicitly names each package. D. It doesn't matter — both commands behave identically.
B. composer install, because it installs exactly the versions recorded in composer.lock without re-resolving them. — composer install respects composer.lock exactly and never modifies it, guaranteeing the same, already-reviewed versions are installed everywhere; composer update re-resolves versions against current constraints, which can silently introduce a newer, unreviewed (or even malicious) package version.
Which of these is the soundest dependency update strategy? A. Never update dependencies once the application ships, to avoid any risk of a breaking change. B. Automatically merge every dependency update, including major versions, with no review, as soon as CI passes. C. Regularly check composer audit/outdated, apply patch and minor updates promptly, review major version updates deliberately, and act immediately on high/critical audit findings. D. Only update a dependency after it has been publicly exploited in your own application.
C. Regularly check composer audit/outdated, apply patch and minor updates promptly, review major version updates deliberately, and act immediately on high/critical audit findings. — Staying fully stale accumulates known, exploitable vulnerabilities, while blind auto-updating (especially of major versions) risks breaking changes and unreviewed — occasionally compromised — releases; a regular, risk-prioritized cadence balances both dangers.
Explain, in your own words, why a CI pipeline step running composer update && composer audit provides a weaker security guarantee than one running composer install && composer audit, even though both commands report vulnerabilities in whatever is currently installed. Then list two concrete signs (besides a composer audit finding) that would make you consider a Composer package to be effectively abandoned.
composer update re-resolves dependency versions against the current constraints before auditing, so it can audit (and pass) a different, newer set of versions than what's actually recorded in the committed composer.lock and what production runs from composer install — a clean audit result in that CI step doesn't guarantee production is equally clean. composer install && composer audit audits the exact versions that will actually be deployed. Signs of abandonment: composer's own 'abandoned' flag in the package's metadata, and no commits/releases for over a year with open, unanswered security-related issues on its repository.
Think about which exact package versions each command leaves installed at the moment composer audit runs, and whether that matches what production is actually running.