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.