A composer.lock that had not changed in three years. A colleague showed it to me last week, not as a confession, as a normal fact about the project. Nobody decided this. Every single upgrade looked small and risky, so everybody postponed it. Three years of postponed small risks equals one migration project with a budget and a name.

The alternative is dull and it works. Upgrades as normal operations, like backups.

composer outdated --direct

Run it weekly. Direct dependencies only, transitive ones follow. One small PR per package or per group. Patch and minor versions merge on green CI, no discussion. Major versions get a ticket and a reading of the upgrade guide, one at a time.

Two things make this cheap. Tests you trust: full coverage is optional, coverage of the things that make money is the minimum. And deprecations treated as a budget. When a library warns, fix it this quarter, while the old way and the new way both work. A deprecation warning is the cheapest migration you will ever get. Ignoring it converts cheap work into expensive work at a date somebody else chooses.

Framework majors, same principle. A project that follows minors continuously does a Symfony or Laravel major in days. A project that waits does it in months, and half of that time is ten transitive packages that froze along with it.

Dependency debt has an interest rate. Small payments forever, or one payment that hurts. Set up a bot to open the PRs if typing the command is too much.

I opened the lock file of my own side project after that conversation. Not three years. Not a number I am proud of either.