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:
- The baseline script starts with
DROP TABLE IF EXISTSfor every table. On an empty database that is correct. On an existing one, the documented migrate command would have emptied it. The baseline now refuses to run if any table exists. - A failed "which tables exist?" query was read as "no tables", which switched that guard off. It now aborts.
- Merging mainline brought 27 conflicts and three breaks that produced no conflict at all. The worst: two branches disagreed on a function's parameter order, so an argument meaning "use the test database" landed in "skip the database", and the test suite booted without one. Every call site now uses named arguments.
- A retry after a half-finished baseline checked one table out of 286 and marked the job done. It now checks the full table set and refuses a partial schema.
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.