The CRM has run since 2019 and sat on PHP 5.6. Support for 5.6 ended years ago; keeping it on a server was getting harder every month.
It had to move. The problem: this system is used every day, produces thousands of bills, and stopping it is expensive.
It had been tried before
I had a folder: a copy of the system that someone had tried to port to PHP 8.3. It was roughly 85% done and had stopped there.
To understand why, I compared the two:
322 files → present in both, different
545 items → only in the live system (written after the copy was taken)
26 items → only in the copy (written during the port)
The middle line tells the whole story. After the copy was taken, 545 new things landed in the live system: the MCP server, the semantic search layer, product integration, the new HR module, database migrations. None of them exist in the copy.
Reviving it would have meant throwing away a year of work.
Copying a code base and saying “the new version will live here” is a promise to keep two systems alive from that moment on. The live system does not pause — does the customer pause? Every new feature has to be written twice. After a while nobody writes it twice and the copy dies.
This copy had died exactly that way.
The decision itself
I did not make a second copy. Instead: fix the live code in place so it runs on both versions.
That looks impossible at first, and it is not — because almost everything that breaks in PHP 8 has an equivalent that also works in 5.6:
| written for 5.6 | works on both |
|---|---|
mysql_query() | mysqli_query() |
ereg() | preg_match() |
each($arr) | foreach |
$a{0} | $a[0] |
$undefined | isset($x) ? $x : null |
After every fix the code is still live and still on 5.6. Nobody notices anything. And file by file, the system becomes ready for 8.3.
The cutover therefore never becomes a giant release: you change the PHP version on the server. If it goes wrong you change it back.
Two binaries, one folder
To do this both versions are installed locally. Every change is syntax-checked against both:
php5630\php.exe -l file.php → is 5.6 still happy?
php833\php.exe -l file.php → does 8.3 accept it?
5.6.30, 7.1.3, 7.4.27 and 8.3.3. And for a long time the php on PATH was 7.1.3 — meaning some of what I called “working” was running under the wrong interpreter.
I found that and fixed the PATH, but I wrote the lesson down elsewhere: pin the version in the code, never leave it to guesswork. Every script now states which interpreter it wants. PATH is a machine setting; on another machine, in another shell, or after a tool re-adds its own entry, it will point at the wrong thing again.
The single biggest break: the template engine
The system used a bundled build of an old template engine. That build throws a fatal error on PHP 8 at the first call — it uses a language function that has been removed.
So the system would not run at all on 8.3. I moved it to the modern release; initialisation, delimiters and the plugin resolution mechanism all changed.
And a second thing surfaced: the background worker that produces bulk invoice PDFs used the same old engine. Nobody tested it, because it never passes through the web interface. Had we switched to 8.3, a production run of thousands of invoices would have died silently.
That discovery started a separate project — the Job Centre.
What I learned
In a large migration the real risk is not technical difficulty, it is how long the work takes. If it takes long, the live system moves on and the migration falls behind.
That is the only real advantage of the in-place, runs-on-both approach: you are never behind. Stop tomorrow and you are left with a slightly cleaner system. Fork, stop tomorrow, and you are left with a dead folder.