Relev Works
Migrating a Live Platform from Core PHP to Laravel Without Downtime

Established web product

February 2026

6 min read

Backend Engineer

Client confidential

Migrating a Live Platform from Core PHP to Laravel Without Downtime

Rewrote an established web product from a custom core PHP architecture to Laravel — with a backward-compatible database migration, dry-run validation, and a one-hour maintenance window cutover.

0

Data loss

~1 hr

Cutover window

Preserved

Rollback path

Some clients prefer not to be named publicly. We honor that. These write-ups describe the problem, our approach, and the outcome. Never confidential details.

Written by

Relev Works EngineeringBackend Engineer

At a glance

Problem

A mature product on custom core PHP could no longer ship features at the pace the business required. Background work relied on brittle custom workers. Every new capability meant rebuilding infrastructure Laravel provides natively — with no tolerance for data loss or a long outage during cutover.

Approach

Full Laravel rewrite in parallel while core PHP stayed live. Legacy data tagged rather than deleted. Migration script with --dry-run transformed old schema into a fresh target database inside transactions, validated dozens of times before production. One-hour cutover: maintenance mode, final migration, smoke tests, lift.

Result

Clean cutover within the one-hour window. Zero data loss. Rollback remained available throughout. Development velocity increased — work that previously required weeks of boilerplate shipped in days against the Laravel ecosystem.

Write-up

Context

The original codebase was structured — not spaghetti. Services were separate from controllers. Business logic was organised. But core PHP at this scale meant:

  • No native queue system. Background jobs required custom workers that were brittle and hard to extend.
  • Every new feature required building infrastructure Laravel provides out of the box: rate limiting, job retries, scheduled tasks, event broadcasting.
  • The development surface area was large. Adding a feature meant touching more files and writing more boilerplate than the feature itself warranted.

The product had grown past the point where this was acceptable. Real accounts, real transactions, real uptime expectations. The codebase needed to match the product's maturity.

Problem

A full rewrite is the right call in some situations. It was the right call here. The business logic was well-understood — services mapped directly to their Laravel equivalents. The risk was not in the rewrite itself.

The risk was the database.

The original schema had evolved organically over years. Column names, data types, and relational assumptions had accumulated that did not map cleanly to what a greenfield Laravel application would produce. Three categories of problem:

  1. Schema differences. Column naming conventions, nullable fields, and index structures differed between old and new.
  2. Legacy data. Rows existed that reflected old product decisions — statuses, types, and flags that no longer had a corresponding concept in the new system.
  3. No room for error. A significant user base and years of transactional history. A failed migration mid-cutover with no clean rollback path would be a serious incident.

The new application could not simply point at the old database and run.

Constraints

  • The product had to remain live until the cutover window opened.
  • All historical data had to survive — no purging of legacy records.
  • The migration script had to be safe to run repeatedly in testing and exactly once in production.
  • Rollback had to be possible if the cutover failed.
  • Maintenance window had to be short. One hour was the target.

Solution

Phase 1 — Full rewrite in parallel

The Laravel application was built in full while the core PHP system remained live. Business logic was ported service by service. Because the original architecture was disciplined, this was largely a translation exercise — core PHP service methods became Laravel service classes, with enhancements where the Laravel ecosystem made them straightforward (queued jobs, scheduled commands, event listeners).

Features that were hard to build in core PHP were rebuilt properly in Laravel rather than ported directly. The migration was an opportunity, not just a translation.

Legacy concepts — data states, flags, and statuses that no longer reflected the product — were tagged explicitly in the new schema rather than silently dropped. Anything not relevant to current features was marked legacy_ and preserved for audit purposes.

Phase 2 — Database migration script with dry run

The core engineering problem was the schema delta. A migration script was written that:

  1. Read every table and column in the old database
  2. Compared structure and data against the new schema's expectations
  3. Transformed data where conventions had changed (column renames, type casting, nullable normalisation)
  4. Flagged legacy rows for preservation under legacy_ tagging rather than deletion

The script ran inside a database transaction with a --dry-run flag:

php artisan migrate:legacy --dry-run

In dry-run mode, the script executed all transformations, logged every change it would make, and rolled back without committing. This made it safe to run dozens of times in development and staging to verify correctness before touching production.

The output of every dry run was a structured log: tables processed, rows transformed, legacy rows tagged, anomalies flagged. Each run was reviewed before the next one. By the time production cutover arrived, the script had been run enough times that the output was predictable to the line.

Phase 3 — New database, not in-place migration

Rather than migrating the existing database in place, a new database was provisioned with the Laravel schema applied fresh. The migration script read from the old database and wrote to the new one. This meant:

  • The old database remained untouched and live until cutover
  • Rollback during the maintenance window was trivial — point back at the old database and restart the old application
  • No partial migration state was possible on the production database

Phase 4 — Cutover

During a low-traffic window:

  1. Maintenance mode enabled on the live product
  2. Final migration script run against production — old database to new
  3. Data validation checks run against the new database
  4. Laravel application deployed and pointed at the new database
  5. Smoke tests run across critical paths (auth, billing, account workflows, background jobs)
  6. Maintenance mode lifted

Total window: approximately one hour.

What made it safe

The dry-run script. Running the migration dozens of times before production meant there were no surprises. The script's behaviour in production matched its behaviour in staging exactly.

Separate target database. Migrating into a new database rather than transforming the existing one meant the old system could be restored in minutes if anything went wrong. The old database was not touched during the maintenance window.

Legacy tagging instead of deletion. Rows that didn't map to new concepts were preserved and tagged rather than dropped. No historical data was destroyed.

Known codebase. The original architecture was understood deeply. There were no hidden dependencies or undocumented behaviours to discover mid-migration.

Outcome

  • Zero data loss across the full production dataset
  • One-hour maintenance window — within target
  • Full rollback capability preserved throughout the cutover
  • Queue processing available immediately post-migration — background jobs that previously required custom brittle workers now ran on Laravel's native queue system
  • Faster response times on core product paths
  • Development velocity increased — features that previously required weeks of boilerplate could be built in days against the Laravel ecosystem

Lessons learned

Dry-run first, always. By the time the production script ran, it was boring. That is exactly what you want from a one-shot migration on a live system.

Platform migration
  • A disciplined original codebase makes migration tractable. The rewrite was manageable because the original code had clear separation of concerns. A genuinely tangled codebase would have required a different strategy entirely.

  • Dry-run first, always. The investment in a dry-run flag paid for itself immediately. By the time the production script ran, it was boring. That is exactly what you want from a one-shot migration on a live system.

  • Migrate into a clean target, not in place. In-place schema migrations on production databases carrying years of real data are high-risk. A separate target database with a clean schema reduces that risk to near zero and keeps rollback simple.

  • Tag legacy data, don't delete it. Deleting rows that no longer fit the new model is tempting and almost always wrong. Tag them, preserve them, and revisit later with full context. Data you deleted is data you cannot recover.

Technical appendix

Technical problem

The legacy schema had evolved for years and did not map cleanly to a greenfield Laravel application. Column conventions, nullable fields, and legacy flags accumulated. A failed mid-cutover migration with no rollback path would be a serious production incident under real traffic.

Technical approach

Full Laravel rewrite in parallel while core PHP stayed live. Legacy data tagged rather than deleted. Migration script with --dry-run transformed old schema into a fresh target database inside transactions, validated dozens of times before production. One-hour cutover: maintenance mode, final migration, smoke tests, lift.

Technical outcome

Separate target database kept the legacy system restorable in minutes. Dry-run logs made production behaviour predictable. Queue processing moved to Laravel's native system immediately. Core request paths improved without changing user-facing contracts.