Most conversations about legacy systems start in the wrong place. Someone says "the platform is holding us back", everyone nods, and within twenty minutes the discussion is about whether to rebuild in Laravel or buy something off the shelf. The system is treated as a single object with a single fate: keep it or bin it.
In practice a ten year old system is not one thing. It is twenty or thirty areas of behaviour with wildly different health. Some parts are boring and stable and will happily run for another decade. Some parts are so entangled that any change takes three weeks and breaks invoicing. The second group is what is actually holding the business back, and it is usually much smaller than people assume.
So before you scope a replacement, find out where the pain actually lives.
Start with the requests the system said no to
The most useful artefact here is not the codebase. It is the list of things the business wanted in the last twelve months and did not get, or got late.
Go and collect them. Ask the commercial director, the ops manager, the head of customer service. Not "what do you want from a new system" (you will get a wish list) but "what did you ask for that turned out to be hard". You will hear things like:
- We wanted to give trade customers their own login to see order history. Was told it needed the whole order system reworked.
- We wanted to change the discount rules for a new product line. Took two months.
- We wanted to stop double-entering orders that come in from the marketplace.
- Finance wanted a report joining orders to stock movements and we ended up doing it in Excel.
Now map each one onto the code. This is the bit that takes real effort, and it is worth paying an engineer to do it properly for a week. What you are looking for is the overlap. Nine blocked requests might trace back to three places: the pricing logic, the lack of any way to read order data from outside the application, and a database schema where stock movements are not actually linked to order lines.
That is your modernisation programme. Three projects, not one.
An example worth being concrete about
Say you are a wholesaler running an order management system written around 2011. PHP, no framework, direct SQL in page scripts, some jQuery. It works. Thirty people use it daily and it processes real money.
Sales want a customer portal. The obvious approach is to build a new front end that talks to the existing data. The obvious blocker is that pricing lives in a 4,000 line include file that runs inside the order entry page, reads $_SESSION to know which sales rep is logged in, and writes to a log table as a side effect. There is no way to ask "what would this customer pay for this product" without pretending to be a logged-in rep halfway through an order.
That single file is the thing holding the business back. Not the lack of a framework. Not the jQuery. Pricing.
So the first project is not a rewrite. It is extracting pricing into something callable. In practice that means reading the file carefully, writing down the rules, and building an implementation you can test in isolation:
interface PricingEngine
{
public function priceFor(CustomerId $customer, Sku $sku, int $quantity): Money;
}
Then the hard part: proving the new implementation agrees with the old one. Run both in parallel inside the existing order page, use the legacy answer, log any disagreement.
$legacy = legacy_calculate_price($customerId, $sku, $qty);
$candidate = $pricing->priceFor(new CustomerId($customerId), new Sku($sku), $qty);
if (! $candidate->equals(Money::fromPence($legacy))) {
Log::warning('pricing.mismatch', [
'customer' => $customerId,
'sku' => $sku,
'qty' => $qty,
'legacy' => $legacy,
'candidate'=> $candidate->pence(),
]);
}
return $legacy;
Leave that running for a few weeks of real trading. The mismatches will teach you the rules nobody documented: the customer who has a hand-edited price in a column called special_2, the rounding that happens on line total rather than unit price, the quantity break that only applies on Fridays because of a promotion from 2016. You cannot get that from reading code. You get it from traffic.
Once the mismatch log is quiet, flip to using the new engine and delete the old path. Now the portal is buildable, and so is the discount change that took two months, and so is the marketplace integration, because all three needed the same thing.
What this costs
Be honest about the tradeoffs, because there are several.
It is slower to start than a rewrite. A rewrite gives you a clean page and a satisfying architecture diagram in week one. Extraction gives you six weeks of careful archaeology and a system that looks identical from the outside. That is a hard sell internally, and you should expect to have to make the case more than once.
You also carry two implementations for a while. The parallel-run period means two pricing paths, two sets of bugs, and a log you have to actually read. Skipping the read is the common failure: teams switch over early, and then discover in month three that a whole customer segment has been quietly overcharged.
And you will not fix everything. The stock movement schema problem from that list is a data modelling job, and it may well need a migration with downtime. Wrapping does not help there. Some things really do need surgery.
The upside is that each project delivers on its own. If budget dries up after the first one, you still have a pricing engine you can call from anywhere, and the portal is now a normal piece of work rather than a heroic one. A half-finished rewrite gives you nothing except two systems to maintain.
Where wrapping is the right answer instead
Not every blocker needs extraction. Sometimes the legacy behaviour is fine and the problem is purely access: the data is in there, nothing can read it. In that case the cheapest useful thing is a thin read API in front of the existing database, with no write path at all. A small Laravel app, read-only credentials, a handful of endpoints shaped around what consumers actually need rather than around the tables.
That gets you a customer portal, a reporting stack and a mobile app without touching the old code. The cost is that you have now coupled a new application to an old schema, so when the schema does change you have two things to fix. Worth it as a deliberate, time-boxed decision. Not worth it as a permanent architecture.
The distinction is roughly: if the blocker is reading, wrap it. If the blocker is behaviour you need to reuse or change, extract it. If the blocker is the shape of the data itself, you are looking at a migration and you should plan it as one.
What to do this month
- Write down every business request from the last year that the system made hard or impossible. Get it from the business, not from the backlog.
- Have an engineer trace each one to a specific file, table or module. Expect the list to collapse into three or four root causes.
- For each root cause, decide: leave it, wrap it for reading, extract the behaviour, or migrate the data.
- Pick the one that unblocks the most requests and do that first. Not the ugliest code. The most load-bearing.
- If you extract anything that touches money, parallel-run it against the old path for at least one full trading cycle and read the mismatch log yourself.
The question worth asking at the next board discussion is not "should we replace the system". It is "which specific part of it said no to us most often last year". That one has an answer you can act on.