Upgrading Laravel 5.5 to 13 without changing a single API response
Description and use
Migrated a Laravel 5.5 API to 13 without altering any API response for an external frontend. Employed DriftGate to capture and compare all 622 requests after each upgrade step, identified and fixed regressions, reduced capture time, and ensured byte‑for‑byte compatibility.
Problem
A Laravel 5.5 API had to be upgraded to Laravel 13 and PHP 8.3 while keeping every API response byte‑for‑byte unchanged for an external frontend.
Outcome
The migration was completed in about 15 steps, covering 622 requests. DriftGate detected and fixed more than ten regressions that feature tests missed, reduced capture time from 4 minutes to 148.9 s, and ensured every response remained byte‑for‑byte identical.
Workflow
- Upgrade to next Laravel version (5.6, 5.7 … 13) one step per branch
- Run
driftgate runto capture baseline and compare responses - Fix drifted differences (e.g., cast "60" to int internally, adjust serializer settings)
- Accept intentional changes with
driftgate acceptand log reason and ticket - Repeat until all 622 requests are identical
- Merge pull request
- Run final capture to confirm no drift
Evidence
The API ran on Laravel 5.5 and PHP 7.3. It had to end up on Laravel 13 and PHP 8.3, in about fifteen steps, and the other company's frontend had to keep working without a single change on their side.
Feature tests rarely catch these. They assert what the developer thought mattered when the test was written, and they usually compare by meaning: assertJson(['id' => 42]) is happy with both 42 and "42". A JavaScript frontend written by another team is not always that forgiving.
The login response includes the token lifetime, which comes from .env, so it was the string "60". Carbon 3 accepts only numbers in addMinutes(), and every login started failing. The obvious fix, casting the value to an integer, made login work again, but the response now said 60 instead of "60".
What we needed was simpler and stricter: record what every route returns today, and after every upgrade step prove that it still returns exactly that, on the same data.
The tool reads the application's own route list (for Laravel, route:list) and proposes a request for each route. driftgate coverage fails while a route has neither a request nor an exclusion with a reason, so nothing slips through because nobody thought of it.
By the end, the acceptance log explains every difference between the old and the new API, and nothing else changed.
Two production Laravel APIs: one from 5.5 to 13 in about 15 steps (622 requests), one from 6 to 13 in about 12 steps (494 requests). Each step was closed only at 622/622 and 494/494 identical responses.
- Record date: 2026-11-03T11:13:58.866Z