On May 22, 2026, an attacker with push access to the Laravel-Lang GitHub organisation rewrote more than 700 git tags across several Composer packages (laravel-lang/http-statuses, laravel-lang/actions, laravel-lang/attributes) in about fifteen minutes. Not a new version published on top of the old one: the existing tags, rewritten.
The payload injected a helpers.php file into the autoload.files section of composer.json, which makes it run on every application boot. From there, it exfiltrated whatever secrets were on the machine.
The part that stings: pinning a precise version in your composer.json wasn’t enough. Why? We’ll come back to that.
The first article in this series set the scene and dismantled the first reflexes. This one gets concrete: what, on the Laravel/PHP side and the JavaScript side, would have changed something for Laravel-Lang and for attacks of the same kind. With good news (package managers have come a long way) and less good news (gaps remain, and they’re not in the same place depending on the ecosystem).
A few principles, quickly
The first article already dug into this, so we’ll be brief. Four ideas hold up everything else:
- The lock file is the source of truth.
composer.lock,package-lock.json,pnpm-lock.yaml: it’s the exact snapshot of what got installed, with hashes to verify integrity. You install from it, and it’s essential to maintain it and make sure it’s actually used everywhere. - The quarantine delay (the “cooldown”). Most malicious versions are spotted and pulled within hours of being published. Waiting a few days before installing a brand-new version filters out a large share of attacks. It does run counter to having the latest fixes right away, but it keeps the latest backdoor (the one nobody has noticed yet) out.
- Reduce the execution surface. The less code you let run at install time, the fewer doors you leave open.
- Auditing is an act, not a magic tool. A scanner sees what it already knows. It helps, it never suffices.
What’s new over the past year is that these principles are no longer just best practices you have to wire up yourself. Part of it is now native in package managers. But not the same part depending on whether you’re on the PHP or JS side, which is exactly why it’s worth looking at both.
On the Laravel side: Composer
First, on the Composer side, let’s clarify and separate two actions that don’t carry the same level of risk at all: install on one side, update and require on the other.
composer install replays the lock. It’s what you run in CI, in production, on a new developer’s machine. It’s repetitive, automatable, and should never introduce a new version. composer update and composer require, on the other hand, modify the dependency tree. That’s a deliberate action, done by a human, and it’s precisely where new versions (potentially trojaned) enter your project.
Once that separation is clear, you can place each defence where it belongs.
On install: lock it down, with --no-scripts as a belt-and-braces
In CI and in production, you install from the lock, full stop. composer install, never composer update. And while you’re at it, add --no-scripts:
composer install --no-dev --no-scripts --prefer-dist
The idea: in CI or production, you generally don’t need package lifecycle scripts (post-install-cmd and friends) to run. Cutting them closes a classic code-execution vector at install time. It’s a real belt-and-braces, and it costs almost nothing.
But let’s be honest about its limit, because recent events made it clear. --no-scripts would not have blocked Laravel-Lang. The execution vector wasn’t a lifecycle script, it was autoload.files. And autoloading isn’t a script: it’s Composer’s normal mechanism for loading code.
A special case deserves a mention: libraries. By convention, a package meant to be included by others doesn’t commit its composer.lock (Composer ignores a dependency’s lock file anyway, only the final application’s matters). The result: a library’s CI re-resolves its tree on every run, exactly like an update. That’s all the more concerning because this CI often holds the package’s publishing tokens, which makes it possible to compromise it, and therefore poison the package for all its consumers in turn. Practice is starting to shift on this point: more and more maintainers do commit a composer.lock, purely to make their own pipeline reliable and secure, with no effect downstream since that lock is never reused by consumers.
Autoloading is the real trap
When a package declares a file in autoload.files, Composer wires it into vendor/composer/autoload_files.php. And that file is included every time vendor/autoload.php is loaded. Which is to say constantly: every web request, every php artisan, every queue worker, every test suite run, every deploy.
This has two consequences.
The first is that --no-scripts doesn’t defuse the bomb. On a composer require in a Laravel project, it’s the @php artisan package:discover hook (a post-autoload-dump script) that boots the application and triggers the payload during the command. If you pass --no-scripts, that hook is skipped, so the require doesn’t boot the app: you avoid the immediate trigger on your machine. But the malicious file is already registered in the autoloader. On the very next load of vendor/autoload.php, it runs. --no-scripts keeps you from pulling the trigger yourself, it doesn’t remove the round from the chamber, which will fire on the first PHP script that follows.
The second consequence matters even more: once the mistake is made, it’s always too late. A scanner, an audit, a community alert, a malware filter catching up: all of it only warns after the fact. And there will always be other execution vectors: it’s the very nature of a dependency to have its code run on your side. Supply chain attacks belong to the RCE (remote code execution) family, their goal is to run code on your machines. And once that code has run, with your permissions, next to your secrets, the damage is irreversible: an exfiltrated API key or CI token can’t be “un-leaked”, being warned the next day changes nothing. All that’s left is to audit everything, then revoke and regenerate every secret.
Keep this chain in mind, because the rest of the PHP section follows from it: for a vector like autoload.files, only prevention really counts. Detecting after the fact limits spread and duration, but it doesn’t undo an execution that already happened.
On update / require: Composer 2.10 and its malware filter
This is where the real news arrives on the PHP side. Composer 2.10 ships native malware filtering. The data comes from Aikido (under a CC-BY 4.0 licence) and it’s enabled by default for everyone using Packagist.org. Concretely, a version identified as malicious is blocked during install, update, and require, and composer audit fails on it by default.
The clever detail: the check also runs on install. So if a version slipped into your composer.lock before being identified as malicious, the next composer install fails. A trojaned version that landed in your lock won’t be silently reinstalled in CI or production.
While we’re at it, 2.10 tidies all this into a unified framework, config.policy, which replaces the old config.audit and handles security advisories, abandoned packages, and malware under one roof. And it deprecates the “source” download fallback (full removal planned in 2.11), which was a possible attack path.
In practice, the reflex to wire up is to guarantee a Composer version ≥ 2.10 in CI (otherwise no active malware filter), then run composer audit on the update PR:
composer audit
You turn an update into a verifiable act: a dedicated PR, a readable lock diff, an audit that passes or breaks the build.
The remaining gap: no native cooldown
Here’s the honest point, and it’s the most important one in the PHP section. Composer 2.10’s malware filter is reactive. It only sees a version once it has already been identified by Aikido (or reported by the community). So there’s a window of exposure, and that window is the detection time.
The concrete scenario: you run a composer require or composer update on a version published an hour ago and already trojaned. Aikido hasn’t flagged it yet, so Composer lets it through without a hitch. And with an autoload.files vector, the code runs immediately. If nobody has spotted the version yet (and detection is neither instant nor guaranteed), nothing stops you.
This is exactly what a cooldown would fill: refusing to install a version that’s too recent, giving the community a chance to spot a problem. Except that Composer has no native cooldown today. On this specific point, PHP is behind the JavaScript ecosystem.
And the reason is instructive. A cooldown only has value if the publication date is reliable. But on the Composer side, that date comes from the commit timestamp or the time field in composer.json: data controlled by the maintainer, and therefore by the attacker. With Laravel-Lang, which rewrote the tags, an attacker could just as easily backdate a version to slip under the cooldown’s radar. This is exactly what the discussion on the subject (Composer issue #12793) points out:
“An attacker could right now easily fake the release date to put it in the past, as the release is under control of the package authors.”
A PR tried to add a minimum-release-age (PR #12692), another issue asked for a registry-controlled publication date (issue #12793): both were closed, the latter “as not planned”. The real limit, as you can see, isn’t in Composer, it’s at Packagist.
The encouraging news is that the groundwork has just been laid. As the Packagist team explains, stable versions are now immutable: you can no longer rewrite a published tag, which closes the re-tagging vector used by Laravel-Lang. Their phrasing sums up the prerequisite well:
“We need release metadata to be reliably immutable before we can safely treat ‘publication time’ as a security input.”
So the groundwork is laid, but the cooldown feature itself isn’t there yet. In the meantime, on the consumer side, three levers remain, all non-native:
- Renovate
minimumReleaseAgeor Dependabot’s cooldown. Effective, but only if you go through these tools, and only on automated update PRs (not on acomposer requirerun by hand).
// renovate.json: we don't accept a version less than 3 days old
{
"minimumReleaseAge": "3 days"
}
- Private Packagist, which offers quarantine and malware screening on ingestion. Solid, but it’s infrastructure few small teams will run.
- Manual discipline, for lack of better: don’t run a
require/updateon a same-day release, read the lock diff, lean on the hashes.
Pinning: useful, but not enough on its own
Pinning a version in composer.json, sure, why not, but that’s not where security is decided, and honestly I’m not a fan. Playing with composer.json constraints muddies the picture for developers, and above all it doesn’t lock the whole dependency tree, only what you declare directly.
What protects you is composer.lock, which pins far more than a version number: it records the exact commit SHA of each package (dist.reference). A composer install replayed from a validated lock therefore fetches the content at that SHA, not at the rewritten tag. That’s why an install from a trusted lock is safer than a blind update. You could debate how much trust to place in that SHA, but since stable versions became immutable on the Packagist side, the re-tagging vector is closed at the registry level anyway.
On the JavaScript side: npm, yarn, pnpm
Same reading grid as with Composer. npm ci replays the lock (that’s what you use in CI and production), npm install and npm update modify the tree. Everything we said about the install / update separation holds here too.
But the JS ecosystem has an asset PHP doesn’t have yet, and a gap PHP has filled. That contrast is the interesting part.
The asset: the cooldown, sometimes on by default
The cooldown, that quarantine delay Composer is missing, exists natively on the JS side. With one nuance that changes everything: is it on by default or not?
- pnpm:
minimumReleaseAgeis on by default since pnpm v11 (1 day, that is 1440 minutes). pnpm was the first major manager to ship a cooldown by default. The publication/detection window is therefore closed out of the box. - Yarn (Berry):
npmMinimalAgeGateis also on by default (since Yarn 4.10), at 3 days, even more cautious than pnpm. - npm:
min-release-agewas introduced in npm 11.10.0 (February 2026), but it’s off by default. Until you turn it on, npm has exactly the same gap as Composer. npm v12 is expected to enable it by default, but in the meantime, it’s up to you.
For npm, you set it in the config:
# .npmrc: we refuse versions less than 7 days old
min-release-age=7
The reasoning behind it is always the same: the vast majority of malicious versions are pulled within hours. A 24-hour delay already filters out “smash-and-grab” attacks, and a week shields you from nearly all of them. At the cost of being a week behind on the new stuff, which, for dependencies, has never killed anyone.
In practice, the choice of package manager becomes a security choice: by default, pnpm and yarn protect you where npm leaves you to fend for yourself.
The gap: no native malware filter in the client
Here’s the other side of the contrast. On the JS side, there’s no equivalent of Composer 2.10’s malware filter built into the client. The npm registry does pull malicious packages once they’re identified (that’s GitHub’s security team’s job), but it’s not a block enabled by default at the moment you install.
Proactive detection therefore goes through third-party tools, sometimes called “supply chain firewalls”:
- Socket, which inspects packages across seventy-some signals (obfuscated code, unexpected network or file access, shell command execution, install scripts).
- Aikido Safe Chain, which hooks into npm, yarn, and pnpm to block a malicious package at install time.
- Snyk, OSV-Scanner, and others.
And it has to be said plainly: these engines have the same reactive blind spot as Composer’s filter. They see what has been identified, not a true zero-day published ten minutes ago. Scanning doesn’t replace the cooldown, it complements it.
The table that sums it all up
If you put the two ecosystems side by side, you see that nobody is complete:
| Native malware filter (client) | Cooldown | |
|---|---|---|
| Composer (PHP) | Yes (2.10, Aikido, default) | No, none native |
| npm | No (third-party tool) | Yes, but off by default |
| pnpm / yarn | No (third-party tool) | Yes, by default |
The lesson isn’t “this ecosystem is better”. It’s that a robust defence combines cooldown and malware detection, and that no manager gives you both natively today. On the PHP side, you have the filter but must add the cooldown from outside. On the JS side, you have the cooldown (to enable for npm) but must add detection through a third-party tool.
Reducing the surface: lifecycle scripts
The equivalent of Composer’s --no-scripts exists everywhere:
npm install --ignore-scripts
# or globally, once and for all
npm config set ignore-scripts true
The catch is that some packages genuinely need their build script (native modules, for instance). Cutting everything sometimes breaks the install. pnpm offers a finer approach: an explicit allowlist of the only packages allowed to run their scripts.
// package.json: only esbuild is allowed to run its scripts
{
"pnpm": {
"onlyBuiltDependencies": ["esbuild"]
}
}
That’s the right trade-off: by default, nothing runs, and you open the door case by case, knowingly.
npm audit: useful, but don’t think you’re covered
npm audit (and composer audit) list known vulnerabilities, referenced in databases. That’s valuable for classic CVEs. But against a supply chain attack, at the moment it lands, there’s nothing in the database: the malware was just published. audit doesn’t see it. It’s a net for the known past, not for today’s threat.
What won’t save you
Let’s recap the false friends, because they’re the ones that lull you to sleep.
- Pinning a tag. Useless if the tag is rewritten, as Laravel-Lang showed. It’s the lock’s hash that protects you, not the version number.
- Trusting popular packages. Axios was racking up 70 million weekly downloads when it was compromised. Popularity says nothing about the security of a given version.
audit(Composer or npm). Blind to what isn’t referenced yet. So blind to the attack at the moment it counts.- Composer 2.10’s malware filter. Reactive. It only sees a version once it’s flagged. On a
require/updateby hand, a fresh version not yet detected gets through. Its only net (the failure of the nextinstall) is retroactive: it doesn’t help the machine that already pulled it. --no-scripts/ignore-scripts. Doesn’t blockautoload.fileson the PHP side, nor every vector on the JS side.- The absence of a native cooldown on the PHP side. Nothing natively covers the window between publication and detection, unlike pnpm and yarn which enable it by default.
None of these defences is useless. They’re simply incomplete taken in isolation, and dangerous if they give you a false sense of security.
Going further: what if we sandboxed the install?
Since no net perfectly covers the publication / detection window, you can move up a level: rather than hoping to detect the bad package, make it so there’s nothing for it to steal even if it runs. The idea is to run dependency installation in a disposable container, without your secrets, without your keys, and above all without outbound network access (at best an allowlist to the registry). Even if a post-install or an autoload.files runs, it has nothing to exfiltrate and nowhere to send it. It’s egress, not local scanning, that neutralises the most common attack.
It doesn’t replace anything (the cooldown, lock integrity, and least privilege are still needed) and it doesn’t cover runtime. But it’s a whole subject of its own, and it’s exactly the one in the next article.
What to put in place on your next commit
No need to wait for an overhaul. The concrete steps, right now:
- In CI and production,
composer install --no-scriptsandnpm ci, neverupdate. Replay the lock, introduce nothing. - Enable a cooldown. On pnpm and yarn, just check it’s there (it is, by default). On npm, add
min-release-age. On Composer, for lack of a native option, wireminimumReleaseAgeinto Renovate or Dependabot. - Wire
composer auditandnpm auditinto CI, on update PRs. And for Composer, you also get the native malware filter from 2.10. - Reduce the execution surface.
--no-scripts/ignore-scriptsby default, and an allowlist (onlyBuiltDependencies) for the rare packages that need it. - Treat every update as an act. A PR, a lock diff that gets read, a changelog skimmed. Not a side-effect of an
installrun without thinking.
One last word on the other end of the chain. If you publish packages yourself, the toolkit has moved a lot: trusted publishing via OIDC (which removes long-lived tokens), provenance attestations, npm granular tokens limited to 7 days for publishing, hardware 2FA (FIDO) rather than SMS. It’s a topic in its own right, on the maintainer side, which we’ll keep for later.
The next article will stay on the thread we left taut at the end of this section: protecting yourself with Docker. Images, registries, scanning, reproducible builds, and that install sandbox we just brushed against. Because FROM node:latest, as we said in the first article, isn’t an innocuous command.