Engineering Journal · ARCHITECTURE · August 27, 2024 · 18 min read
A Migration the Business Can Survive
Rewriting a long-lived product feels clean until you try to do it while invoices, logins, and term dates keep moving. The real design problem is the path, not the destination framework.
By Golam Sorwar, Tech Lead and Full Stack Engineer in Dublin.
Every long-lived product collects a rewrite speech. The framework is old. The package graph is a museum. New engineers take a week to find the door. Someone says, with complete sincerity, that it would be faster to start again.
I have wanted that speech to be true. It is a relief to imagine a codebase that does not contain your past. It is also, in the products I have been responsible for, usually a way of refusing the actual problem: the business cannot stop, and the knowledge that makes the old system valuable is not sitting in a spec. It is sitting in behaviour, including the ugly behaviour.
I have written about walking Laravel versions and about the archaeology of dependencies. This is the decision in front of those tactics. Do you rewrite, do you freeze and renovate, or do you build a path the organisation can walk while it is still taking attendance, sending email, and collecting money?
Context
The system in my head here is a monolith that began in an older Laravel and grew into the operating layer of a real organisation. Student-facing apps, staff tools, finance edges, a mobile client that could not be forced to a flag day. Features did not stop arriving because the framework documentation had moved on.
The team was small and already busy. That is the usual setup, and it is the one rewrite plans like to forget. A rewrite is often estimated as if a second team existed. In practice the same people would have been asked to keep the old product alive, answer production, and invent the new one with better habits.
Users were not internal-only. If we broke login or a payment return URL, we would hear it from people who do not care what a service provider is. That raises the quality bar on temporary dual running. Temporary, in these systems, has a habit of becoming the architecture.
The real problem
The apparent problem was technology age. Unsupported PHP. Packages that had died. Framework conventions that made new work feel like translation. Those are real costs. They are also the costs that are easiest to put in a slide.
The deeper problem was that the old system was the only complete description of the business. Not a good description. A complete one, including the exceptions. A rewrite that starts from interviews will get the official process. It will miss the unofficial one that operations invented because the official one never fitted. Then you cut over and discover that the new system is correct and unusable.
There is another deeper problem: rewrite versus refactor is the wrong binary. The honest options are about blast radius and time. Can you move the runtime under the product without inventing the product again? Can you extract a boundary while the old one still serves? Can you keep shipping the work the organisation is already committed to, or are you asking them to live in a museum for a year?
If you cannot answer those, you are not choosing architecture. You are choosing a story about a future team that does not have your calendar.
Constraints
The product had to keep taking money and keeping sessions. That constraint is not romantic. It eliminates big-bang cutovers that depend on a long freeze, and it eliminates rewrites that only work if feature work stops.
Mobile clients and email links extend the past. You can deploy a server every day. You cannot force every phone to upgrade on Tuesday. Compatibility is not a slogan here. It is a window measured in months.
Data quality was uneven. A new schema that refuses the old dirt will fail at import, then fail again when someone finds a row that was legally meaningful and technically illegal.
Budget was attention more than money. A small team has a finite number of careful hours. A rewrite consumes those hours in a place users cannot see, while the visible product still demands them.
Third-party callbacks — payments, sign-in, inbound mail — had URLs and assumptions. A new application that wants a clean slate still inherits those promises unless you enjoy coordinating every vendor at once.
Options considered
Rewrite in parallel, cut over later. Advantage: psychological freshness, a chance to drop rituals nobody can defend. Disadvantage: you fund two products and a translation layer. Risk: the new product ships the brochure and misses the exceptions; the cutover becomes a second project with its own politics. Maintenance doubles. The business sees a year of "nearly" while the old system still holds the truth.
Freeze features and renovate in place. Advantage: focus. Disadvantage: the organisation does not freeze. Someone will need a change that is not renovation. Risk: the freeze is a fiction, or it holds and you lose trust. Complexity is social more than technical. I have rarely seen this survive contact with a term date.
Strangle one seam at a time while the monolith stays the system of record. Advantage: each step can be reversed; users stay on one product. Disadvantage: you live with seams, flags, and a codebase that looks indecisive in the middle. Risk: the seams become permanent suburbs. Maintenance is uglier in the short term and cheaper than a failed rewrite if you actually finish seams.
Version-walk the existing app, keep the domain, replace only what is dead. Advantage: you keep the behaviour you already paid to discover. Disadvantage: you will carry some shameful code into a modern runtime. Risk: you confuse a framework upgrade with a design improvement and declare victory too early. This is the least glamorous option and the one that usually respects the constraints.
I should say what I rejected quickly: a microservices conversion as the migration strategy. Taking an unclear monolith and spraying it across a network gives you an unclear distributed system. That is not courage. It is a way to make the same coupling more expensive.
Decision
We version-walked the monolith and reserved extraction for places where a boundary was already true: a queue-heavy email path, a payment callback, a read-heavy report that was harming writes. We did not start a second product.
I chose that because the organisation needed the old behaviour more than it needed a new folder structure. The framework work was justified on security, hiring, and the cost of standing still. The rewrite was not justified, because we could not staff the truth twice.
The "why" was also personal in the professional sense. I had seen enough multi-tenant and back-office systems to know that the second system is where optimism goes to meet the exception list. I would rather be accused of conservatism than of disappearing for six months and coming back with a demo that cannot invoice.
We still said no to some features that would have deepened a rotten seam. That is not a freeze. It is triage. If a request would only make the later version-walk harder, we offered a smaller change or a later date. That is a tech lead decision wearing an architecture costume.
Implementation / Process
The path was a sequence of boring contracts. First, make the app build and test on a runtime we could still patch. Then move one major framework version at a time, with the official upgrade notes as a checklist rather than as bedtime reading. Then replace packages that could not come with us, preferably with something dull.
We kept shipping inside that sequence. The rule was that a feature had to land on the current production line, not on an imagined future line. If a change was easier after the next version step, we sometimes waited a short, named period. We did not open a forever branch.
Where we extracted, we extracted at an existing seam. Email already wanted a queue. Payments already wanted idempotent workers. Those could become clearer modules without pretending they were new companies. The monolith remained the place sessions and permissions made sense.
Deployment stayed reversible. Expand/contract for schema. Feature flags only when we could say who would turn them off. A migration that cannot roll forward in small steps will eventually be attempted as a weekend, and weekends are when you discover the exception list.
Testing followed risk, not ideology. Auth, money, tenancy, the jobs that send the organisation's voice to the outside world. We did not wait for a perfect suite. We also did not walk versions on hope.
Problems and failures
We under-priced compatibility. A mobile client or a stored link will keep calling a shape you have already grown tired of. I still catch myself designing the next server as if clients were polite.
Package replacement took longer than version bumps. The framework is documented. The abandoned package that encoded a local tax or a forgotten SSO path is not. That is where rewrites look attractive: you imagine you will not have to understand the package if you throw it away. You will have to understand the behaviour, which is worse.
We had a mid-project moment of rewrite temptation after a particularly ugly module. The correct response was to extract that module's tests and shrink it, not to declare the programme dead. I did not always give that response on the first day. Fatigue makes people philosophical.
Communication failed when we talked about versions instead of risk. "We are going to Laravel N" is not a stakeholder sentence. "We are removing a class of security and hiring risk while keeping checkout intact" is. I had to learn that translation again, even though I already knew it.
There were production surprises that were not the framework's fault. They were ours: a config cache, a queue worker that still had the old code in memory, a scheduled command nobody had run in staging. Migration problems are often operations problems wearing composer clothing.
Trade-offs
We accepted a codebase that would not photograph well for a while. Mixed conventions, shims, a few adapters that existed to protect the rest of the app from a dead library. That is uglier than a new repo. It is also a product that still knew how to do Tuesday.
We spent calendar time that could have been features. That cost is real. I do not hide it. The opposing cost is a security story or a hiring story that arrives on a deadline you do not choose.
We did not get a pure domain model. We got a runnable modern runtime and a few honest seams. Anyone who needs purity should not take responsibility for a live back-office. Purity is a luxury of the first version, or of a product that has not yet met its users.
Result
The organisation did not experience a cutover day, which I take as the point. People kept logging in. Money kept attaching to invoices. We were able to hire and onboard against current docs instead of folklore from a dead major version.
The codebase was still itself. Anyone expecting a different personality would have been disappointed. What changed was the cost of the next change and the shame level of the platform conversation. Those are dull victories. They are the kind that compound.
I will not invent a downtime number. The honest result is negative space: the migration did not become the only story of that year.
What I would do differently today
I would have written the "what this system actually does" inventory before the first version bump, not after the third ugly package. Behaviour first, runtime second. I knew that and still let composer set the agenda because composer is concrete.
I would have been earlier and kinder about the no. Some features we accepted made the walk harder and were not material to the term. A clearer intake rule would have saved more engineering time than a clever shim.
I would also have rehearsed worker restarts and schedule overlap as first-class migration steps. We treated them as deploy hygiene. They are where users feel a migration that "went fine" on the web nodes.
Broader lesson
A rewrite is a claim that you understand the business well enough to discard its only working description. Sometimes that claim is true. In a live operational product, it is usually vanity.
The design problem is the path: how behaviour survives contact with a new runtime, a new package set, a new team. If your plan has a destination and no path the calendar can live with, you do not have a strategy. You have a wish that will be re-estimated in six months.
Closing thought
Move the ground under the product if you have to. Do not ask the product to levitate while you build a better one in the next room. The business will not pause to applaud the architecture, and the exceptions will not volunteer to be documented.