Work · 01

From a mixed PHP and Node.js stack to Symfony 7, with behavioral parity

A product used daily by large companies was moved to a modern foundation, piece by piece, without users noticing and without the team pausing delivery.

Migration · Enterprise SaaS

3 monthsfrom a mixed PHP and Node.js stack to Symfony 7
Context
A multi-tenant platform used by enterprise and public-sector customers across the EU, UK, Canada and US. The stack had grown for nearly a decade: PHP on Zend Framework next to services on an old Node.js version, both past the end of their maintenance. Billing, public APIs, queue consumers, crons and SSO, all on code that had to keep running during the move.
What we did
We held the move to strict 1:1 behavioral parity. The new Symfony 7 code ran as a shadow system, in dry-run mode, next to the legacy code and on the same input, and we compared the output of both until it was identical. Then customers were moved over gradually, under monitoring, and the legacy and shadow code were removed. Billing, several public API surfaces, queue consumers, crons and SAML SSO all moved this way while the product kept shipping.
Result
Migrated in 3 months with zero production incidents caused by a cutover, and fewer than 15 bugs afterwards, all minor. A Symfony 7 codebase with 11 custom PHPStan rules and coverage gates enforced in CI, easier to hire for and faster to ship on.
Stack
PHP 8, Symfony 7, Node.js, MySQL, Redis, SAML

Where it started

The platform had been in production for nearly a decade. Its core ran on PHP with Zend Framework, and a set of services ran on an old Node.js version. Both had reached the end of their maintenance: libraries dropping support one release at a time, and fewer engineers willing to work on them every year.

Nothing was on fire. That is the uncomfortable part of an unmaintained stack: the risk grows quietly, and it is cheapest to deal with while the product is healthy and the team is not under pressure.

Why a migration and not a rewrite

The goal was narrow on purpose: bring the stack up to date and remove the risk of running on unmaintained technology, without pausing the roadmap. A rewrite would have paused delivery and put at risk a decade of behavior that customers depend on, including the parts nobody remembers writing.

So the rule was 1:1 parity. No redesigns, no improvements "while we are in there". Improvements wait until the old code is gone.

A shadow system instead of a leap of faith

We did not trust a test suite alone to prove parity on a system this old. We built a shadow system: the new Symfony 7 code ran next to the legacy code, in dry-run mode, on the same input. The legacy code stayed in charge and did the real work. The new code computed what it would have done, without side effects, and the output of both was monitored and compared.

Every difference was one of two things: a bug in the new code, or a behavior of the old code that nobody had written down. We fixed the first kind and reproduced the second, and repeated this until the output of the two systems was fully identical.

Only then did customers move, gradually, with both systems monitored. When the last customer was on Symfony 7, the legacy code and the shadow system were removed.

Guardrails that outlive the migration

A migrated codebase drifts back if its architecture lives only in people's heads. So the decisions are enforced by tooling, not by memory: 11 custom PHPStan rules run in CI next to the coverage gates. What they enforce:

  • Dependency injection: no injecting the whole service container, no injecting classes that are excluded from it, and one consistent injection style.
  • Database access: Doctrine queries live in repositories and nowhere else, with narrow written exceptions for vendor hooks, and repositories return domain types, not query objects.
  • One write path for the core entity: saving it directly is banned outside a short allowlist where every entry carries its reason, so dry-run, approval and event rules cannot be bypassed by accident.
  • Controllers and security: every controller argument declares how it is resolved, and every endpoint carries a known security attribute, so nothing ships without an explicit authorization decision.
  • Entity hygiene: no boilerplate accessors, and no calls to accessors that no longer exist.

The same gates apply to code written by people and to code written with AI. That is what makes AI assistance safe to use at speed on this codebase.

What came out of it

The migration took 3 months, while the product kept shipping. There were zero production incidents caused by a cutover. The one surprise was how little came back afterwards: fewer than 15 bugs, all minor.

The lasting effect is speed. On a current framework, with static analysis and gates in CI, the team ships features faster and iterates with more confidence, especially with AI in the loop.

Contact

Let's talk

Tell us what you have, in your own words. You do not need to know which technology the system runs on; we will work that out. You get back questions and a written proposal.

Write to usor directly at [email protected]