Tools: Migrating a PHP monolith in production: how I think about it - Guide

Tools: Migrating a PHP monolith in production: how I think about it - Guide

What I was looking at

What we actually did

The sequence

What the new code looks like

The thing I didn't expect

Where I am now

Why I wrote this TL;DR — A security breach took down a site running on a 10-year-old Debian server with an obsolete LAMP stack. No access to the original infrastructure, no time to understand it. This is what I did, in what order, and why. The system is still in production. The migration is still ongoing. The site went down because of a security breach. Not a gradual degradation, not a performance problem we'd been ignoring. A breach. The system was offline, and getting back in meant dealing with infrastructure that hadn't been touched in over a decade — a Debian server, no longer receiving updates, running a LAMP stack that had been obsolete for years. No access to the original setup. No time to understand it properly. The only priority was getting it back up. That's where this started. A system doesn't have to be old to become unmaintainable. Five to seven years of incremental pressure is enough. The system was 5 to 7 years old, but the infrastructure underneath it was older. When you run something on a server for long enough without revisiting it, the server becomes part of the system — not explicitly, but in practice. Things depend on the exact PHP version, the exact MySQL version, the exact way that particular Debian release handled file permissions. Nobody documented any of it because nobody needed to. It worked. The codebase itself was mixed. Some parts had structure — someone had clearly tried to impose order at some point. Around those parts, other things had grown organically: patches, workarounds, logic that existed because of a constraint that no longer applied. The kind of code that's perfectly explicable if you were there when it was written, and completely opaque if you weren't. Adding a feature meant touching things you weren't sure about. Changing one module broke another. The system worked, but it had become fragile in a way that wasn't visible until something forced you to look. The breach removed every argument against changing the infrastructure. Nobody could say "not now" anymore. With the site offline, the immediate goal was obvious: get it back up. Not rewrite it — restore it. Continuity first. But the breach also opened a door that had been closed. The second decision — taken at the same time, not later — was to migrate everything to Kubernetes. Not to modernize the application code immediately, but to get control of the infrastructure. The existing LAMP stack, obsolete versions and all, would be containerized and run on k8s. From there, new modules could be introduced gradually, sitting alongside the old system, routed through ingress rules, replacing behaviour piece by piece. That's the Strangler Fig pattern. You don't kill the old system. You build the new one around it, redirect traffic module by module, and remove the old pieces when they're no longer needed. The fig grows around the tree. The order wasn't arbitrary. Once the site was stable again, the work followed a specific order: The decision was made once, at the beginning, and applied consistently. Introducing patterns gradually and inconsistently produces a different kind of mess. Everything written after the decision to modernize runs on PHP 8.x, follows Domain-Driven Design principles, and is structured around a hexagonal architecture. DDD has a cost. You have to understand the domain before you model it. You can't just translate old code into new patterns — you have to ask what the system is actually doing, what the real business rules are, where the boundaries between responsibilities should sit. Those conversations take time and they're sometimes uncomfortable. But the alternative — introducing patterns gradually, inconsistently, module by module — produces a codebase that's modern in some places and legacy in others, with the boundary between them becoming its own source of confusion. The technical debt was worse than it looked. It almost always is. Stop trying to understand why things are the way they are. Focus on what they need to do. I knew the technical debt would be bad. Knowing it and finding it are different things. When you start pulling at a module, you find business logic in SQL queries, assumptions baked into the schema, side effects triggered by things that look like reads. The system wasn't badly built because nobody cared. It was built under pressure, incrementally, by people solving real problems with the tools and time they had. That's how most systems end up where they are. Once I accepted that, the approach changed. Instead of trying to understand why things were the way they were, I focused on what they needed to do — modelled that cleanly, and replaced the behaviour one piece at a time. The system hasn't stopped. That's the point. Some modules are fully on the new architecture. Others are still the original code, containerized but otherwise untouched, waiting their turn. New development happens almost entirely in the new structure. It's not finished. I'm not sure when it will be. If I were starting today, I'd do the same things in the same order. The one thing I'd add earlier is a testing baseline — not full coverage, just enough signal to know when something breaks that shouldn't. Without it, you rely on intuition more than is comfortable. I looked for articles like this when I was in the middle of it. What I found was mostly theory — patterns described without context, case studies from companies with six months of runway and a dedicated platform team. This started with a site that was offline and a server I couldn't get into. Every decision here came from that situation, not from an ideal setup. If you're dealing with something similar — a system that works but has become the kind of thing nobody wants to touch — I hope some of this is useful. If you want to compare notes, my LinkedIn is in the profile. I write about software architecture and PHP. Templates let you quickly answer FAQs or store snippets for re-use. as well , this person and/or - Performance — the components with direct user exposure. There's no point modernizing the backend if the frontend is slow.

- Subscriptions and payments — migrating to Stripe, cleaning up a part of the business that had become technically fragile and commercially important.- Authentication — moving to OIDC and OAuth 2.0. Not urgent on its own, but without it, any future integration would be harder than it needed to be.- Everything else — the remaining modules, refactored into the new architecture as capacity and priority allowed.Each step could be shipped independently. Each one made the next one less complicated.