PHP
Legacy PHP to Laravel 12: a dual-write cutover playbook
How we moved a Zwolle distributor's 17-year-old PHP 7.0 portal onto Laravel 12 in seven weeks, with 28 SOAP-bound EDI feeds answering throughout.

The office is in a low brick building off the IJsselallee in Zwolle, a five-minute drive from the trainline. On the Tuesday we walked in, the IT lead had a printed list of 312 order acknowledgements stuck in the outbound EDI queue and a coffee that had gone cold an hour earlier. The dealer-management portal the company had been running since 2009 had refused to restart cleanly after the previous night's MySQL patch. The PHP version was 7.0. The database was MySQL 5.6. Twenty-eight live EDI feeds from Claas and Kubota dealers were quietly piling up against an Apache process that would not reload.
That was the moment the rebuild stopped being optional.
What follows is the playbook we used to move that portal onto Laravel 12, Postgres 16 and Inertia.js across seven weeks, while keeping the SOAP endpoints answering on the same URLs the whole time. It is not a generic Laravel-migration post. It is what worked on a distributor with 41 staff, no downtime tolerance between 07:00 and 10:00 Amsterdam time, and two manufacturers that audit their EDI partners.
The constraints we walked into
PHP 7.0 has been unsupported since 10 December 2018, according to the PHP project's own supported-versions page. MySQL 5.6 reached end of life in February 2021. Both still ran. Neither had received a security patch in years. The portal had two ISO 27001-curious customers and an insurer asking pointed questions in the next renewal cycle.
On top of that:
- 28 active EDI feeds, each on a fixed SOAP endpoint URL the manufacturers had baked into their dealer systems.
- 9 daily power users in operations and parts, plus around 30 occasional logins from sales and service.
- A morning window from 07:00 to 10:00 when stock allocations had to clear, and during which a five-minute outage meant a stack of angry phone calls from real dealers in real time.
- 47 MySQL stored procedures, 11 views, 3 cron files that called each other in a dependency chain nobody had drawn since 2014.
The customer wanted Laravel because their nearest junior developer already knew it. They wanted Inertia because they had seen a screen recording and liked the speed. We agreed with both. We added Postgres because Postgres 16 handles JSON, partial indexes and stricter typing in ways that map cleanly onto the kind of code Laravel encourages.
Reading the system before we touched anything
The first two weeks were read-only. We did not write Laravel code. We did not run a migration. We mapped what was there.
The deliverable from that fortnight was a single Markdown file with seven sections: SOAP methods (89 of them across two WSDLs), database objects (tables, views, procedures, triggers), cron entries (14 across two servers), shell scripts that touched the database, third-party integrations (a German freight API and a Dutch accounting export), background workers (none, it was all cron), and a list of every place where the codebase wrote a date in a format that was not ISO 8601.
That last one mattered more than it sounds. Half the SOAP responses were emitting dates as d-m-Y. Two of the EDI partners parsed them strictly. Any rewrite had to preserve that formatting at the byte level on the response side, even though we would internally store everything as timestamptz.
If you do not document the ugly parts of the legacy system before you write a line of new code, the new system will reinvent every old mistake it did not know to avoid. Two weeks of reading is cheap.
The seven-week dual-write architecture
A big-bang cutover was off the table. Not because of nerves, but because of audit trails. The accounting export needed an unbroken numbered sequence on outbound invoices. Two of the EDI partners required signed weekly reconciliation reports. Any cut where the new system briefly disagreed with the old would force a manual reconciliation neither side wanted to do.
So we built dual-write. Both systems run. Both accept writes. The legacy PHP system stays authoritative for reads and outbound SOAP responses until the very end. The new Laravel system shadows every write, runs every business rule independently, and surfaces any drift to a daily diff report.
The shape, simplified:
+-------------------+
HTTP / SOAP | nginx (router) |
from dealers +---------+---------+
|
+-------------------+-------------------+
| |
+---------------+ +----------------+
| Legacy PHP | dual-write events | Laravel 12 |
| + MySQL 5.6 |---------------------->| + Postgres 16 |
| (authoritative| | (shadow, then |
| until week 7)| | authoritative)|
+---------------+ +----------------+
The dual-write channel was deliberately boring. A small PHP class in the legacy codebase pushed JSON payloads onto a Redis stream after each successful transaction. A Laravel queue worker consumed the stream, replayed the write through Eloquent, and wrote a row into a sync_events table with the result. A nightly job compared row counts and key-column hashes between MySQL and Postgres and wrote a one-line summary to a Slack channel the customer's IT lead read every morning over coffee.
Keeping the SOAP endpoints answering
This was the part that kept people awake. Claas and Kubota dealer systems were not going to update their endpoint URLs because a Dutch distributor was rebuilding a portal. The endpoints had to stay live, on the same hostnames, with the same WSDLs, byte-for-byte equivalent where the partners cared.
We did three things.
First, we extracted the SOAP layer from the legacy codebase into a thin shim in front of it. nginx routed /soap/* to a dedicated PHP-FPM pool. That gave us a clean boundary to swap later, without touching the rest of the legacy code.
Second, we built a parallel SOAP server in Laravel using PHP's built-in SoapServer class, bound to a controller. Laravel does not advertise itself as a SOAP framework, and that is fine, because PHP itself ships one. The minimum looks like this:
// routes/soap.php
Route::any('/soap/dealer/v1', function (Request $request) {
$wsdl = storage_path('soap/dealer-v1.wsdl');
$server = new \SoapServer($wsdl, [
'cache_wsdl' => WSDL_CACHE_NONE,
'features' => SOAP_SINGLE_ELEMENT_ARRAYS,
]);
$server->setObject(app(DealerSoapHandler::class));
ob_start();
$server->handle($request->getContent());
$response = ob_get_clean();
return response($response, 200)
->header('Content-Type', 'text/xml; charset=utf-8');
});
Third, we ran the legacy SOAP server and the Laravel SOAP server side by side for three weeks. Every inbound request was answered by the legacy server. The same payload was forwarded asynchronously to the Laravel server, whose response was diffed against the legacy response and logged. By week five, the diff was empty for 27 of the 28 feeds. The 28th was a Kubota partner whose system sent a malformed namespace declaration the legacy code silently ignored. We taught Laravel to ignore the same malformation, documented it, and moved on.
A rewrite that has to preserve a public API is not a rewrite. It is a re-implementation behind a frozen contract. Treat the contract as a test suite, not a spec.
Moving MySQL 5.6 to Postgres 16
The schema move had three categories of work. Mechanical, semantic, and procedural.
Mechanical was the cheapest. pgloader handled the bulk of the table moves in a single run, with a config file that translated TINYINT(1) to boolean, MySQL ENUM columns to Postgres CHECK constraints, and the various 0000-00-00 sentinel dates to NULL. That last translation alone touched 18,400 rows in the order history.
Semantic was harder. MySQL 5.6 with the default SQL mode tolerated a lot. Implicit string-to-integer casts, dates that did not exist, group-by columns that were not in the select. Postgres tolerates none of it. We ran the test suite against Postgres in CI from week two onward, which surfaced a long tail of small bugs. Most of them were one-line fixes. A few exposed real logic errors that had been silently producing wrong totals in the legacy system for years.
Procedural was the most expensive. The 47 stored procedures came out of the database and into Laravel as service classes, one per domain concept (dealer onboarding, stock allocation, EDI dispatch, invoice numbering, and so on). Each one got tests. A few of them, after being read in daylight for the first time since 2011, turned out to do nothing the application code did not already do twice elsewhere. We deleted those.
The seven-week schedule
The calendar was tight but not heroic.
- Week 1. Read the system. Produce the Markdown file. Stand up the empty Laravel app behind a wildcard subdomain with no traffic.
- Week 2. Schema translation with pgloader. CI green against Postgres. Authentication ported.
- Week 3. Dual-write turned on for the three lowest-risk tables (dealers, products, contacts). Daily drift report goes to Slack.
- Week 4. Dual-write extended to orders, allocations, and the invoice sequence. Inertia-driven staff portal screens replace the oldest legacy pages.
- Week 5. Laravel SOAP server stood up. Shadow-mode response diffing on every inbound EDI call. Drift goes from 38 percent to under 1 percent.
- Week 6. Read traffic for the staff portal moves to Laravel. Legacy PHP stops serving HTML. SOAP still on legacy.
- Week 7. nginx flips the SOAP route to Laravel during the Saturday maintenance window. Dual-write reverses direction for 72 hours as insurance. Legacy MySQL goes read-only on the Tuesday after.
The rollback plan you hope to never run
A cutover without a rollback is not a cutover, it is a leap. We wrote the rollback first and tested it twice before we touched production.
Three components. nginx had a single map directive whose value flipped between legacy and laravel, so reverting traffic was one config reload. The dual-write stream stayed in reverse-flow mode for 72 hours after cutover, so Laravel writes were mirrored back into MySQL and kept it warm. DNS TTLs on the SOAP hostnames were lowered to 60 seconds a full week ahead of the window. The whole rollback was one shell script with a confirmation prompt, owned by the customer's IT lead. We made sure he ran it once in staging on the Thursday before the cutover, just to feel the keys under his fingers.
We did not need it. That is the only reason to write it.
What we would do differently
Two things, with hindsight.
We spent too long on the staff portal UI in week 4. Inertia.js makes it tempting to redesign as you migrate, and we did. The customer was delighted with the new screens, but we lost three days that should have gone to the SOAP shim. If we ran this again, the new UI would be a separate phase after cutover, not during it.
And we underestimated how much value the daily drift report would deliver as a permanent artefact. Six months after go-live, the customer still reads it every morning. It catches accounting export bugs before the accountant does. A small Slack-bound script, written in week three to keep us honest during the migration, has quietly become part of how the company runs. We will build one into every migration from now on.
When we rebuilt the dealer portal for this Zwolle distributor, the thing we ran into was the byte-level fragility of the inbound EDI contracts. We solved it with a three-week shadow-diff between the legacy and Laravel SOAP servers, treating the partners' actual payloads as the only spec that mattered. That kind of legacy-stack migration is most of what we do for distributors and operators with a system old enough to vote.
If you are sitting on a PHP 7 portal with live integrations and a calendar that does not include a downtime window, the cheapest next step is the two-week read. Map your SOAP methods, your stored procedures, and your date formats before you choose a framework. The rewrite gets easier from there.
Key takeaway
A rewrite that has to preserve a public API is not a rewrite. It is a re-implementation behind a frozen contract. Treat the contract as a test suite, not a spec.
FAQ
Why dual-write instead of a one-night big-bang cutover?
Because the portal had an unbroken invoice sequence, signed EDI reconciliation reports, and a three-hour morning window with zero tolerance for outage. Dual-write let us prove parity for weeks before touching live traffic.
Why Postgres if the team already knew MySQL?
Postgres 16's stricter typing, JSON support, and partial indexes map onto Laravel's Eloquent and migrations more cleanly than MySQL 5.6, and CI on Postgres surfaced years-old logic bugs the lenient MySQL mode had hidden.
Can Laravel really host SOAP endpoints for EDI partners?
Yes. PHP ships SoapServer in core. Bind it to a Laravel route, point it at the existing WSDL, and shadow-diff every response against the legacy server until drift is zero. The framework does not need to know it is SOAP.
How long was the actual downtime during cutover?
Around four minutes during a Saturday maintenance window for the nginx route flip on SOAP traffic. Staff portal read traffic moved in week 6 with zero perceptible downtime, behind a feature flag.