When a Composer package vanishes from GitHub: don’t panic, and don’t delete vendor/

Today our CI/CD pipeline went red on a job that hadn’t been touched in months. The .gitlab-ci.yml was untouched. The branch built fine yesterday. composer install exploded.

The relevant chunk of the failure log:

1
2
3
4
5
6
7
8
Failed to download acme/some-nova-tool from dist:
  https://api.github.com/repos/old-owner/some-nova-tool/zipball/24bd3d8...
  HTTP/2 404

In Git.php line 657:
  Failed to execute git clone --mirror -- https://github.com/old-owner/some-nova-tool.git ...
  remote: Repository not found.
  fatal: repository 'https://github.com/old-owner/some-nova-tool.git/' not found

The package itself was still listed on Packagist — but the canonical GitHub repo it points to had been deleted. Even better: both the original repo and the namespace-renamed fork it had been moved to were gone. Packagist had quietly marked the package as frozen with a tiny note: “This package’s canonical repository appears to be gone and the package has been frozen as a result.” 💀

The package was tiny (a Laravel Nova permissions tool) but load-bearing — twelve files in our codebase imported a trait from it, plus a service provider registration. Removing it was not an option for today.

Why local dev kept working

Here’s the part I want you to internalize before anything else: do not rm -rf vendor/ when you hit this kind of failure. Not on your laptop, not on the developer machine of whoever first reports the issue. 🛑

The vendor/ directory is your last copy of that package’s source code. Composer downloaded it months ago from a repository that, today, no longer exists. If you blow away vendor/ and re-run composer install, you will get the exact 404 the CI runner got, and now you have no way to recover the source short of finding a teammate whose vendor/ is still warm.

Tell your team the same thing. The instinct on a broken composer install is to nuke vendor/ and try again. That instinct is wrong here. Until you have a plan, treat the existing vendor/ tree like an artifact you’d lose forever if you deleted it — because that’s what it is.

The recovery: copy, fork, host it yourself

Once you have a backed-up copy of the package source, the recovery is straightforward. The shape of the fix:

  1. Copy the package source out of vendor/ into a scratch directory.
  2. Push it to a Git host you control (your company’s GitLab, a personal GitHub org, wherever).
  3. Tag a version on your fork.
  4. Tell composer.json to look at your fork instead of Packagist.

Step one and two:

1
2
3
4
5
6
7
8
9
mkdir /tmp/some-nova-tool && cd /tmp/some-nova-tool
cp -R ~/projects/myapp/vendor/acme/some-nova-tool/. .
git init -b main
git add .
git commit -m "Import acme/some-nova-tool source (upstream deleted)"
git tag v1.0.8-beta.0
git remote add origin https://gitlab.example.com/internal/some-nova-tool.git
git push -u origin main
git push origin v1.0.8-beta.0

A note on the tag. The locked commit in our composer.lock was on the dev-main branch, several months past the package’s last tagged release (v1.0.7). Rather than invent a v4.0.0 from thin air, I anchored the tag to actual upstream history: v1.0.8-beta.0 — “newer than 1.0.7, not stable, exact snapshot of where main was the day upstream disappeared.” The version string is arbitrary as long as it’s valid SemVer; pick one that won’t lie to a future reader. 🪦

Then in composer.json, add a VCS repository entry pointing at your fork and pin the version:

1
2
3
4
5
6
7
8
9
10
11
{
  "require": {
    "acme/some-nova-tool": "v1.0.8-beta.0"
  },
  "repositories": [
    {
      "type": "vcs",
      "url": "https://gitlab.example.com/internal/some-nova-tool.git"
    }
  ]
}

Crucially, keep the package name the same — acme/some-nova-tool. Composer’s package name and the autoload PSR-4 namespace are what your application code references. If you change the package name, every use Acme\SomeNovaTool\… statement across your codebase breaks. Keep the name; just change where Composer looks for it.

Regenerate the lockfile with the new source:

1
composer update acme/some-nova-tool --with-dependencies

Commit composer.json and composer.lock together and your CI runs green again. The next developer to composer install on a cold cache will pull from your fork and never know there was ever a problem.

Two small details that bit us

HTTPS vs SSH. Make sure the repository URL in composer.json is HTTPS, not SSH. Your laptop probably has an SSH key on the host; CI runners don’t, and they almost always authenticate via an HTTPS token (composer config –global gitlab-token.gitlab.example.com $TOKEN). One of them is in your shell config; the other has to work in a fresh container with only env vars. If they don’t agree, CI fails with auth errors that look nothing like the original 404.

Packagist will not save you. The package page may still resolve — the metadata lives on Packagist, not on GitHub — but the dist URL embedded in that metadata points at GitHub. Composer reads the dist URL, fetches it, gets a 404, falls back to a git clone, gets another 404, and gives up. Once the upstream Git host is gone, Packagist is just a tombstone. 🪦

The lessons, in one sentence each

  • Vendor is your backup. A populated vendor/ tree is the only copy of a deleted package you’ll ever have. Treat it like data, not cache.
  • Pin to tags, not branches. Tracking dev-main means “whatever HEAD is” — fine until HEAD is gone. A pinned tag on a fork you control is reproducible forever.
  • Self-host anything load-bearing. If a third-party package is woven into a dozen of your files, the cost of mirroring it on a Git host you control is one afternoon. The cost of not doing it is the day it disappears and your CI is red and you can’t ship.

Software supply-chain rot is a real thing. Repos get deleted, packages get unpublished, maintainers leave platforms, accounts get suspended. The defensive move costs almost nothing and pays off the one day you really need it. 🛡️

This entry was posted in php and tagged , , , , . Bookmark the permalink.

Comments are closed.