Back to writing
Laravel · Docker

Composer update is now a security risk. Here is the safer workflow

/ 7 min read

A Laravel package got hijacked through git tags and stole credentials at autoload time. Here is the Composer workflow I run on every project to shrink the blast radius.

A locked padlock over a Composer dependency tree, dark terminal style
On this page

I opened a project last week, glanced at composer.json, and felt my stomach drop.

There it was, sitting quietly in require-dev:

"laravel-lang/lang": "^14.8",
"laravel-lang/publisher": "^16.8",

Those are the packages that got hijacked in June. The credential stealer. The one that ran on every php artisan command and shipped your secrets to a server somewhere. I'd read about it like everyone else and thought "not my problem." Then I opened my own config and found it staring back.

Here is the part that should bother you: a routine composer update was all it took to get hit. No exploit. No clever attack on your code. Just a command you run a hundred times a year, doing exactly what it always does.

This post is the workflow I now run on every project, including client work at dsrpt, to make composer update boring again. Not bulletproof. Boring. Those are different goals, and I'll be honest about which one is actually achievable.

What actually happened with laravel-lang

Quick version, because the mechanics matter.

Someone got malicious code into the laravel-lang packages. The clever bit: the main repo looked spotless. No dodgy commits, no new files in the branch you'd inspect. The payload came in through git tags on forks, the thing almost nobody reviews.

Once installed, it ran at autoload time. That means it fired the instant PHP hit require_once __DIR__.'/../vendor/autoload.php' in public/index.php. Every web request. Every queue worker. Every artisan command. Silent. No error, no warning, nothing on screen.

And it was a key stealer, hunting for:

  • .env files from your Laravel projects
  • AWS access keys and session tokens
  • SSH private keys
  • GitHub and NPM tokens

This is not SQL injection messing with a few database rows. This is everything on the machine, gone, while your app runs like normal. Aikido Security caught it and Packagist pulled the bad versions fast. But anyone who ran composer update in that window was already exposed.

Why this is a different kind of problem

The Laravel security you were taught is about input. SQL injection, XSS, CSRF. Bad data coming in from outside, through a form or a URL. You validate, you escape, you're mostly fine.

A supply chain attack comes from inside your build. The attacker doesn't need a hole in your code. They need to compromise one maintainer account on one package you already trust. Then everyone downstream installs the poison themselves, by hand, with a command they think is safe.

JavaScript has been living with this for years. Hundreds of incidents. PHP got to feel smug for a while. That's over now, and AI tooling is making these attacks cheaper to pull off and harder to spot.

If you've read my self-hosted security checklist, this is the same lesson from a different angle: the boring infrastructure layer is where you actually get owned, not the flashy stuff.

The workflow I run now

I sat down after that gut-drop moment and wrote out exactly what I do on every project. Here it is, the full thing:

# 1. Run composer update inside Docker, never on the bare host
docker compose exec app composer update

# 2. Pin constraints to what's actually installed
docker compose exec app vendor/bin/jack raise-to-installed --dry-run  # look first
docker compose exec app vendor/bin/jack raise-to-installed             # then apply

# 3. Update the lock hash
docker compose exec app composer update --lock

# 4. Commit it like the code change it is
git add composer.json composer.lock
git commit -m "chore: update dependencies"

Running inside Docker is the first quiet win. If something nasty does execute, it runs in a container, not on the machine holding my real SSH keys and AWS credentials. Smaller blast radius. That alone is worth the friction.

The jack raise-to-installed step needs a word. rector/jack is a tool from the Rector team. The raise-to-installed command takes whatever you've got installed right now and tightens your constraints to match. So "guzzlehttp/guzzle": "^7.10" becomes "^7.11" if that's what you're running. Every future update can then only reach versions you've explicitly allowed, instead of drifting wherever Composer feels like.

This is not standard practice, and I won't pretend it is. It trades upgrade convenience for a narrower jump on the next update. For client work where a surprise major bump can break a live site, I'll take the narrow jump every time.

One gotcha. On a Laravel 10 project, a bare composer update can die on an advisory block. The fix:

"config": {
    "audit": {
        "abandoned": "report"
    },
    "policy": {
        "advisories": {
            "block": false
        }
    }
}

This reports advisories instead of blocking. You still see them, you still have to act. The update just doesn't wedge itself.

And always check what jumped before you commit. If your project runs PHP 8.1 and something tries to pull a version needing 8.2, you want to catch that in the dry run, not in production at 2am.

The honest caveat nobody likes

Here's the thing that workflow won't do: it would not have stopped the laravel-lang attack.

By the time jack raise-to-installed runs, the infected version is already on disk. The autoload payload has already fired. Pinning constraints afterwards just bakes the bad version in as your new baseline. That's worse, not better.

The defenses that actually matter happen at install time, and they're thin. composer audit checks against known advisories, but a package poisoned ten minutes ago isn't a known advisory yet. So run composer audit before and after every update anyway. It catches the stuff that's already public:

composer audit

On a fresh project it shows nothing. On an older one you'll get the real picture:

Found 3 security vulnerability advisories affecting 2 packages:
Package symfony/http-kernel ...
Package league/commonmark ...

