— Article — № 094

094 —Migration

Laravel migration without downtime: booking engine rebuild

A 14-year-old custom PHP booking engine, a calendar that processed a reservation every 90 seconds, and a Laravel rebuild that had to land without a single missed booking.

Overhead still life of inked migration plan, Gantt strip, manila folder, brass plate, wax seal on linen.
Hero · staged still№ 094

The Loom landed in our inbox at 23:41 on a Wednesday. A Dutch agency we work with had taken on an emergency rescue, and the brief was tight: a 14-year-old custom-PHP booking engine, written in 2012 by a developer who had since moved to Berlin and stopped answering email. The end client wanted Laravel. The calendar could not go offline for more than 60 seconds. PHP 8.2 end-of-life was 11 months out.

The recording showed the codebase. booking.php at 4,800 lines. A single functions.php at 11,000 lines. A config.php with the database credentials in clear text three directories above the docroot. The session store was a MySQL table called sessions_NEW2 (the NEW2 was from 2017). The booking calendar was rendered by a 600-line switch statement.

What follows is how we got it onto Laravel 11 across eight weeks of dual-running, without losing a single reservation, and what we would do differently if we ran the Laravel migration again.

The shape we inherited

Before any Laravel migration, we mapped the surface. Not in a Confluence doc, in a single text file on the lead developer's desktop, because the codebase moved faster than the doc would.

The engine had three layers, none of them clean:

  • A public booking widget. One PHP file, jQuery 1.7, a tangle of inline CSS.
  • An admin panel at /beheer/ behind HTTP Basic auth, with its own session handler and its own copy of the calendar code.
  • A REST-ish endpoint at /api/v2.php used by three Android tablets at the front desk. The tablets ran a WebView app last updated in 2018. Nobody had the source.

The MySQL schema was 47 tables. Sixteen of them were unused. Six of them held booking data: bookings, booking_lines, booking_slots, booking_holds, booking_history, and a table called booking_temp that turned out to be load-bearing. Removing it broke the seat-hold logic, because a stored procedure from 2015 used it as a scratch space during collision checks.

The first decision was to leave the schema alone for the first cutover. Laravel would read and write the same tables the legacy code wrote to. Schema changes would come after the rebuild was stable.

The constraint that wouldn't move

The calendar ran 24/7. Reservations came in at all hours from three feeder channels. The client's hard limit was a 60-second window for the cutover itself, and zero data loss across the entire Laravel migration. No "we lost the last hour, please rebook." Their support team was four people and they were already saturated.

That ruled out the cleanest approach (export, transform, import, swap DNS, hope). We needed both stacks running side by side, writing to the same data, until we could prove the new one was correct under live load.

The pattern is well-known: the strangler fig. Martin Fowler's framing is the one we kept coming back to. Route a thin slice of traffic to the new stack. Watch it. Route more. Eventually the old codebase is dead branches you can prune.

The twist for this engine was that the seam wasn't in HTTP, it was in the database. The booking widget, the admin panel, and the tablet API all wrote to the same six tables. If we forked the writes, we would fork the truth. So we kept one writer at a time per row, and we used MySQL to keep both stacks in sync until we were ready to flip.

Routing the seam at nginx

We put nginx in front of the existing Apache. The legacy code stayed on Apache; Laravel ran on PHP-FPM 8.3 behind nginx on a second box. The routing rule for the Laravel migration was a single map block:

map $request_uri $backend {
    default            apache_legacy;
    ~^/api/v3/         laravel_new;
    ~^/beheer/v2/      laravel_new;
    ~^/widget/v2/      laravel_new;
}

server {
    listen 443 ssl;
    server_name booking.example.nl;

    location / {
        proxy_pass http://$backend;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $remote_addr;
    }
}

New endpoints lived under versioned prefixes. Old endpoints kept their paths. The tablet API was the hardest case because the WebView clients had baked-in URLs we couldn't change. We solved that by writing a Laravel route that matched the legacy path exactly, with a small shim that translated the old payload shape (XML, snake_case fields, an MD5 token instead of an API key) into a modern request before the controller saw it.

Dual writes via MySQL triggers

For the first six weeks, every booking written by the legacy code had to appear in the Laravel-readable shape, and every booking written by Laravel had to appear in the legacy shape. We didn't trust application-layer dual writes. Too many places to forget.

Instead, we used MySQL triggers. The legacy schema stayed authoritative. Laravel wrote to a parallel set of tables (bookings_v2, etc.). A trigger on each table mirrored the row across.

DELIMITER $

CREATE TRIGGER bookings_to_v2
AFTER INSERT ON bookings
FOR EACH ROW
BEGIN
    INSERT INTO bookings_v2 (
        id, reference, customer_id, slot_id,
        status, total_cents, created_at, source
    ) VALUES (
        NEW.id, NEW.reference, NEW.customer_id, NEW.slot_id,
        NEW.status, ROUND(NEW.total * 100), NEW.created_at, 'legacy'
    )
    ON DUPLICATE KEY UPDATE
        status = VALUES(status),
        total_cents = VALUES(total_cents);
END$

DELIMITER ;

