The new reality: if you ran composer update on the wrong morning, the malware may be already on your machine.
No exploit chain. No vulnerability scanner could've saved you. Just one command, on the wrong day.
So this lesson is about the Composer habits that reduce the chance of this happening - and reduce the damage if it does.
None of it is fancy. Most of it is "stuff you already know but don't actually do".
1. Commit composer.lock
This sounds basic. But there are still Laravel projects out there with composer.lock in .gitignore.
Two files, two jobs:
composer.json- the allowed version rangescomposer.lock- the exact versions and commits installed
When your project is installed on a different server, it usually has composer install in the deployment script.
So without composer.lock available, it will try to download the latest versions according to composer.json which may include compromised versions.
The lockfile is your real production dependency list.
If composer.lock is not in your repo, fix that today.
2. Stop running composer update blindly
This is the single most dangerous Composer command.
composer update re-resolves every dependency, pulls the newest allowed versions of all of them, and rewrites your lockfile. If anything in your tree got compromised this week, you just installed it.
Usually, composer update installs dozens of updates, and also dependencies of those dependencies, so it's very hard to even know upfront which packages would actually get updated.
Bad:
composer update
Better:
composer update vendor/specific-package
Best:
- read the changelog
- check what changed in
composer.lock - update one package at a time, on purpose
You don't need to update everything every Monday. Most Laravel projects can go weeks between updates without any real cost.
3. Pin versions for security-sensitive packages
Caret ranges like "^12.0" are convenient, but they're also "please install whatever the maintainer tagged most recently".
For most packages, that's fine.
For packages that touch authentication, payments, encryption, or anything that loads early in the request lifecycle, consider being more precise:
"vendor/package": "1.4.2"
And in emergencies, you can even pin to a specific commit:
"vendor/package": "dev-main#a1b2c3d"
This last example is what to reach for when the maintainer says "the tags are compromised, use this clean commit".
4. Vet packages before adding them
When you run:
composer require somevendor/something
...you're trusting that package, all of its dependencies, and the maintainers behind all of them.
Before adding a new package, take 60 seconds to look at:
- maintainer activity on GitHub
- install count on Packagist
- dependencies of that package in its
composer.json - recent ownership changes (a popular package switching to a new maintainer is sometimes how supply-chain attacks start)
- whether there was a sudden burst of commits from a new contributor
- whether the package is abandoned
And if you have time/willingness to dive into the code of the package, here are the red flags in the code itself:
- minified or obfuscated PHP files
- unexplained
eval() base64_decode()on long strings- code that downloads things from the internet during install
- unexpected
shell_exec/execcalls
Two specific attack patterns worth knowing:
- Typosquatting - attackers publish a package with a name that's a typo of a popular one (
laravel-langsnext tolaravel-lang,lavarel/frameworknext tolaravel/framework). Double-check the spelling when copy-pasting acomposer requirecommand from a tutorial, a blog post, or an AI suggestion. - Dependency confusion - if your team uses private Composer packages, an attacker can register a package with the same name on public Packagist. Composer may then prefer the public one. If you have private packages, configure your
composer.jsonrepositories explicitly and reserve the namespace on Packagist where possible.
5. Minimize dependencies
The Laravel ecosystem makes it very easy to add packages. Helpers, small wrappers, "convenience" packages, tiny utilities that wrap one PHP function.
Each one is convenient. Each one is also:
trusted code execution inside your app, every request, forever.
Before adding a package, ask:
- could I write this in 20 lines myself?
- could I ask an AI agent to reproduce the same feature locally?
Fewer dependencies means fewer maintainers to trust. That's the whole point.
You may also have less trouble when a new Laravel version comes out - you won't need to wait until the maintainer releases a new version supporting the newest Laravel.
6. Be careful with composer global require
When you install a package globally:
composer global require some/package
...that package becomes part of your shell environment basically forever. It's loaded whenever you use the global Composer autoloader, which often includes Laravel installers, dev tools, IDE helpers, etc.
So a compromised globally-installed package can be even more dangerous than a project package - because it's "installed once, runs on every project".
A few practical habits:
- keep your global Composer install minimal (run
composer global showand review it) - prefer per-project dev tooling where possible
- remove globally-installed packages you don't actively use
Same idea, less obviously: globally-installed npm / pnpm packages used by your editor, linters, or formatters. Same risk profile.
7. Run composer audit
Composer ships with a built-in security checker:
composer audit
This compares your composer.lock against a public database of known vulnerable Laravel/PHP packages and tells you which versions have published advisories.
It won't catch supply-chain attacks like laravel-lang on day one - those don't have advisories yet. But it does catch the long tail of older known issues.
Run it:
- before deploying
- in CI
- once a week as a habit
For automated monitoring, you can also use:
- GitHub Dependabot
- Renovate
- Packagist's own security advisories
8. Use --no-scripts when something feels off
Composer runs install scripts as part of install and update.
Here's an example of composer.json scripts from the default laravel/laravel repository, at the time of writing this:
composer.json:
{ "require": { // ... }, "scripts": { "setup": [ "composer install", "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"", "@php artisan key:generate", "@php artisan migrate --force", "npm install --ignore-scripts", "npm run build" ], "dev": [ "Composer\\Config::disableProcessTimeout", "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others" ], "test": [ "@php artisan config:clear --ansi @no_additional_args", "@php artisan test" ], "post-autoload-dump": [ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "@php artisan package:discover --ansi" ], "post-update-cmd": [ "@php artisan vendor:publish --tag=laravel-assets --ansi --force" ], "post-root-package-install": [ "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" ], "post-create-project-cmd": [ "@php artisan key:generate --ansi", "@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"", "@php artisan migrate --graceful --ansi" ], "pre-package-uninstall": [ "Illuminate\\Foundation\\ComposerScripts::prePackageUninstall" ] }}
That's how malware in a compromised package may get executed during install.
When you're investigating a suspicious package, or reinstalling after a possible compromise, run:
composer install --no-scripts
This skips all package scripts entirely. Slower, more cautious, much safer.
You wouldn't use this every day. But it's a very useful flag to know exists.
9. Never run Composer as root
If you're SSH'd into a server as root and you run composer install, then any malicious package can do anything to the whole server.
Run Composer as the application user. Same for deployments. Same for Docker images.
If your deployment process requires sudo composer ..., that's a sign the process needs to be fixed, not that the rule should be ignored.
10. Don't ship dev dependencies to production
When deploying:
composer install --no-dev --optimize-autoloader
--no-dev skips everything in require-dev: PHPUnit, Pint, debug bars, IDE helpers, Faker, Telescope dev tooling, and so on.
You don't need any of that in production. And every one of those packages is one more place a supply-chain attack can come from.
Most Laravel deployment tools (Forge, Envoyer, Cloud, Deployer) already do this by default.
Here's the example deployment script from Laravel Forge:
$CREATE_RELEASE() cd $FORGE_RELEASE_DIRECTORY $FORGE_COMPOSER install --no-dev --no-interaction --prefer-dist --optimize-autoloader$FORGE_PHP artisan optimize$FORGE_PHP artisan storage:link$FORGE_PHP artisan migrate --force $ACTIVATE_RELEASE() $RESTART_QUEUES()
See the additional flags on composer install? So, check if your deployment tool has these.
11. When something goes wrong: don't panic-update
If you suspect a package compromise (or you're reading about one in the news), the instinct is to run composer update to "get the fixed version".
Stop. First figure out what scenario you're actually in.
Scenario A: no verified fix yet, you're in the first hours of an incident.
Don't update anything. Roll the lockfile back in git to a known-safe state, then:
rm -rf vendorcomposer install
This reinstalls exactly the versions in composer.lock - cleanly, without re-resolving anything. You're back to safe code while you wait for proper guidance.
Scenario B: the maintainer has published a clean version with specific instructions.
Follow them - but precisely. If they say "upgrade to 1.4.3", do exactly that:
composer require vendor/package:1.4.3
Not composer update. Not composer update vendor/package (which may pick up something else in the range). The specific version they told you to use.
Scenario C: you don't know which scenario you're in.
Then you're in Scenario A. Roll back, reinstall, wait for guidance.
The principle: never blind-update during an incident. Either roll back to safety, or follow specific verified instructions. Nothing in between.
In the next lesson, we'll assume the worst has already happened: a .env file got out. What changes when you design your project around that assumption.
No comments yet…