The call is always some version of the same story. The shop makes real money. The agency that built it is gone, or the one developer who understood it left in March. There is a backlog of bugs the business has learned to live with, a Symfony version that stopped receiving security fixes a while ago, and a deployment process that involves a specific person, a specific laptop, and courage.

The instinct — the client's and the engineer's — is to start fixing the most painful bug on day one. It is almost always the wrong first move, because in a codebase you do not understand, with no tests and no safe deployment, every fix is a coin flip that can convert "annoying bug" into "site down". Here is the sequence that has worked instead, and it front-loads a month in which embarrassingly few features ship.

Week one: make deployment boring

Whatever else is true of the system, you cannot operate it until you can release and roll back without ceremony. So the first deliverable is not a fix, it is a pipeline: reproducible builds from a clean checkout, one command to deploy, one command to roll back, and the environment configuration pulled out of the special laptop and into version control (secrets excepted, obviously).

This work is invisible to the business, so say the quiet part in the kickoff: nothing user-visible will improve for a few weeks, and this is the part of the engagement that makes every later week cheaper. Clients accept a stated trade-off far more gracefully than a discovered one.

While the pipeline work happens, resist knowledge-transfer theatre. Reading the entire codebase up front converts none of it into understanding. Instead, wire up error tracking and slow-query logging and let production tell you where the bodies are. A week of real traffic annotates the codebase better than its authors ever did — you get a ranked list of what actually breaks and what is actually slow, which is the only map worth having.

Characterise before you change

The standard objection to touching legacy code is "there are no tests". True, and unhelpful — you cannot retrofit a proper unit test suite onto code whose behaviour you do not yet know is intended. What you can do is pin the current behaviour down before changing it. Two tools carry most of the weight:

  • Golden-master tests for the money paths. Drive checkout, cart totals, tax and shipping calculation through their paces with a few dozen recorded inputs and assert the outputs stay byte-identical. You are not asserting the behaviour is correct — nobody knows — you are asserting your changes did not alter it unknowingly. When a golden master breaks on purpose, that diff is the code review.
  • HTTP-level smoke tests. Twenty URLs, logged in and out: status codes, a handful of load-bearing strings, response times. Crude, and it catches the class of regression that actually takes revenue down — the fatal error on a template path nobody clicked in staging.

With the pipeline and the pins in place, upgrades become mechanical instead of heroic. The Symfony jump goes minor version by minor version, deprecations cleared at each step, golden masters green at each step — tedious, low-drama work, which is the point. (This staircase of mechanical sweeps is also where AI-assisted tooling genuinely pays; the deprecation churn compresses nicely. The judgement calls on either side of it do not.)

Strangle the worst of it; keep the rest

Somewhere in month two, someone will propose the rewrite. The honest response to "this is unmaintainable" is usually "parts of this are unmaintainable" — most of the revenue is flowing through code that, however ugly, encodes years of fixes for problems you have not met yet. Throwing that away wholesale means rediscovering those problems one incident at a time.

So rescue proceeds by organ transplant, not resurrection. The genuinely rotten subsystem — in one memorable case, a homegrown "cache" that wrote serialized PHP objects to the database and was the source of a quarter of all errors — gets isolated behind an interface, rebuilt, and swapped, golden masters standing guard. The merely old-fashioned code gets left alone until there is a business reason to open it. Ugly-but-stable is not a technical problem; it is a preference, and rescues that chase preferences run out of budget before they run out of ugliness.

The success condition is worth stating because it is modest: the client can deploy on a Tuesday afternoon without holding their breath, the error tracker is quiet enough that a new entry means something, and the next developer to join gets productive in days. Not a beautiful codebase — a boring one. In legacy work, boring is the deliverable.