The ON DUPLICATE KEY UPDATE made the trigger idempotent. If Laravel wrote first and the legacy code wrote second (which happened during the overlap window), the row was reconciled rather than duplicated. The MySQL trigger docs have the full grammar. The gotcha we hit was that triggers do not fire on REPLACE INTO statements the way you would expect, and the legacy admin panel used REPLACE in two places. We patched those two queries to use INSERT … ON DUPLICATE KEY UPDATE instead, then verified the trigger fired on each.

The collision problem

The seat-hold logic was the riskiest piece of the Laravel migration. Two customers landing on the same time slot at the same second had to see one success and one "sorry, just sold." The legacy code used a stored procedure with a row-level lock on booking_temp. Laravel didn't know about that procedure.

For the overlap period, we didn't try to be clever. Both stacks called the same stored procedure. Laravel's reservation controller ended with a raw DB::statement('CALL hold_slot(?, ?, ?)'). Ugly, but correct. The procedure stayed authoritative until the cutover, at which point we rewrote it as a Laravel transaction with SELECT … FOR UPDATE and retired the procedure.

We caught one collision bug during this phase. A race condition where the tablet API and the public widget held the same slot for 800ms before one was rejected. It had been latent in the legacy code for years. The agency's lead developer noticed it because Laravel's structured logs surfaced it. The legacy code had been silently overwriting the loser with an empty row, and the customer-facing error message read "er ging iets mis," which had been ignored as noise for at least a year.

Cutover at 03:47

The actual switch happened on a Tuesday morning. The lowest-traffic window for this client was Tuesday between 03:30 and 04:30 Amsterdam time. Two bookings on average during that hour.

The sequence was four steps and took 38 seconds:

  1. Flip a feature flag in the legacy code that put the booking widget into read-only mode (calendar visible, form button disabled, banner reading "Even geduld, we werken aan iets moois").
  2. Wait for in-flight requests to drain. We watched SHOW PROCESSLIST in one terminal and the nginx access log in another. Eight seconds to fully drain.
  3. Update the nginx map block so the default became laravel_new instead of apache_legacy. Reload nginx with nginx -s reload. Zero dropped connections, because nginx hot-reloads gracefully.
  4. Remove the read-only banner in Laravel's config and clear the cache.

Total user-facing downtime: 38 seconds, all of it during the drain. No bookings were attempted in that window. We had a rollback script ready that flipped the map block back and re-enabled the legacy code, but we never ran it.

The first booking through the new stack came in at 04:11. The tablet at the front desk had been on standby, and the morning shift's first walk-in booked a 09:00 slot. It wrote to bookings_v2, and the trigger mirrored it back to bookings so the legacy admin panel (which we hadn't migrated yet) still saw it. We kept the trigger running for another five weeks, then dropped it after the admin panel rebuild went live and the Laravel migration was complete.

What we would do differently

Three things.

First, we would write trigger tests before the triggers. We tested the mirroring by hand, and we missed the REPLACE INTO case on the first pass. A 30-line PHP script that inserts, updates, replaces, and deletes into both tables and asserts equality would have caught it in five minutes.

Second, we would snapshot more aggressively. We took a database dump at the start of every overlap-period week. That was right. What we did not do was snapshot the Apache document root, and we paid for it when a junior dev on the agency's side renamed a file in the legacy code mid-migration to "tidy up." The rename broke the booking confirmation email for six hours. A daily tar -czf of the docroot would have made the rollback trivial.

Third, we would shorten the overlap. Six weeks of dual-writing was too long. The agency was paying attention for the first two weeks, going through the motions for the next two, and skimming for the last two. The bugs we found in week six were bugs we should have looked for in week two. Four weeks would have been enough.

The smallest version of this

If you are staring down a similar Laravel migration, the first step is not to write code. It is to put one MySQL trigger on your most important table and watch what your application does to it for a week. Not a mirror trigger. Just an audit trigger that logs every INSERT, UPDATE, and DELETE with the connection ID and the originating statement. You will learn things about your codebase. Then plan the migration.

When we built Pier we ran into this exact pattern, and the way we handled the dual-truth window was to give every file and database row a version history entry that survives the cutover. The MySQL editor was built to make those triggers visible and editable alongside the rows they touch.

The smallest thing you can do today: open one of your noisiest tables and write a one-line audit trigger. You will know more about your migration in 24 hours than a week of code reading would tell you.

— Questions —

Can you really cut over a live booking system in under a minute?

Yes if you stage it. The legacy code goes read-only first, in-flight writes drain, then you flip the routing layer. Most of the time is the drain, not the flip.

Why MySQL triggers instead of dual writes in PHP?

Application-layer dual writes need every code path to remember. A trigger fires for every write, including cron jobs, raw SQL in legacy scripts, and manual fixes. Fewer places to forget.

How long should the overlap window last?

Long enough to see one full business cycle (a week for most consumer apps, a month for B2B with longer billing cycles) and short enough that nobody stops paying attention. Four weeks is usually right.

What if the legacy code uses stored procedures Laravel doesn't know about?

Call them from Laravel as raw SQL during the overlap. Migrate the logic into application code only after cutover, when you can change one writer at a time without coordination.