devinmind

Rewriting a live legacy system without stopping it

Two PHP applications, one database each, people using them every day. The plan was to replace them with no downtime and no lost data, and to be able to prove the new version behaved like the old one.

Igor Wendt, November 2026

The starting point

Two management systems for small gyms, written in PHP on CodeIgniter 3 around 2016 to 2020. Member records, fitness assessments with body-composition formulas, workouts, payments, instructor payouts and reports. Each one on shared hosting, each with its own MySQL database, and both still in daily use.

The goal was a modern stack (NestJS with Kysely on the backend, React on the frontend), a small VPS with Docker instead of shared hosting, and three hard rules:

  • The old and new versions must be able to run at the same time, on the same database.
  • Nothing changes for users until the new version is proven equivalent.
  • The old version stays ready as a fallback until it is no longer needed.

One database, two applications

Keeping the database shared is what makes a gradual switch possible, and it shapes every other decision. Schema changes are limited to additive migrations that the PHP code tolerates. One example: password columns had to be widened before the new version could store bcrypt hashes, and the PHP code had to keep working with the wider columns.

The less obvious constraint is data types. PHP gets everything from MySQL as strings and formats numbers with number_format. If the Node side parses decimals into floats and dates into Date objects, small differences appear everywhere: rounding, time zones, trailing zeros. So the new code reads decimals and dates as strings (dateStrings on, decimal parsing off) and formats them with a small module that reproduces PHP's behavior exactly. Float conversion happens only inside calculations, never on the way to the screen.

Proving equivalence

"It looks the same" is not a test. Three layers did the actual work.

Golden tests for the formulas

The assessment module computes body fat, lean mass and ideal weight with several published protocols, and the results are shown to people. A PHP script runs the original functions over a set of inputs covering each protocol and its edge cases, and writes the expected outputs to a JSON file. The TypeScript port has to match every value exactly. If a rule ever needs to change, the golden file is regenerated on purpose, and the change shows up in review.

One end-to-end suite, two targets

The same Playwright tests run against both systems, switched by an environment variable:

TARGET=legacy npx playwright test   # PHP, synthetic seed
TARGET=new    npx playwright test   # NestJS + React, same seed

For that to work, the new frontend keeps a UI contract with the old one: the same URLs, the same form field names, the same button labels and messages. When the new version fixes a legacy bug on purpose, the test is marked as expected to fail on the legacy target, so the suite also documents every intentional difference.

Parity on a copy of the real data

Synthetic data never covers everything that real data does. A parity tool loaded a copy of each production database, rendered every assessment, every comparison report and every financial report in both versions, and compared the results. Each difference had to be explained. In the end, every remaining difference was one of the documented fixes.

What parity found

Comparing against real data surfaced bugs that had been in production for years:

  • Totals were wrong for values of 1,000 or more. The PHP code formatted the amount as 1,310.00 and then added the formatted string, which PHP reads as 1. Any month with a large payment had a wrong total.
  • Records were reachable by changing the ID in the URL. A user could open other people's assessments. The new version checks ownership on every request.
  • Passwords were stored in plain text. Hashing them while PHP was still live would have locked users out of the old system, so the new code accepts both formats and hashes on login only after a flag is turned on, once the PHP version is gone.
  • The repository wasn't the system. The code in production had been edited directly on the server after the last commit. The behavior to match was the one running in production, so that became the reference instead of the repository.

The switch

Because both versions could share a database, the switch could happen in small, reversible steps instead of all at once:

  1. New version on the old databaseUsers test it on a new address, with real data, while the old version keeps working.
  2. Move the addressChange DNS to the new server. During propagation some people still reach the old system, and that's fine: both write to the same database.
  3. Copy the database in a quiet windowDump, import on the new server, apply migrations, point the application at it. About a minute.
  4. Compare everythingFull data dumps of both databases, compared byte for byte, not just row counts.
  5. Keep the fallbackThe old application stays packaged and ready to start, and the new one keeps the previous image for a one-command rollback.

Things that almost slipped through

Time zones and a clock change that no longer exists

After the copy, row counts and maximum IDs matched exactly. A full dump comparison still showed 37 differences, all in a TIMESTAMP column, all from before 2019. The old server used the system time zone, America/Sao_Paulo. The new MySQL had been configured with a fixed -03:00 offset. Brazil abolished daylight saving time in 2019, so for current dates the two are identical, but for older records that fell in daylight saving periods the new server showed times one hour earlier. The stored values were identical; only the interpretation differed. Setting the named time zone fixed it, and only comparing full dumps would have caught it.

Email that technically worked

Moving off shared hosting meant moving email sending to a transactional provider. Messages were delivered, but some went to spam, and the headers showed why: the provider had rewritten the sender to its own domain because the address was not yet registered as a sender, so DKIM signed for the provider's domain instead of ours. After registering the senders, SPF, DKIM and DMARC aligned on our own domains, which is the baseline for landing in the inbox at all.

Testing against real data without touching it

The test environment ran on a copy of production data, which includes real people's email addresses. Two safeguards made that safe. The application gained a setting that redirects every outgoing email to a single inbox, with the original recipient in the subject. And the browser-driven acceptance suite runs behind a request guard: every write request is checked, and anything that doesn't target a record created by the test itself is blocked before it leaves the browser.

Running it on a small server

The production setup is intentionally boring:

FailureWhat handles it
Process crashes or runs out of memoryDocker restart policy, per-container memory limits
Process alive but stuckHealth check that queries the database, plus a watchdog that restarts unhealthy containers
Bad deployThe previous image is tagged before every update; rolling back is one command
Bad dataNightly database dumps on the server and copied off-site, with a restore script
Nobody notices it's downExternal uptime monitoring with alerts

A reverse proxy handles TLS certificates automatically. The old URLs redirect to the new ones path by path, so links in old emails keep working.

What I'd keep doing

  • Treat equivalence as something to prove with tests, not something to eyeball.
  • Write the same end-to-end suite for the old and the new system before changing anything.
  • Compare full data, not summaries. Counts and maximum IDs matched while 37 timestamps didn't.
  • Make every step reversible, and keep the fallback until the new version has run quietly for a while.
Back to writing