← Blog

PHP

PHP 5.6 ERP rip-out: a six-week rolling cutover playbook

A 34-person plumbing-supplies wholesaler in Mechelen had a homegrown PHP 5.6 ERP running the order desk. Here is the six-week cutover that replaced it without freezing the phones.

Jacob Molkenboer· Founder · A Brand New Company· 10 Jun 2026· 11 min
Open leather ledger on ivory paper with brass relay, iron tag, green sticky note, red wax fragment, side light.

The order desk in Mechelen has two phones, three monitors, and a kettle that has been on since 7:14 in the morning. Karin, who has worked here since 2009, types order numbers into a green-on-black terminal that nobody at the company can recompile any more. The PHP 5.6 source sits on a Synology in the corner. The original developer moved to Australia in 2019. Last winter the VAT calculation rounded the wrong way for three days before anyone noticed, and the fix was a comment in Dutch that said // tijdelijk, fixen voor kerst. Christmas 2017.

This is the kind of system we get called about. Not a glamorous failure. A daily one. The owner had been quoted €380k and eight months by a Belgian SAP partner to replace it, and his counter-offer to us was: do it in six weeks, do not stop the phones, and let Karin keep her keyboard shortcuts.

What follows is the actual sequence we ran, with the calls we got right and the two we got wrong. The stack landed on Laravel 12, PostgreSQL 16, and a chat agent that handles the inbound email orders the desk used to retype by hand. The cutover was rolling, not big-bang. Nothing was rewritten that did not need to be.

Week zero: read the database, not the code

The PHP was unreadable. The MySQL 5.5 schema was not. We spent three days doing nothing but querying the live database from a read replica, mapping every table, and asking Karin and the warehouse manager what each one was actually for. Half the tables were dead. One of them, klanten_oud_2014, was being written to every night by a cron nobody remembered installing.

The deliverable from week zero was a single A3 sheet pinned above the order desk: 41 live tables, 19 dead, 6 "unsure, leave alone". No code yet. No Jira. We needed the team to trust the map before we moved anything on it. On a legacy PHP rip-out, the database is the spec. The code is just the last person's interpretation of it.

Week one: a Laravel shell that reads from the old database

We did not migrate any data in week one. We pointed a fresh Laravel 12 app at the existing MySQL 5.5 instance as a secondary connection, generated Eloquent models against the live tables, and built one screen: a read-only order lookup. Karin used it on her second monitor for two days before we let her type into it.

The trick that made this work was Laravel's multi-database support. The new app talked to the old database for reads, and to a new PostgreSQL 16 instance for anything it wrote itself. We never had to fork the data.

// config/database.php
'connections' => [
    'pgsql' => [
        'driver' => 'pgsql',
        'host' => env('DB_HOST'),
        'database' => env('DB_DATABASE'),
        // ...primary, new writes land here
    ],
    'legacy' => [
        'driver' => 'mysql',
        'host' => env('LEGACY_DB_HOST'),
        'database' => 'erp_synology',
        'options' => [
            PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES 'latin1'",
        ],
    ],
],

The latin1 line is not optional. The old database was full of mojibake, and any attempt to read it as UTF-8 turned every é in a customer's name into a question mark. We left the old database in its native encoding and converted on the way out, per column, with a known map.

Week two: the strangler around order entry

The order-entry screen was the busiest surface in the building. Forty to sixty phone orders a day, plus walk-ins. We did not touch it yet. Instead we wrote a Laravel screen that did exactly the same thing, with Karin's exact keyboard shortcuts, and put it on a second URL. Both screens wrote to the same MySQL tables, via a thin write-through layer that also mirrored the row into PostgreSQL.

This is the classic strangler fig shape, but the part that matters operationally is who flips the switch. We did not. Karin did. For two weeks she could enter any order in either screen. By the end of week three she had stopped opening the green terminal except for one specific report. That was the signal.

Warning

If you write the cutover date in the contract, you will hit it and the team will hate the new system. Let the user pick the day they stop using the old screen. They always pick sooner than you would have.

Week three: the chat agent on the inbound mailbox

Forty percent of orders came in by email, mostly from contractors with a standing account, mostly in a recognisable shape: a PDF or a list of SKUs, a delivery date, a site address. The desk used to retype these into the green terminal. We pointed an agent at the orders@ mailbox instead.

The agent does three things and only three: it parses the email plus any attached PDF, matches lines against the product catalogue with a fuzzy SKU lookup, and drafts an order in the new Laravel screen with a status of needs_review. It does not send. It does not confirm. Karin still presses the button.

// app/Agents/OrderIntake.php (the loop, stripped down)
public function handle(InboundEmail $email): DraftOrder
{
    $customer = $this->matchCustomer($email->from);
    $lines    = $this->extractLines($email->body, $email->attachments);

    $matched = collect($lines)->map(fn ($l) => [
        'raw'       => $l->text,
        'sku'       => $this->catalogue->fuzzyMatch($l->text, $customer),
        'qty'       => $l->qty,
        'confidence'=> $this->catalogue->lastScore(),
    ]);

    return DraftOrder::create([
        'customer_id' => $customer?->id,
        'status'      => 'needs_review',
        'source'      => 'email_agent',
        'lines'       => $matched,
        'raw_email'   => $email->id,
    ]);
}

