The situation
An industrial operator engaged us to take over the platform that runs most of their operational reporting and data entry. Twelve years old. More than forty screens. About a hundred users across more than a dozen production sites in four countries.
The legacy stack was the kind of thing you read about in retrospectives. Flat PHP files. Five different frontend patterns — jQuery in some screens, Vue in others, hand-written DOM elsewhere. Business-critical IDs hard-coded across multiple files. ODBC credentials and the auth encryption key checked into the repository. No documentation outside the code itself. Every reporting cycle, the business depended on numbers it could not independently re-derive.
So the system kept running. It also kept getting harder to change. A small fix took a week of investigation and another week of careful testing. A new report could take a quarter. Anyone who has inherited a system like this knows the shape of it: you can't move forward and you can't stop.
The approach
We didn't propose a rewrite. Rewrites of systems like this fail. The rules embedded in twelve years of code are not the rules anyone remembers writing — they are the rules the business accidentally settled into, and they are load-bearing.
Instead we built in parallel. The old system kept running, unchanged. We started a new codebase on Laravel 12 alongside it, organised into eleven bounded domains — reporting, forecasting, mass balance, shipping, data entry, admin, and the rest. Then, working through the legacy code one path at a time, we captured every business rule we could identify as a test against the legacy implementation. Not a unit test of pure logic — a test that took real input, ran it through the old system, and recorded what came out. Each test became a contract: the new system, when finished, had to produce the same output for the same input, or we had to explain why.
// A parity test, simplified. The legacy result is the oracle. it('monthly mass balance matches legacy to the cent', function () { $period = Period::of(2024, 11); $legacy = LegacyReport::run($period); $rebuilt = MassBalance::for($period)->compute(); expect($rebuilt->rows()) ->toMatchLegacy($legacy->rows()); });
We have written more than 1,100 such tests so far. Some are small (this fee rounds half-up, not half-even). Some are large (this entire monthly aggregation, with these specific input shapes, must match to the cent). Together they are the closest thing to a specification the platform has ever had.
The parity bar was hard. Numerical differences were not acceptable: if the new system disagreed with the legacy on any cell — count, value, percentage, label — the new system was wrong by default until proven otherwise. That bar forces honest engineering. It also means the test suite now catches the kind of subtle regression that used to surface months later, in production, when somebody downstream noticed the wrong number on a printed report.
Then we ran a full reporting cycle through both systems in parallel. Real data, production volumes, side by side. We compared output line by line. Where the new system disagreed with the old, we investigated. Sometimes the new system was right and the old one had a quiet bug nobody had ever noticed. Sometimes the old system was right and we had misread a rule. We resolved every difference before talking about a cutover.
The result
We switched over. Nothing broke.
Reporting that previously required careful handling by people who knew where the system kept its skeletons can now be re-run by anyone on the team. New rules can be added in a day and trusted in a week, because the test suite catches the kind of subtle regression that used to take months to surface. The skeleton is documented — in tests, not in slides.
We still maintain it. The client has added reports and forecasting views we did not anticipate. None of them required a rewrite.
Stack
- Framework
- Laravel 12 · PHP 8.2
- Frontend
- Livewire 4 · Alpine.js · Tailwind 4
- Data
- MySQL · ODBC to client data warehouse
- Testing
- Pest · 1,100+ business-logic tests
- Observability
- Sentry
- Architecture
- 11 bounded domains · 34 migrations