For over a decade, ARES has been the system automotive damage operations run on. Workshops, experts, insurers and integrations all depend on it — every day, all day. Which is exactly what makes replacing it hard: there is no maintenance window. You can’t tell an entire industry to pause while you migrate.
This is the story of how we re-platformed a 15,600-commit CodeIgniter-era monolith into a 50-module Laravel modular monolith on a completely new normalized PostgreSQL schema, without the legacy system ever going down.
The problem with the old schema
A schema that grows organically for ten years accumulates debt in ways code doesn’t. Columns reused for different meanings. Duplicate sources of truth. Table families that made sense for the business of 2015 but fight the business of today. At some point, every new feature costs more because of the foundation it stands on.
The obvious answer — “rewrite it” — is also the classic trap. Big-bang rewrites of live enterprise systems fail so often it’s a cliché. So we didn’t do a big bang. We did a strangler migration whose dual-write changed direction mid-flight.
Act one: observer-driven dual-write
The core idea: the legacy system keeps running as-is, and every write to a legacy table is mirrored — through model observers — into the new normalized schema. The new schema stays continuously up to date without the legacy code ever knowing it exists.
This gave us three things:
- Zero risk to production. If anything went wrong on the v2 side, the legacy system didn’t care.
- Real data from day one. The new schema was populated by real production traffic, not by a one-off backfill that goes stale.
- A way to verify. We could compare both sides continuously and catch mapping mistakes while they were still cheap.
Identity resolution: the assumption that collapsed
The hardest part of running two schemas at once isn’t writing to both — it’s agreeing on what things are. Everyone assumed the new file id and the legacy reference number were “basically the same number”. Then we sampled 55 production files: 54 of them differed, and the two id bands overlapped — a value could be a valid id in both spaces, pointing at different records. Silent data corruption waiting to happen.
The fix was an eight-phase wire contract program: every external API id space — vehicle taxonomy, provinces, policy types, branches, document types, campaigns, invoices — moved from legacy references to v2 ids, with dual-read mappers rolled out simultaneously across three clients (the React SPA, the Flutter tablet and the mobile app), so old and new payloads could coexist during each phase.
Strangling table families — a concrete one
With dual-write keeping the schemas in sync, we retired legacy table families one by one: rewrite every read path, rewrite every write path, keep the downstream feeds alive, then drop the tables.
The labor tables were the hardest, and took five phases: first the mini-repair flow, then the damage flows, then severing the pricing dependency, then an atomic migration of the entire labor-id space, and finally a reconcile command before the DROP. The post-DROP review still surfaced nine live error paths that only production traffic could find — each one hunted down the same week. Retire families, not tables; entities travel in groups.
Act two: reversing the mirror
This is the part most migration stories skip. Mirroring legacy writes into v2 keeps v2 fresh — but v2 is still the copy. The real milestone is the flip: read paths repointed to v2 in six waves, roughly 40 endpoints rewritten v2-native out of ~70 audited, and then the mirror itself reversed direction — v2 became the authoritative system, syncing back into the legacy tables for the consumers still reading them.
In August 2026 we removed the last legacy→v2 mirror leg and deleted the feature flags. The decade-old system is still queryable — but today it’s the replica, not the source.
The supporting cast
A migration like this leans on infrastructure choices that don’t show up in demos:
- A transactional outbox with idempotency, so events published to the parts-supply platform never drift from what was committed.
- HMAC-signed messaging for service-to-service calls and third-party webhooks.
- Configurable workflow and document rule engines, so damage processes are data instead of hardcoded logic.
- Internal tooling: a Scenario Runner that fabricates end-to-end test files on demand, and a Schema Dictionary that maps v1↔v2 tables — whose introspection dropped from 94 seconds to 1.9 the day we swapped information_schema for pg_catalog.
Across the program: 347 migration files, 236 test files, 533 merged MRs, 18 production releases.
What I’d tell you if you’re facing the same thing
- Dual-write via observers is boring, unglamorous — and exactly why it works. The legacy system never has to know.
- Spend your design budget on identity resolution, and measure your assumptions. Ours died on a sample of 55 files.
- Strangle table families, not tables — and budget for the post-DROP hunt; production always knows one more read path than you do.
- The migration isn’t done when the new system is up to date. It’s done when the old one becomes the copy — plan the mirror reversal from day one.
This is a condensed version of the story — if you want to talk migrations, reach out.