The thing that made the agent earn its keep was not the parsing. It was the customer-specific SKU lookup. Contractor A calls a 22mm copper elbow a knie 22. Contractor B writes CU-22-90. The same physical part. We built a per-customer alias table that the agent writes to whenever Karin corrects it, and after three weeks the confidence scores were over 0.9 on roughly four out of five lines.

Week four: data migration, in the boring way

We had been mirroring writes to PostgreSQL since week two, so by week four the new database had four weeks of clean data. What we still needed was the back-catalogue: customers, products, fifteen years of order history.

We did this with a one-shot Laravel command, run nightly, idempotent, with a hash on every row so reruns only touched what changed. No ETL tool. No Talend. Six hundred lines of PHP. The whole migration ran in 41 minutes on the final pass.

The two things that bit us: dates stored as VARCHAR(10) in three different formats depending on which decade the row was written in, and a prijs column that was sometimes ex-VAT and sometimes in-VAT with no flag. We solved the first with a small parser and a refusal to guess. We solved the second by asking the bookkeeper, who knew exactly when the convention had changed (June 2011, after an audit).

Week five: the warehouse-side screens and the report nobody mentioned

Every project has one. The report that nobody mentioned in the kick-off and that the owner runs every Monday morning at 7:30. Ours was a stock-movement summary that the green terminal produced as a fixed-width text file, which the owner then opened in Excel.

We rebuilt it in a single Laravel view, kept the column order exactly, and added a CSV export. The owner used the new one for the first time on a Monday in week five, and the green terminal was unplugged from the wall on the Wednesday.

Week six: deprecation, not deletion

The old PHP 5.6 stack stayed online for another two months, read-only, on its original Synology, on a subnet that could only be reached from the office. Nobody opened it. We checked the access log every Friday. Two reads in eight weeks, both from the bookkeeper checking a 2018 invoice. After that we took an image of the disk, archived it to cold storage, and shut the box down.

PHP 5.6 itself has been out of official security support since 2019, which is the line we always start the conversation with. The box was not on the public internet, but the LAN it sat on had a Windows 7 machine and a printer with a known CVE, and that is the realistic threat model for a 34-person wholesaler. The point of the rip-out was not the new features. It was getting off a stack where a single unpatched RCE in the office would have taken the order book with it.

What we got wrong

Two things, both in week three. We underestimated how much the desk depended on a printed pick-list that came out of the green terminal in a very specific font and column layout. The first version of our replacement was almost right and therefore worse than useless. We rebuilt it pixel-for-pixel, in a monospace font, on the same A5 paper, and then it was fine.

The second was the agent. The first week we let it auto-confirm orders below a confidence threshold. It got one wrong, a tee fitting instead of an elbow, and the contractor was on site when the wrong box arrived. After that the agent drafts and Karin confirms, always. The cost is about two minutes per order. The cost of the alternative is a callout to a building site in Antwerp.

When we built the order-intake chat agent for the Mechelen desk, the thing we ran into was that the catalogue lookup only worked once it learned each contractor's private vocabulary. We ended up solving it with a per-customer alias table that Karin writes to every time she corrects a draft, and the agent's accuracy climbs week by week without anyone training it on purpose.

If you are sitting on a PHP 5.x ERP today, the smallest thing you can do this afternoon is open the database in a read-only client and write down which tables changed in the last seven days. That list is your real system. Everything else is scaffolding.

Key takeaway

On a legacy PHP rip-out, the database is the spec and the user picks the cutover date. Everything else is just scaffolding around those two facts.

FAQ

Why Laravel 12 and not a packaged ERP like Odoo or Exact?

Packaged ERPs assume your processes look like theirs. After fifteen years of homegrown workflow, ours did not. Laravel let us match the desk's existing shortcuts and reports instead of retraining 34 people.

How did you avoid downtime during the cutover?

Both the old PHP screen and the new Laravel screen wrote to the same MySQL tables for two weeks, with a mirror to PostgreSQL. The user chose when to stop opening the old screen. There was no flip-day.

Did the chat agent replace the order desk?

No. It drafts orders from inbound emails and PDFs into a needs-review queue. Karin still confirms every order. That is the rule we broke once in week three and never broke again.

What did the database migration tool look like?

A single idempotent Laravel artisan command, around 600 lines of PHP, run nightly. Each row carried a content hash so reruns only touched changed rows. Final pass took 41 minutes.

How long did the old PHP 5.6 stack stay online?

Read-only for two months on an internal subnet, then imaged to cold storage. Two reads in eight weeks, both from the bookkeeper looking up an old invoice.

phplegacy sitesmigrationai agentscase studyarchitecture

Building something?

Start a project