Expert Opinion

Ten years on one codebase, and what I would build differently

Ten years on one codebase, and what I would build differently

If you have run the same codebase for a decade, the most consequential decision you make is the one you keep making: not to rewrite it. I made that call on a US SaaS product I ran for ten years. I did not migrate it to a framework. I rebuilt it in place instead, and I tracked the whole thing with a number I invented.

That number went from 4.5 to 9.2 in about six months. It is also the reason I missed something important for 53 days.

Here is what I chose, what it bought, and the three calls I would make differently.

The decision not to migrate

The question every mature PHP codebase eventually faces is whether to move onto a framework. For this product the honest answer was no.

The reasoning was not sentiment. It was inventory. Over the years the codebase had grown its own router, its own validation layer, its own logging discipline, and data access built on Illuminate components rather than the full framework. A migration would not have given me capabilities I lacked. It would have given me a different implementation of capabilities I already had.

Against that, I estimated the migration would freeze feature work for roughly a year. That was my estimate at the time, not a measured fact, and I want to be precise about it because the entire decision rests on it. A year of no new features on a product with paying customers is not a technical cost. It is a business one.

So I settled on a rule I could apply without re-litigating it every time: a new standalone service starts on a framework, an existing surface stays on what it runs on. Greenfield gets the framework’s benefits at zero migration cost. Established surfaces do not pay a year to get what they already have.

I still think that rule was right. Later in this piece I will show you where applying it cost me something I did not anticipate.

What I built instead

If you are not going to adopt a framework’s structure, you have to supply your own, and it has to be enforced by something other than good intentions.

The backend runs five layers, each with one job. A controller reads the request and returns a response, with no data access and no logic. A service holds all the business logic and never touches a model directly. Below that sits a repository layer that routes reads to the fastest source that can answer them, which means picking the right kind of cache for each kind of read and falling back to the database when the cache cannot answer. Data access lives in its own layer, and it is the only place raw queries are allowed. Models carry schema and nothing else, so no queries and no rules.

That description is worth very little on its own. Every codebase has an architecture diagram that stopped being true two years ago.

What makes it real is that the rules are enforced in continuous integration. I wrote ten custom static analysis rules that fail the build when a layer does something that belongs to a different layer. No models in controllers. No repositories in controllers. No transformers inside services. No object construction hidden in constructors. Each one encodes a decision that would otherwise erode the first time someone was in a hurry.

This is the part I would carry into any codebase I work on next. Architecture rules without enforcement decay, and they decay quietly. A rule the team agreed to in a meeting lasts until the first deadline. A rule that fails the build is a rule.

The same principle applies below the application layer. When I moved logic out of database triggers and into application events, the thing that made it work was not the events themselves. It was that the trigger version could never be checked and the event version could. Twenty-seven events and forty-three listeners later, the system does more and hides less.

The number I made up

At some point a client or a colleague asks how the codebase is doing, and “better” is not an answer.

So I built a scorecard. Twenty or so dimensions covering layer separation, data access, authentication, queueing, observability, test coverage, deployment and the rest. Each scored out of ten, each with a written justification, refreshed as a new column so the history stayed visible.

I want to be direct about what this is. I invented the rubric. I chose the dimensions, I set the weights, and I scored my own work against them. When I say the architecture went from 4.5 to 9.2, that is my rating on my own scale, not an industry benchmark. The same applies to the comparison columns where I scored two major frameworks against the same rubric. Those are my readings of them, not theirs.

A reasonable person hearing “I gave my own code a 9.2” should be skeptical, and I would rather answer that up front than have it sit unspoken.

Here is why I think it earned its place anyway.

The scorecard’s value was never the score. It was that it forced a written justification next to every number, which meant “how is the codebase” became “it is a 6.7, and here is the specific column holding it back, and here is what moving that column costs.” That is a decision you can act on. “Better” is not.

And the honest test of a self-made metric is whether it can tell you something you did not want to hear. Mine did, repeatedly. It caught an inventory I had overcounted, and the correction was recorded plainly: the recount fixed the inventory, not the score. It flagged two capabilities scored as missing months after they had shipped. It struck an entire roadmap item once I established the problem it was chasing did not exist. At one point it corrected a count of leftover database objects from four to two, and noted the two survivors were deliberate safeguards rather than debt.

A number you can never be wrong about is marketing. A number that corrects you is a tool. Mine corrected me often enough that I trusted it.

Then it failed at something else entirely.

What I got wrong

### I built an authentication stack and never turned it on

I built token-based authentication properly. The middleware, the token generation, the refresh cycle, the client-side plumbing. It was complete work and I scored it as such.

It was never actually used. The middleware did not replace the session, it rehydrated it. The browser never requested a token, so every single request quietly fell through to the old session path. The entire stack sat there, finished and inert, running on nobody.

The failure was not technical. Every individual piece worked. The failure was that I treated “built” as the finish line when the finish line was “carrying production traffic.” Nothing in my process distinguished between those two states, so a complete implementation and a complete-and-live implementation looked identical on the scorecard.

