Back

Re-platforming a 25-module Laravel monolith with zero downtime | Veli Kaan Çetinel

· 3 min read

Laravel PostgreSQL Migration Architecture

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:

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:

  1. Rewrite every read path of a table family to the new schema.
  2. Rewrite every write path.
  3. Keep legacy integration feeds alive for downstream consumers.
  4. 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:

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

This is a condensed version of the story — if you want to talk migrations, reach out.