Key Takeaways
- Packagist indexes Git tags instead of hosting artifacts, so until July 2026 a published version could be quietly repointed at a different commit.
- Two 2026 incidents ran that same trick, one registering a rogue Composer plugin and the other hijacking PHP’s autoloader.
- A committed
composer.lockpins the commit that was already resolved, so a later tag rewrite cannot change whatcomposer installpulls. - Packagist now rejects retags of published stable versions, but old lockfiles and dev branches still need a look.
The other major registries all made the same call early. Once a version ships, its contents are frozen. npm tarballs are immutable. PyPI won’t let you re-upload a filename. crates.io allows yanking, not deleting. Maven Central won’t delete a published artifact at all.
Packagist made a different call. It doesn’t store packages. It reads a Git repository, indexes whatever tags it finds, and tells Composer where to fetch from. A version like 2.4.1 is a Git tag plus the commit reference Packagist recorded for it, and until July 2026 that tag could be deleted and re-pushed against a different commit, with Packagist quietly following along.
That gap is what the 2026 Composer supply chain attacks exploited, and PHP got two concrete examples three weeks apart.
Why Packagist Is an Index Rather Than a Registry
The architectural difference produces the security difference. When Composer resolves a dependency, Packagist supplies metadata: the package name, its versions, and where each version lives, including source and dist references that are normally a commit hash. The actual code comes from the source repository or from a distribution archive the code host generates on demand from a specific ref. Nothing in that chain is an immutable artifact that Packagist itself stores.
| Registry | Version identity | Can a released version change |
|---|---|---|
| npm | A published tarball | No, versions are immutable |
| PyPI | An uploaded file | No, uploads cannot be replaced |
| crates.io | A published archive | No, yank only |
| Maven Central | A published artifact | No, deletion not permitted |
| Packagist | A Git tag plus an indexed commit reference | Not since July 2026, retags were followed before that |
That last row is the whole story of PHP supply chain risk, and it isn’t a bug Packagist introduced by accident. Indexing Git keeps publishing friction unusually low, and that has long been one of Packagist’s practical advantages.
Reality Check: Calling this a Packagist vulnerability misses where the mutability actually lives. Git tags have always been movable by design. Packagist inherited that property by choosing to index repositories instead of hosting artifacts, and the same tradeoff is what makes publishing a Composer package comparatively lightweight.
What a Composer Supply Chain Attack Actually Looks Like
On April 30, 2026, someone holding a compromised service account pushed a malicious commit to intercom/intercom-php and put the existing 5.0.2 tag on it, according to the GitHub advisory. The rewritten composer.json declared the package a Composer plugin, and once approved – either through an interactive prompt or a permissive allow-plugins setting – it ran during composer install as a dropper that fetched the Bun runtime and executed an obfuscated credential stealer. The bad tag was live for under two hours before maintainers pointed it back at clean code. Researchers named it Mini Shai-Hulud and tied it to the campaign that hit Intercom’s npm client in the same window.
Three weeks later the same weakness got used at a much larger scale. Starting at 22:32 UTC on May 22 and running into May 23, an attacker with organization-wide push access rewrote every existing tag across four Laravel-Lang localization packages, pointing them at commits in a fork they controlled, so nothing malicious ever landed in the official repositories. Socket counted more than 700 remapped historical versions, Aikido confirmed 233, and the main burst of rewriting took under 90 minutes. This time the payload hijacked PHP’s autoloader instead of registering a plugin.
Neither incident needed a new release to appear anywhere. No changelog entry, no new version number, nothing that a maintainer or a dependency bot watching version numbers would flag as different.
Compare that with how an attacker has to work on npm. Replacing a legitimate release there means shipping a different version number, because the bytes behind an existing package and version cannot be swapped out underneath you. A new version shows up in release feeds and trips dependency bots. Moving a tag produced none of that.
| Attack step | npm | Packagist before July 2026 |
|---|---|---|
| Publish malicious code | New version number required | Existing version could be repointed |
| New release event | Yes | No |
| Caught by version-diff tooling | Yes | Often missed, the version number never changed |
| Reverted by upstream | Unpublish window applies | Tag could simply be moved back |
Reverting cut both ways. Recovery was quick – a maintainer could just move the tag back, which is exactly what happened with intercom-php that same night – but forensics got harder for the same reason, because a version number could point to a different commit than it used to. Packagist’s public transparency log recorded those reference changes, which at least leaves a durable record of when a published reference moved.
Two Different Payloads, One Mutable Tag
Both 2026 incidents were Composer supply chain attacks in the strictest sense, and both started with the same trick. What they did next was completely different, and that distinction matters a lot when you decide what to actually fix.
The compromised tag on intercom/intercom-php pointed at a commit that changed composer.json to declare the package a Composer plugin. That’s the version of this attack most write-ups reach for, because it maps cleanly onto Composer’s plugin system. An allowed plugin runs during composer install and composer update, before any of your application code executes.
The Laravel-Lang compromise never touched plugin registration. It wired a malicious src/helpers.php into the autoload.files entry, so the payload ran on every PHP request from the moment the package was installed. Same moved tag, same trust Composer places in whatever a tag resolves to, two completely different execution paths. A fix for one does nothing for the other.
You can also explore core framework protection strategies in our detailed guide to Laravel security.
Composer Plugin Security Has a Real Gate
A moved tag delivers code. Something has to run it, and for the intercom-style attack that something is the plugin system. A package declaring "type": "composer-plugin" gets loaded and can execute code as part of Composer’s own install and update runs once it is allowed. That’s the intended behavior, since plugins exist to alter how Composer installs things, which means running before installation finishes.
Composer 2.2.0 added the allow-plugins config, and the default today permits nothing at all. In composer.json it looks like this.
{
"config": {
"allow-plugins": {
"composer/package-versions-deprecated": true,
"phpstan/extension-installer": true
}
}
}
With allow-plugins configured, Composer refuses to execute any plugin that isn’t on the list. An unlisted plugin prompts when Composer runs interactively, and in a non-interactive run Composer fails instead of quietly carrying on, unless the plugin declares itself optional. A failed build beats a silent install.
Watch Out: Check whether anyone set "allow-plugins": true just to make a prompt go away. That single line allowlists every plugin, present and future, and it disables the allowlist protection completely. Running composer config allow-plugins shows what a project actually has configured, and false is the value that denies everything.
For a build that needs no plugins at all, the flags are blunt and effective.
composer install --no-plugins --no-scripts --no-dev
If your production build doesn’t need plugins or Composer scripts, switching both off there while leaving them available on developer machines is a reasonable split.
Autoload Files Are the Quieter Path
allow-plugins does nothing for the Laravel-Lang-style attack. autoload.files isn’t an optional feature you can gate behind a permission prompt. It’s a common way PHP packages ship helper functions, and Composer has to trust every package’s autoload configuration for the ecosystem to work at all.
That’s what made this version of the attack land so hard. Composer asks whether you trust a package to run as a plugin. It never asks whether you trust a package to define autoloaded files. Before Composer 2.10’s malware policy and Packagist’s immutability change, the main protection here was never resolving the rewritten tag in the first place, which is what made the lockfile matter so much.
The One Thing Composer Gets Right That npm Does Not
Composer scripts declared by a dependency don’t run. Only the root package’s scripts execute. A malicious post-install-cmd sitting four levels deep in your tree does nothing at all. That’s a meaningfully better default than npm, where any package in the tree can ship a postinstall script that fires during install.
| Mechanism | Runs for dependencies |
|---|---|
| npm lifecycle scripts | Yes, any package in the tree |
Python .pth startup hooks |
Yes, at interpreter start |
Rust build.rs |
Yes, during compilation |
| Composer scripts | No, root package only |
| Composer plugins | Yes, if allowed |
Did You Know: Ignoring dependency scripts is a deliberate Composer design decision, not a hardening measure bolted on after an incident. It’s one of the more overlooked details in how the two ecosystems actually compare.
What Your Composer Lockfile Records and What It Misses
composer.lock is more protective than its reputation suggests, and the protection has a specific boundary, one that happens to cover both the plugin attack and the autoload attack because it operates a level below either one.
For each package, the lockfile records the resolved version along with source and dist references, normally the Git commit hash behind that release. Run composer install and Composer asks for that exact commit rather than whatever the tag points at today, so a later rewrite of the tag never enters the picture. The guarantee is about which commit gets requested, not a checksum on the bytes that come back, since code hosts generate those archives on demand and the lockfile carries no usable hash for them. Operations that re-resolve dependencies were the exposure point, and that includes partial updates like composer update foo -W. The Packagist security write-up makes the same point about lock files, which is worth knowing because a fair amount of Laravel-Lang coverage claimed lockfiles offered no protection at all.
| Operation | Keeps the locked reference | Exposed to a moved tag |
|---|---|---|
composer install with lockfile committed |
Yes | No |
composer update |
No, it re-resolves | Yes |
| Partial update of one package | Only for entries it leaves alone | Yes, for anything it re-resolves |
composer require newpackage |
Partly | Yes, for the new requirement and any dependency it moves |
| Fresh clone with lockfile gitignored | No | Yes |
composer install in a Docker build without the lock copied |
No | Yes |
A Dockerfile that copies only composer.json before running install has no lockfile to work from, and the protection disappears without anyone touching a line of application code. Copying composer.lock alongside it brings the protection back.
git ls-files composer.lock
If that returns nothing, composer.lock isn’t tracked in this repository, so every clean build resolves dependencies afresh against whatever the registry metadata says at that moment.
Malware Screening and the July Immutability Change
The May incidents didn’t sit unanswered. Nils Adermann and Igor Benko published a security roadmap for Composer and Packagist on May 27, and Composer 2.10 shipped the next day.
One piece of that release matters here directly. Packagist had already been importing Aikido’s malware detection results since March, and 2.10 is what made that useful in practice. Flagged versions drop out of the resolution pool, so composer update, composer require and composer create-project cannot reach them, and composer install fails loudly if a flagged version is already sitting in your lockfile rather than pulling it in quietly. composer audit reports the same versions and fails by default.
In June, Private Packagist organizations also got plugin restriction controls, letting an org decide up front which packages may act as Composer plugins. With that switched on, a poisoned release that suddenly declares itself a plugin gets stripped out of update metadata and never reaches Composer’s resolver, though installs from an existing lockfile need Private Packagist’s separate download-blocking setting too.
The bigger structural fix landed on July 7, when Packagist rolled out immutable versions. Once a stable version is published, the Git reference behind it no longer changes. Delete, re-create or move the tag upstream and Packagist keeps the original reference, puts a retag blocked badge on the package page, and emails every maintainer.
Dev versions that track branches still move, since that is the point of a branch, and the rule covers the version to commit mapping rather than the archive bytes, because most zips are still generated on demand by the code host. Blocked reference changes, soft deletions and recoveries all land in the public transparency log.
None of that retroactively cleans a lockfile that captured a malicious commit before the protections shipped, which is exactly why composer.lock and allow-plugins still need checking on your own projects, whatever a scanner reports.
Verifying Packages Before a Tag Ever Resolves
In both attacks the dangerous reference entered the dependency graph at resolution time, long before a plugin loaded or an autoloader hijack fired. Composer 2.10 now adds a malware check at install time as well, though that check still depends on somebody having flagged the version first.
SecureChain for PHP gives teams a rebuilt-from-source, malware-screened package source instead of resolving straight through upstream VCS references. Every package is rebuilt in an isolated environment and screened before it is available to install, so what you pull is what was verified. Vulnerabilities get patched continuously under SLA, and Endless Lifecycle Support keeps that coverage going once a maintainer stops shipping updates. That matters most on abandoned packages, where the maintainer oversight that would catch an unexpected upstream change is already gone.
None of that replaces composer.lock, allow-plugins or composer audit. It closes the part of the problem that sits upstream of anything your own tooling can see. See how SecureChain for PHP works.
FAQ
What is a Composer supply chain attack?
An attack that compromises a PHP package before it ever reaches your project, usually through a compromised repository, a moved Git tag or a malicious Composer plugin, rather than through a vulnerability in code you wrote. It’s one specific version of the broader supply chain attack pattern that has been hitting open-source registries across every language.
Can a Packagist package version change after release?
It could, historically. Packagist indexed Git tags rather than storing immutable artifacts, so a tag could be deleted and re-pushed against a different commit without the version number changing. Since July 2026, Packagist rejects any attempt to change the source reference of an already-published stable version, a server-side protection that applies no matter which Composer version a developer runs. Dev versions tracking branches still move, and the change cannot make historical installs safe if they resolved a malicious commit earlier.
Do Composer dependency scripts run during install?
No. Only the root package’s scripts execute. A malicious post-install-cmd declared by a dependency is ignored, which is a stronger default than npm’s.
How do Composer plugins execute code?
A package with "type": "composer-plugin" can execute code as part of Composer’s own install and update runs, once it has been installed and permitted. Composer 2.2.0 and later require plugins to be explicitly allowlisted through the allow-plugins config.
Does composer.lock protect against a moved Git tag?
Yes, if the lockfile already holds the legitimate commit, because composer install uses that locked reference instead of resolving the tag again. It does not protect composer update, new composer require calls, or builds where the lockfile is absent.
What does allow-plugins set to true do?
It allowlists every Composer plugin, current and future, which removes the protection entirely. Replace it with an explicit map of the specific plugins your project needs.