What I would do now: a stack is not done until the old path is off and something breaks if you remove the new one. If I cannot turn off the thing it replaced, I have not finished, I have accumulated.

### I emitted observability data and collected none of it

I put distributed tracing in. Every request got a trace identifier, and it propagated across queue boundaries so an asynchronous job could be traced back to the request that caused it. I was pleased with it, and it was genuinely the right design.

Nothing collected it. The identifiers were emitted into log files on a server nobody was watching. There was no error tracker and no collector. In practice, an error in production was a line in a file that no human or system would read unless someone already suspected a problem and went looking.

The cost of that shows up in ordinary debugging. When one insert throws two different database errors that look like the same bug, what you want is to pull every log line sharing a request identifier and read the sequence. I had built exactly that capability and then left it with nowhere to land.

This one stings more, because I had written down that fixing it was the highest-leverage next thing to do. I estimated it at about one session of work. It did not happen, and then it kept not happening.

What I would do now: instrumentation that nothing consumes is not observability. It is the preparation for observability. I would not score the dimension at all until something is on the receiving end.

### I scored what I was good at

This is the real one, and the other two are symptoms of it.

My architecture score went up seven refreshes in a row. Over the same stretch, operational readiness barely moved. Seven operational gaps stayed open for 53 days: containerization, error tracking, process supervision, service level objectives, API specification, token scoping, real-time delivery. Zero of the seven closed. Two of them were the ones I had personally flagged as the highest-leverage next tasks.

Meanwhile hundreds of commits went into a major feature rewrite and interface work. That was a defensible business decision and I would probably make it again.

The problem is that nothing in my system noticed. The scorecard tracked architecture beautifully and had no mechanism to flag that a whole category of work had stalled. I measured the dimension I enjoyed and was already strong at, and the score climbed, and the climbing score felt like progress on everything.

Containerization sat at one out of ten across every single snapshot. It never moved, and because it was one line in a large table, it never drew attention.

What I would do now: score operational readiness from the first snapshot, as a headline number next to the architecture one rather than a row inside it. And track a second statistic that has nothing to do with quality, which is how many known gaps closed since the last refresh. If that number is zero twice in a row, that is the finding, regardless of what the score says.

The uncomfortable version: my architecture score was accurate and my priorities were wrong, simultaneously, for nearly two months. A metric can be correct and still point you away from the work that matters.

What ten years actually teaches

Most of what I know about software I learned from living with the consequences of my own decisions for long enough that I could not blame anyone else for them.

Short engagements hide this. If you are on a codebase for eighteen months you get to see which of your choices shipped. You rarely get to see which of them were still a good idea four years later. I got to watch a caching strategy age, watch an abstraction I was proud of turn into the thing people worked around, and watch decisions I made under deadline in year three quietly set the cost of everything in year seven.

The three mistakes above share a shape. Each one was a piece of work that looked finished from the inside and was not finished from the outside. Built but not wired up. Emitted but not collected. Measured but not prioritized. Ten years is long enough to notice that this is my particular failure mode, and that is worth more to me than any of the individual fixes.

The engagement ended in August 2026. The codebase it left behind is one I would still be comfortable handing to another engineer, which was the actual goal the entire time.

Questions I get asked about this

Should you migrate a long-lived custom codebase to a framework?

Usually not, if the codebase is mature and you have already built the equivalents. I chose not to. The migration would have frozen feature work for roughly a year on a product with paying customers, and the things a framework would have given me were things I already had. The rule I settled on was simple: a new standalone service starts on a framework, an existing surface stays on what it runs on. Where that rule cost me is covered in the piece.

Does a self-invented architecture score mean anything?

Only if it can tell you something you did not want to hear. Mine started as a way to answer “how is the codebase” with something better than a shrug. It earned its keep when it caught its own mistakes: an inventory I had overcounted, a roadmap item that was chasing a problem that did not exist, two gaps scored as open months after they shipped. A number you can never be wrong about is marketing. A number that corrects you is a tool.

What is the real risk of tracking architecture as a number?

You improve the thing the number measures. My architecture score went up seven snapshots in a row while operational readiness sat still, because architecture was what I was scoring and operations was what I was not. Seven operational gaps stayed open for 53 days, including two I had written down as the highest-leverage next tasks. The score was accurate and the priorities were wrong at the same time.

How do you modernize a live codebase without a rewrite?

One surface at a time, with both the old and the new path working during the overlap, and a formal moment where the old code is deleted. A migration with no cut event is not a migration, it is a second codebase you now maintain alongside the first. I ran several of these on the same product and the ones that went well were the ones where I could name the cut before I started.

What would you do differently after ten years on one codebase?

Three things. I would wire up a new authentication stack end to end before calling it built, not leave it complete and unused. I would treat observability as unfinished until something is actually collecting what the application emits. And I would score operational readiness from the first snapshot, not add it later, because the dimensions you leave off the scorecard are the ones that quietly stop improving.

Want to talk about this kind of work?

I am a hands-on senior engineer with 15+ years building and running production systems. I am open to senior engineering and technical lead roles.