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 25-module Laravel monolith onto 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 with dual-write.
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 across two schemas
The hardest part of running two schemas at once isn’t writing to both — it’s agreeing on what things are. The same customer, vehicle or damage file exists in both worlds with different keys and different shapes.
We designed an identity-resolution contract shared by all ~40 v2 API endpoints and both frontend clients (React for web, Flutter for mobile). Every client resolves entities through the same contract, which eliminated an entire class of cross-schema failures — the kind where two screens disagree about which record they’re talking about.
Strangling the legacy tables
With dual-write keeping the schemas in sync, we retired legacy table families one by one:
- Rewrite every read path of a table family to the new schema.
- Rewrite every write path.
- Keep legacy integration feeds alive for downstream consumers.
- Drop the legacy tables.
Each family was its own contained migration with its own verification. No single step was big enough to be scary.
The supporting cast
A migration like this leans on infrastructure choices that don’t show up in demos:
- A transactional outbox so events published to other systems never drift from what was actually committed.
- An HMAC-signed messaging bridge for third-party integrations.
- Configurable workflow and document rule engines so damage and mini-repair processes could be modeled as data instead of hardcoded logic.
And because performance debt was part of the reason for the migration: moving hot paths onto the new schema (and Laravel Octane) took one core admin page from 94 seconds to under 3 seconds.
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. It’s where cross-schema bugs are born.
- Strangle table families, not tables. Entities travel in groups.
- Keep the legacy feeds alive longer than you think you need to. Downstream consumers move slower than you do.
This is a condensed version of the story — if you want to talk migrations, reach out.