Migrating a Mobile App's Backend Without Breaking the Client in the Field

A logistics client needed to migrate from a legacy PHP monolith to a Laravel-based service architecture while their driver-facing mobile app stayed in continuous use across thousands of devices, many running app versions six months to two years old because drivers don't update software on a device they rely on for income unless forced to. Any backend change had to work correctly against every client version already installed, not just the latest one.
We versioned the API explicitly, using /v1/ and /v2/ path prefixes, rather than trying to make one endpoint serve all client versions through conditional logic, which becomes unmanageable within a few changes. Each old API version kept its own thin adapter layer translating requests into calls against the new backend services, so old clients kept working unmodified while all new development happened against the new v2 contract.
Field-testing the migration safely meant staged traffic cutover rather than a single switchover. We ran the new backend in parallel behind a feature flag at the load balancer level, mirroring a percentage of real production traffic to the new services and comparing responses against the legacy system's output for the same requests, which caught three response-shape mismatches, including a null-vs-missing-field discrepancy that crashed old app versions' JSON parsing, before they ever reached a real driver's app.
Database migration ran on its own track from the API cutover, deliberately decoupled. We kept the legacy database as the system of record with a change-data-capture pipeline, Debezium reading MySQL binlogs, streaming updates into the new service's data stores, rather than doing a hard cutover to new databases at the same moment as the API migration, which meant a rollback of the API layer alone stayed possible for the entire multi-month transition window.
The version adapter layers for the oldest client versions stayed in place for fourteen months post-migration, tracked against real usage data showing what percentage of active drivers were still on old app builds, and only got retired once that number dropped below 1%, with direct outreach to the remaining stragglers beforehand. The instinct to just delete the old code the day the new backend goes live is exactly the instinct that strands real users in the field, and it's the mistake we actively design against on every backend migration since.

