Featured Work

Replacing a Homegrown Migration System — and Making Sure It Can’t Empty Production

Deleting the old schema-migration system was the easy part. Making sure its replacement cannot quietly empty a production database took longer.

June – September 2026 · Three phases · in review

Four ways the new tooling could have lost data. Each was reproduced before it was fixed.

The setup

TEAMBOX ran schema changes and upgrades through a homegrown, YAML-driven system: hundreds of YAML schema files, a set of legacy upgrade scripts, and a handful of custom schema classes to tie it together. It worked, in the sense that it had been running for years. It had also never met the failure modes that appear once a real migration tool is in charge.

Phase one: a replacement, not a patch

I built and shipped a full replacement of the old system with Doctrine Migrations. The old schema-definition layer, its YAML fixtures and the legacy upgrade scripts are gone: a net reduction of thousands of lines across hundreds of files. A dedicated fixture-management layer, paired with Doctrine Migrations, now owns schema and test-database setup end to end.

Along the way I found that the CLI framework's long-flag parsing had never worked anywhere in the codebase. An untrimmed-whitespace bug made it fall back to defaults silently instead of failing, and a short-flag path had hidden it for years. It is fixed, with a regression test so it cannot come back.

I also added reconnect-on-disconnect handling for MySQL and an advisory lock around the baseline-migration guard, two failure modes the old system never defended against. An intermittent CI-only authentication failure went through several dead ends before the real fix: one shared database connection instead of a new one per test. Fifteen legacy upgrade directories were deleted, but only after confirming the replacement already covered their behaviour.

Phase two: making it the only path

Next, Doctrine Migrations became the only path for schema changes, with no legacy upgrade route left running beside it "just in case". Removing that fallback exposed the next set of bugs.

An upgrade flag crashed on every real run: the normal path, not an edge case. A CI database-authentication failure came from two password conventions, old and new, that decoded differently.

The most serious: installs from the old system could not mark the new baseline as applied, because the metadata table the new system expected did not exist on them yet. That blocks a real deployment, and no test environment that starts from a clean database would ever hit it. The migration strategy is now an architecture decision record that schema-change reviews are checked against.

Phase three: making sure it can't empty production

The original branch was built on an infrastructure stack that has not shipped yet, so in September I re-integrated the work directly onto mainline. That meant regenerating the migration baseline from current mainline and proving it by round trip, not inspection: rebuild a database through the migrations, then compare it table by table against a fresh reference. 286 of 286 tables matched, with no definition differing.

Before merge, four paths to data loss turned up. Each was reproduced before it was fixed:

A fifth fix was related: a script runner lost its test-database argument in a constructor change, so a command meant for the test database ran against the live one. The fix was verified by checking which database the command connected to, before and after. The pull request is in review.

What mattered

Replacing the old system with a maintained tool was the visible part of the work. The guards are what make it safe to run against production. The baseline refuses a populated database. A failed check stops instead of passing. The test-database flag cannot land in the wrong parameter. A half-finished upgrade cannot be marked done.

None of this is exotic. It is what happens when code that never had to be careful is replaced by code that must be. The answer was the same each time: reproduce the failure first, then fix it, and prove the result by rebuilding rather than reading.

Doctrine Migrations Release Engineering CI/CD Database