Modernizing a Business Application Without Rewriting It from Scratch

From Blade and jQuery to React, from fat controllers to Actions: an incremental rewrite that kept the product running.

November 14, 2026 · About 6 minutes to read

Three craftsmen scrape the wooden floor of a room.

The Floor Scrapers

Gustave Caillebotte, 1875. Musée d’Orsay, Paris. Image: Google Art Project / Wikimedia Commons. Public domain.

We could no longer upgrade Laravel or PHP. Some abandoned libraries were blocking dependency updates, and every change took more effort than it should. Rewriting everything seemed like the simplest option: a new frontend, a cleaned-up backend, up-to-date versions. At least on paper.

A business application that's been in use for years contains much more than you can see in the repository. There are exceptions added for specific customers and behaviors nobody ever documented. Some workflows work the way they do simply because someone needed them to, years ago. Rewriting means rebuilding those things too, often without knowing in advance what they are. Meanwhile, the old application keeps changing.

In a recent project, we rewrote much of a business application, both frontend and backend. We worked on it for months, but we didn't build a second application to deploy all at once. We replaced the code piece by piece, keeping old and new running side by side in the same project. Essentially, we applied the Strangler Fig Pattern inside a Laravel monolith.

Blade wasn't the only problem

The frontend used Blade views, jQuery, and plugins that had since been abandoned. Every new screen had to deal with those libraries. And as long as they were there, upgrading the stack wasn't feasible.

The backend had different problems. Controllers had grown bloated, and application logic was scattered between controllers and services. To understand what happened during an operation, you often had to trace the code through several places. Rebuilding the screens while leaving all that untouched would have solved only part of the problem.

We introduced Inertia and React to gradually replace the Blade views. At the same time, on the Laravel side, we moved application logic into Actions: plain PHP classes with an execute() method, without any dedicated libraries. Controllers were left to handle HTTP requests and responses. We kept services for integrations with third-party systems.

Moving logic without duplicating it

We wanted every operation to have a clear place in the code. Simply moving everything into other classes wouldn't have helped much if we'd kept copying the same logic.

For example, an update Action might need to retrieve a record first. If there's already an Action that handles the lookup, the update Action receives it through dependency injection and calls its execute() method. The retrieval logic stays in one place. If that behavior changes, we update it there rather than hunting down every operation that repeats it.

Actions can be called by different controllers or by other Actions. During the migration, being able to reuse the same operations regardless of which screen triggered them made our work much easier.

Two interfaces, the same operations

Once we'd extracted the logic into Actions, keeping the Blade views working was easier than expected. The controller called an Action and then returned either a Blade view or an Inertia page, depending on the feature. The operation itself was the same.

With Inertia, we could keep using Laravel routes and controllers without building a separate API for React. This let us reorganize the backend while keeping the old screens working and, when the time came, replace a page without duplicating business rules.

For the new code, we adopted typed DTOs with Spatie Laravel Data, which also let us generate TypeScript types for the React views. Data exchanged between the backend and frontend now had explicit contracts, rather than structures we had to piece together by reading the code every time.

The less obvious decision concerned Blade. When an operation's contract changed, we also updated the code responsible for rendering the old view. We adapted the new contract there to the data the template expected. In most cases, the data passed to Blade stayed the same, and we didn't need to change the view itself.

It was still work on code we knew we'd eventually remove. But it saved us from maintaining two application contracts throughout the months of migration. The compatibility code stayed close to the Blade view and could be removed along with it, without constraining the new Actions.

One feature at a time, but the whole feature

New features were built with Inertia and React from the start. We migrated existing ones when we needed to work on them anyway, trying to choose something small enough to test and release independently.

That doesn't always mean a single page. A screen might depend on modals, filters, or scripts shared with other parts of the interface. If rewriting it in React means bringing along the same jQuery plugins we're trying to get rid of, we've only moved the problem. Before starting, we need to understand which behaviors actually belong to that feature.

That's why we kept the previous version accessible. Opening the Blade screen next to the React one helped us check buttons, permissions, and less obvious behaviors. It didn't replace tests, but it helped us spot details we wouldn't have thought to check.

For a while, we maintained two ways of building the interface. So we kept a document up to date with the features we'd already migrated, the conventions we'd adopted, and the issues still open. It served as a reference for everyone working on the project, including AI agents. Without it, each new screen could easily have ended up following slightly different rules from the ones before it.

The part you can only do at the end

For months, we had two ways of rendering pages, while the backend was gradually being organized around Actions. The old screens kept working without forcing us to carry the old structure into the new code.

Migrating features whenever we had a reason to work on them let us spread the work out. Eventually, though, we had to tackle the remaining screens too: as long as they were there, we couldn't get rid of all the outdated dependencies.

Once the last feature had been migrated, we removed the Blade views and deprecated libraries. Only then could we upgrade Laravel, PHP, and the rest of the dependencies to their latest versions. Until then, some abandoned libraries had made the upgrade impractical.

We didn't avoid the rewrite. We avoided having to release it all at once. Meanwhile, the business application kept running.