It tells you what you're actually fixing. It will not save you from a zero-day in your dependency tree. Both things are true.

Question every dependency before it goes in

This is the part that compounds over time. Every package is a trust relationship and another thing that can be turned against you.

Before you composer require something, ask: do I need the whole package, or one function I could write in ten minutes? Some things you're not replacing, intervention/image or maatwebsite/excel earn their place. But the throwaway "env helper" that reads your .env? That one crosses a security boundary for a convenience you didn't need.

A heuristic I've started using: if I can't say in one sentence what a package does and what it touches (filesystem, network, env), it doesn't go in. Fewer packages means fewer attack surfaces and fewer things that break when the next Laravel major lands. This is dependency debt, the kind I wrote about in what is technical debt, except this version can leak your credentials.

What Packagist is doing about it

The ecosystem is moving, to be fair.

Composer 2.10 wired Aikido's malware detection straight into Packagist. Every release tag now gets scanned automatically. Stable version immutability also shipped: once a version is published, it can't be silently overwritten. That kills one of the laravel-lang tricks, rewriting an existing tag to slip malware into a version you already trusted.

Coming next: a minimum release age, so new releases sit in quarantine before composer update will pull them. And a two-step release flow where pushing a version triggers an MFA confirmation, so a stolen account alone can't ship poison.

Good direction. But it's a huge ecosystem and none of this lands overnight. Until it does, the discipline is on you.

What to do this week

Don't overhaul everything. Do three things:

  1. Open your composer.json right now and actually read your dev dependencies. Do you recognise all of them? I didn't, and that's how this started.
  2. Add composer audit to your update routine, before and after. It's free and it's two seconds.
  3. Move your updates into Docker if they aren't already, so a bad install can't reach your real secrets.

The bigger shift is mental. Stop treating composer update as a chore and start treating the lockfile like a code change that deserves a review. Same as you'd never merge a PR you didn't read. If you want the next layer down, my piece on why your auth system is probably wrong and the recent Next.js 13 CVE breakdown both come at the same idea: the boring, routine stuff is exactly where the breach gets in.

And when something does go wrong, reproduce it first before you start changing things. Panic-patching a compromised machine is how you make it worse.

Frequently asked questions

Is composer update safe to run? Not on its own. A compromised package can run code at autoload time the moment it installs, before you ever look at it. The laravel-lang hijack in June 2026 stole .env files, AWS keys and SSH keys this way. composer update is still fine to run, but treat the result like a code change: commit composer.lock, run composer audit before and after, and review what got bumped before you deploy.

What is a supply chain attack in PHP? It is when an attacker compromises a package you depend on instead of attacking your own code. They take over a maintainer's account or rewrite a git tag, push malicious code into a version you trust, and every project that pulls that version is exposed. The attacker never has to find a bug in your app. They just have to poison something you already install.

How do I protect a Laravel project from a compromised Composer package? Commit composer.lock so installs are reproducible. Run composer audit before and after every update. Pin constraints close to what you actually run so future updates can only move a little. Question every new dependency before you add it. And update on Composer 2.10 or later, which scans every release for malware and blocks silent tag rewrites.

What does composer audit do? It checks your installed packages against the PHP Security Advisories Database and lists any with known vulnerabilities. It is fast and worth running before and after every update. The catch: it only knows about advisories that have already been reported, so it will not catch a package that was poisoned minutes ago. It is a safety net, not a guarantee.

Did Composer fix the supply chain problem? It is closing the gaps. Composer 2.10 added automatic malware scanning on Packagist and stable version immutability, so a published version can no longer be silently overwritten. A quarantine window for new releases and MFA-gated publishing are on the way. Good progress, but none of it makes review and lockfile discipline optional.

FAQ

Frequently asked

Not on its own. A compromised package can run code at autoload time the moment it installs, before you ever look at it. The laravel-lang hijack in June 2026 stole .env files, AWS keys and SSH keys this way. composer update is still fine to run, but treat the result like a code change: commit composer.lock, run composer audit before and after, and review what got bumped before you deploy.

It is when an attacker compromises a package you depend on instead of attacking your own code. They take over a maintainer's account or rewrite a git tag, push malicious code into a version you trust, and every project that pulls that version is exposed. The attacker never has to find a bug in your app. They just have to poison something you already install.

Commit composer.lock so installs are reproducible. Run composer audit before and after every update. Pin constraints close to what you actually run so future updates can only move a little. Question every new dependency before you add it. And update on Composer 2.10 or later, which scans every release for malware and blocks silent tag rewrites.

It checks your installed packages against the PHP Security Advisories Database and lists any with known vulnerabilities. It is fast and worth running before and after every update. The catch: it only knows about advisories that have already been reported, so it will not catch a package that was poisoned minutes ago. It is a safety net, not a guarantee.

It is closing the gaps. Composer 2.10 added automatic malware scanning on Packagist and stable version immutability, so a published version can no longer be silently overwritten. A quarantine window for new releases and MFA-gated publishing are on the way. Good progress, but none of it makes review and lockfile discipline optional.

Enjoyed this? Let's talk.

Start a conversation →