PHP
Legacy PHP to Laravel 12: a six-week dealer-portal cutover
A 16-year-old PHP 5.6 dealer portal in Venlo had to land on Laravel 12 in six weeks without dropping a single AS2 message. Here is the playbook we used.

On a Monday in March, the operations lead at a 38-person automotive-parts distributor in Venlo pulled up a spreadsheet. Forty-one rows. Each row was an EDI partner: an OEM, a parts wholesaler, a national chain in Germany or Belgium. Each one sent orders, shipping notices, and invoices over AS2 to a single URL that had been answering for sixteen years. The PHP version behind that URL was 5.6. The MySQL version was 5.5. Neither had received a security patch since 2018.
The deadline she had been handed was six weeks. The portal needed to be on Laravel 12 and Postgres before the end of the quarter, because the cyber-insurance renewal had a clause about unsupported runtimes. Forty-one partners could not be asked to repoint their endpoints. The URLs had to keep answering.
This is the playbook we ran.
Mapping the actual surface area before touching code
Two days of reading, no commits. We walked the old codebase from the routes file outward. Every endpoint got a row in a spreadsheet: HTTP method, request body shape, response body shape, what tables it read, what tables it wrote, what side effects it triggered.
The dealer-facing UI was 47 routes. The internal admin was 23. The AS2 receiver was one endpoint that wrapped an entire EDIFACT decoder. The cron jobs added another 14 entry points (invoice generation, MDN retries, partner certificate refresh).
What the exercise surfaced: 19 routes were dead. No partner had hit them in twelve months. The 2014 reporting module had been replaced by a Power BI dashboard in 2022, but nobody had told the developers. Cutting those routes saved a week of work on day three.
A migration that starts with "we will rewrite the system" loses. One that starts with "we will rewrite the 64 endpoints that are actually used" ships.
Standing up Laravel 12 next door, not on top
We provisioned the new stack on a separate host. Laravel 12, PHP 8.4, Postgres 16, Redis 7. Same VLAN as the old box. No DNS changes. No reverse proxy yet.
The first thing on the new box was not a controller. It was a read-only replica of the old MySQL database, streamed via mydumper into a staging Postgres instance and refreshed nightly. That gave us a place to validate schema translations without touching production data.
The second thing on the new box was a single nginx vhost that answered the production hostname on a non-routable internal IP. We tested every endpoint against it from a jump host. When a route returned the wrong shape, we knew before any partner saw it.
Holding the AS2 endpoints steady through the swap
AS2 is RFC 4130. The receiver decrypts a signed S/MIME payload, verifies the partner's certificate, writes the EDIFACT message to a queue, and sends back a signed MDN receipt. Forty-one partners had our certificate's fingerprint pinned in their AS2 software. Some ran Mendelson. Some ran IBM Sterling. One ran a custom Java daemon written in 2009.
We had three options. Option A: build a fresh AS2 receiver in Laravel. Cleanest, but it meant either reissuing certificates (every partner change-controls those) or moving the private key into the new app. Option B: keep the old PHP 5.6 AS2 receiver running on a hardened jump box, behind a strict allowlist, and forward decoded messages to Laravel over an internal HTTP call. Option C: drop in an off-the-shelf receiver like OpenAS2 and reroute internally.
We went with option B. The old receiver was 800 lines of PHP we already understood. It had no exposure to the internet beyond the AS2 ports. We froze its code, put it behind a WAF that only allowed traffic from the 41 partner IP ranges, and gave it one job: receive, verify, dump the decoded EDIFACT to a Redis stream that Laravel consumed.
That preserved every certificate, every URL, every MDN signature. The partners saw no change.
Never co-mingle AS2 private keys with web-app secrets. The receiver key gets its own filesystem permissions, its own backup rotation, and its own access log. Anything else and a future intern with .env access becomes a partner-trust incident.
Dual-write for six weeks, single source of truth on day 43
For the first three weeks, the old PHP app remained the write path. Laravel consumed change-data-capture from MySQL via Debezium, translated rows into the new Postgres schema, and surfaced a read-only version of the dealer UI at portal-v2.internal. Internal users compared screens side by side.
For weeks four and five, we flipped the dealer login form to write to both. Every order placed by a dealer hit the old PHP controller and a Laravel job, in parallel. A reconciler ran every fifteen minutes and compared row counts, totals, and content hashes per table.
<?php
namespace App\Listeners;
use App\Events\OrderPlaced;
use App\Services\LegacyMysqlBridge;
use Illuminate\Support\Facades\Log;
class MirrorOrderToLegacy
{
public function __construct(
private readonly LegacyMysqlBridge $legacy,
) {}
public function handle(OrderPlaced $event): void
{
try {
$this->legacy->writeOrder($event->order);
} catch (\Throwable $e) {
// The new system is canonical. The old one is a mirror.
// We log, alert, and reconcile out of band. We do not throw.
Log::channel('legacy-mirror')->error('mirror failed', [
'order_id' => $event->order->id,
'error' => $e->getMessage(),
]);
}
}
}
The reconciler caught seven discrepancies in the first week. Six were a decimal-precision difference (MySQL was DECIMAL(10,2), Postgres was NUMERIC(12,4)). One was a real bug in how we translated a partner-specific discount code. None of them would have surfaced in a test suite. The reconciler surfaced all of them by row.
In week six, the old PHP controllers became read-only. Laravel became canonical. AS2 stayed on the jump box.
Translating MySQL 5.5 to Postgres without losing data semantics
MySQL 5.5 is permissive in ways Postgres is not. Empty strings as dates. Implicit type coercion. ENUM columns that quietly accept invalid values when the SQL mode is loose. The schema had been written before strict mode existed.
We wrote a translator, not a dumper. For every table, a small Python script read the MySQL DDL, mapped types, fixed nullability, and emitted the Postgres DDL plus a data-copy SQL that included the cleanup rules. The cleanup rules were explicit, in code, reviewed.
-- old (MySQL 5.5)
-- CREATE TABLE invoices (
-- id INT AUTO_INCREMENT PRIMARY KEY,
-- issued_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
-- amount DECIMAL(10,2) NOT NULL,
-- status ENUM('draft','sent','paid','void') NOT NULL DEFAULT 'draft'
-- );
CREATE TYPE invoice_status AS ENUM ('draft', 'sent', 'paid', 'void');
CREATE TABLE invoices (
id BIGSERIAL PRIMARY KEY,
issued_at TIMESTAMPTZ NOT NULL,
amount NUMERIC(12, 4) NOT NULL,
status invoice_status NOT NULL DEFAULT 'draft',
CONSTRAINT invoices_amount_nonneg CHECK (amount >= 0)
);
-- migration step, run inside a transaction per batch of 50,000 rows
INSERT INTO pg.invoices (id, issued_at, amount, status)
SELECT
id,
CASE
WHEN issued_at = '0000-00-00 00:00:00' THEN created_at
ELSE issued_at AT TIME ZONE 'Europe/Amsterdam'
END,
amount,
status::text::invoice_status
FROM my.invoices;
The '0000-00-00 00:00:00' value was in 312 rows. None of them were real invoices. The CASE clause used the row's created_at as a fallback. We logged every fallback to a side table for the operations lead to spot-check. The Postgres documentation on data types is blunt about conversion behavior, and worth reading before any migration of this shape.
The cutover week, hour by hour
Friday evening. Dealers were notified that the portal would be in read-only mode from 19:00 to 23:00.
19:00, the old PHP app was set to read-only via a config flag. AS2 receiver stayed live; the partners do not respect maintenance windows.
19:15, final Debezium-driven sync ran. Laravel ingested the last four hours of writes.
20:30, the reconciler reported zero diffs across 247 tables.
21:00, nginx on the public load balancer was flipped. The dealer-facing hostname pointed at Laravel. The AS2 hostname kept pointing at the jump box.
21:10, smoke tests. Real dealer login. Real order. Real invoice PDF generation. Two failed PDF renders because a font file had not been copied to the new host. Fixed in nine minutes.
23:00, dealers were emailed that the portal was live. Six weeks, on the day.
The first AS2 message hit at 23:47, from a partner in Stuttgart placing a weekend order. The MDN went back signed. Nothing on the partner side changed.
What we would do differently next time
Three things.
First, the reconciler should have been the first thing built, not the third. Every hour the dual-write ran without a reconciler was an hour we were flying blind. Build the diff tool before the new app.
Second, the AS2 receiver should have been moved to its own subdomain a year earlier, while the old app was still in maintenance. Splitting the dealer-facing concern from the partner-facing concern is cheap when the system is calm. It is expensive at cutover.
Third, the Power BI integration. We discovered on day 19 that the reporting team had been writing directly to two MySQL tables via an ODBC link the developers did not know about. That broke the moment Postgres took over. We learned about it from a dashboard alert at 06:42 on the morning after cutover. Map every consumer of the database, not just every producer.
The smallest next step
When we built the dealer-portal migration for the Venlo distributor, the thing that saved us was treating AS2 as a frozen surface and the rest of the app as the migration. Keeping the partner-facing endpoints on the old runtime for six more weeks let us focus the rewrite on the parts where Laravel 12 actually helps. If you are sitting on a custom PHP portal with EDI partners hanging off it, the smallest useful thing you can do today is open a spreadsheet and list every consumer of your database (the dashboards, the scripts, the ODBC links, the analyst's Excel file with a saved connection string) before you touch a line of code. We run this exercise as the first hour of every legacy migration we take on, and the list always surfaces something the team had forgotten.
Key takeaway
Treat the partner-facing surface as frozen and migrate the rest. Dual-write with a reconciler beats a clever cutover weekend every time.
FAQ
Why dual-write for six weeks instead of a hard cutover weekend?
Dual-write surfaces translation bugs against real partner traffic before the old system is gone. A hard cutover finds the same bugs at 02:00 on a Saturday with no rollback path.
Can the old PHP 5.6 AS2 receiver stay on the jump box indefinitely?
No. It is a six-week bridge. Plan the partner-by-partner certificate migration to a modern AS2 receiver within twelve months, or you have just deferred the runtime risk, not solved it.
How do you handle MySQL ENUM values that do not exist in the Postgres ENUM type?
Catch them in the translator. Log every row whose ENUM value is unexpected, decide a default per column, and have the operations lead sign off on the mapping before the data copy runs.
Is six weeks realistic for a 16-year-old portal of this size?
Only if you cut dead routes early, freeze the partner-facing surface, and build the reconciler before the new app. Without those three, plan for twelve weeks.