Composer security comes down to three disciplines: commit and enforce composer.lock so every environment installs byte-identical dependencies, run composer audit against Packagist's advisory feed on every build, and actively manage abandoned packages instead of letting them accumulate as unowned attack surface. PHP powers somewhere near three-quarters of the server-side web, WordPress included, and yet Composer hygiene lags years behind what the same organizations enforce for npm. The mechanics below are not hard. They're just unglamorous.
The lockfile is the control, composer.json is a wish
composer.json declares ranges (^2.4). composer.lock records exact versions plus content hashes (dist.shasum, and the reference commit for source installs). The security property lives entirely in the lockfile: an attacker who compromises a package and republishes within your allowed range owns every environment that installs from composer.json, and none that install from an intact lockfile.
So the CI rule is absolute: deploy with
composer install --no-dev --prefer-dist --no-scripts
composer validate --strict
and never composer update anywhere but a developer machine followed by review of the lockfile diff. composer validate --strict catches the classic drift where someone edited composer.json and forgot to update the lock. If your lockfile diffs are unreadable noise, that's a review-culture problem, not a tooling gap — a 400-line lock diff for a "bump one library" PR deserves the same suspicion as a 400-line code diff.
Note --no-scripts above. Composer packages can run code via lifecycle scripts and plugins at install time. Since Composer 2.2, plugins require explicit allowlisting in composer.json under config.allow-plugins — keep that list short and treat additions to it as security-relevant review items. Installing with scripts disabled in CI, then running your build steps explicitly, removes the "install equals code execution" property that most registry malware depends on.
composer audit and the advisory pipeline
Since Composer 2.4, auditing is built in:
composer audit --locked --format=json
--locked audits what you actually deploy rather than what a fresh resolve would produce. The data comes from the Packagist security-advisory API, which aggregates the FriendsOfPHP security-advisories database plus advisories submitted directly by maintainers. Coverage is decent for the framework world — Symfony, Laravel, Drupal ecosystems are well-fed — and thinner for the long tail.
Two supplements worth knowing. The roave/security-advisories meta-package uses version conflicts to make installing a known-vulnerable version impossible (add it as dev-latest in require-dev); it's prevention rather than detection, and it costs nothing at runtime. And a proper SCA platform adds the things the advisory feed structurally lacks: malware detection, typosquat flagging, reachability context, and policy gates — we've written a deeper dive on composer audit workflows if you want the full comparison.
Packagist is metadata, GitHub is the code — know your trust chain
A quirk that surprises people: Packagist doesn't host package code. It hosts metadata pointing at VCS repositories (almost always GitHub), and Composer downloads dists from there. Your trust chain is therefore Packagist account + GitHub repository + the transport between them. Compromise either account and you've compromised the package.
This is not hypothetical. In May 2023, an attacker used credentials from old leaks to take over four inactive Packagist maintainer accounts and repoint over a dozen packages — including forks of acmephp/acmephp with combined installs in the hundreds of millions — at attacker-controlled repositories. The payload was a job-hunting README rather than malware, which is the good ending. The mechanism was fully functional.
Defenses on your side of the chain: enable 2FA on your own Packagist and GitHub accounts if you publish anything; for what you consume, prefer packages whose maintainers show signs of life; and pin your deploys to the lockfile so a repointed package can't reach production without a visible lock diff. For internal packages, run Private Packagist or a Satis instance, and use repository canonicalization so internal package names can never be satisfied by packagist.org — that's Composer's answer to dependency confusion.
Abandoned packages: the slow-motion incident
Packagist has a formal flag for this — maintainers can mark a package abandoned, optionally naming a successor — and Composer warns on install:
Package fabpot/goutte is abandoned, you should avoid using it.
Use symfony/browser-kit instead.
Most teams see that warning for two years and do nothing. The risk profile of an abandoned package is specific: no one will ship a fix when a CVE lands, and an attacker who acquires the account or repo inherits a trusted name with zero oversight. Account takeovers of inactive maintainers were exactly the 2023 vector.
Make it operational instead of ambient guilt:
| Signal | Check | Action threshold |
|---|---|---|
| Formally abandoned | composer show <pkg> shows abandoned flag | Migrate this quarter |
| No release in 2+ years | Packagist release feed | Evaluate successor |
| Single maintainer, no activity | GitHub insights | Fork-and-own or replace |
| Advisory with no patch | composer audit | Patch locally or remove now |
For the fork-and-own path, Composer makes it clean: add your fork as a VCS repository entry, alias it to the original name with an as version alias, and you're patched while the lockfile keeps the dependency graph coherent. Safeguard flags abandoned and unmaintained Composer packages as findings alongside CVEs, which turns the migration backlog into something a security review can actually track.
A CI baseline you can adopt this week
composer validate --strict— manifest/lock coherence.composer install --no-scriptswith the lockfile, never update, in CI.composer audit --locked— fail on high/critical.config.allow-pluginsexplicitly enumerated; review additions.- Quarterly abandoned-package review using the table above.
Total implementation time is an afternoon. The abandoned-package review is the only recurring cost, and it's the one that pays out during the next account-takeover wave.
Frequently asked questions
Should composer.lock be committed for libraries as well as applications?
Commit it for applications, always — that's your deploy integrity. For libraries, the lock isn't used by consumers, but committing it still gives your own CI reproducible test runs; just make sure you also test against fresh resolves so you notice breakage within your declared ranges.
Does composer audit catch malicious packages?
Only after someone reports them and an advisory is published, which for malware often never happens — the package just gets deleted. Advisory-based auditing needs pairing with registry-monitoring or SCA tooling that detects malicious code patterns and suspicious publish events directly.
What do I do about a vulnerable package that's abandoned?
Fork it, apply the patch, and point Composer at your fork via a VCS repository entry with a version alias — this is a supported first-class workflow, not a hack. Then plan the real migration, because you've just become the maintainer.
Is running Composer as root in Docker builds a problem?
Yes, install-time scripts and plugins execute with whatever privileges Composer has. Use --no-scripts in image builds, run as a non-root user, and allowlist plugins explicitly — the combination removes most of the install-time execution surface.