Blazin Safety: five framework versions moved, zero database changes
A West Texas safety company's aging Laravel 6 app moved to a fully modern stack across ten gated phases, without a rewrite, without a data migration, and without a single schema change.
The problem
The application carrying seventeen years of inspection records had been quietly aging out from under the business. It still worked, which was exactly the problem: it worked well enough that stopping it was never justifiable.
- The stack was multiple generations behind, and the gap was compounding. Laravel 6, PHP 7.2, and Vue 2 are all long past end of life. Each year of deferral made the next attempt harder and the security exposure worse.
- A dead dependency sat directly in the login path. The library handling authentication had been abandoned by its maintainers years earlier. Not a style objection, a hard blocker found during scoping.
- There was no safety net. Zero automated tests. Every change was a manual-verification change, which in practice means every change is a gamble.
- The database could not move. Thirty tables, roughly 225,000 service records, and 975,000 audit-history rows making up an unbroken chain of custody. When your inspection history is the proof of OSHA and NFPA compliance for equipment protecting people on a refinery site, losing some of it in an upgrade is not an inconvenience, it is a liability event.
- A ground-up rebuild had already failed. A greenfield replacement stalled at roughly 18% of scope and was formally retired.
The mandate was narrow and demanding: modernize everything underneath, change nothing the users or the data can see.
What I built
I did the opposite of a rebuild: an incremental, phase-gated modernization that never rewrote the application, never changed the database, and never broke the audit trail. The first phase was not upgrade work, it was an inventory of every screen, field, validation rule, and role gate in the legacy app, producing roughly 150 catalogued findings that became the parity contract governing everything after it. The second built the safety net the upgrade would lean on, a test harness running against real MySQL plus CI on every change, including three guard tests that fail loudly if anyone ever renames a preserved column, breaks the audit trail, or changes URL resolution. Only then did the stack start moving, one concern per phase, each phase ending in a running application that passed a written gate: full suite green, a live browser walkthrough against real data, parity confirmed, audit integrity re-verified, and a recorded sign-off before the phase gets its git tag.
| Legacy app | After 10 phases | |
|---|---|---|
| PHP runtime | 7.2, end of life 2020 | 8.2, supported |
| Framework | Laravel 6, end of life 2022 | Laravel 11 |
| Front end | Vue 2 + Bootstrap-Vue + jQuery | Vue 3 + PrimeVue 4, zero jQuery |
| Build tooling | Laravel Mix / webpack | Vite |
| Authentication | Abandoned library, browser-stored tokens | Laravel Sanctum, server-side sessions |
| Automated tests | None | 103, running in CI on every change |
| Database schema | 30 tables | Identical, zero changes |
| Audit trail | ~975,000 rows | Unbroken, re-verified at every gate |
| Bookmarked URLs | Legacy paths | Still resolve identically |
| Rollback story | None | Any phase revertible to its tagged build |
| Environments | Production only | Local, staging, and production |
The outcome
Ten of thirteen phases are closed and the application now runs on Laravel 11, PHP 8.2, Vue 3, PrimeVue, Vite, and first-party session authentication, on the same production schema it started with, down to the original misspelled column names that were preserved deliberately. Along the way the work fixed roughly a dozen real production bugs, closed two live security exposures, and built a 103-test automated safety net where none existed. The people who use the app every day have not had to change anything about how they work. Not one record was migrated, because not one record needed to be.
Built with
Write the parity contract before writing any code
The first real phase was not upgrade work, it was an inventory. Every screen of the legacy application was walked and documented: every field, validation rule, workflow, role gate, and side effect across eleven feature areas, from user access and the dashboard through clients and locations, the 15,168-item equipment register, service records and work orders, reports, and the cross-cutting audit, barcode, PDF, and Excel tooling.
The output was roughly 150 catalogued findings. Not a wish list, a definition of “unchanged.”
Two details from that phase matter more than the headline number. Predictions were tested rather than assumed: at least two anticipated defects turned out not to exist, because the legacy app had gotten them right, and both were documented as overturned. An inventory you can only confirm is an inventory nobody should trust.
And the database’s typos were promoted to requirements. Column names like
clinet_location_type_id and supression_agents are misspelled. They stayed misspelled.
Downstream code depends on them, and cleaning that up is exactly the kind of well-intentioned
change that breaks a production system on a Tuesday.
Build the safety net before touching the stack
Phase 2 delivered the harness the entire upgrade would lean on: PHPUnit against real MySQL on the back end, front-end unit tests, per-module happy-path coverage, and CI running everything on every change.
It also introduced three guard tests, automated checks that fail loudly if anyone ever renames a preserved production column, breaks the audit trail, or changes URL resolution. These are not feature tests. They are tripwires on the three non-negotiable constraints.
With the net in place, the same phase cleared the backlog of code-only production bugs the inventory had found, each fix shipping with a test. Self-service password reset had been broken for every user, a malformed link plus a token expiry set to four minutes instead of four hours. More than 1,200 equipment-model records were invisible to administrators because of a URL-casing bug. A role-check gap let non-admins reach equipment they should not see, through both the on-screen filter and the Excel export. A 60-second dashboard hang turned out to be a 60 MB payload loaded on every visit and never used.
Move one thing at a time, and gate every step
The core discipline: each phase changes exactly one concern, and no phase closes without passing the same formal bar. Laravel 6 to 8 is not one step, it is two, each separately gated. PHP 7.4 to 8.1 is two. The Vue 3 migration is three checkpoints. There is always a working, verified application between any two steps, never a six-week window where the app does not run.
Phase 4.5 does not appear in the original thirteen. It exists because scoping the PHP 8 upgrade surfaced a hard blocker: the abandoned login library had no released version that runs on both PHP 8 and Laravel 8 at the same time. There was no version of the plan where it survived. The choice was to patch a dead dependency or pull forward a replacement already on the roadmap, and the replacement won, executed immediately on the then-stable build where exactly one variable was changing against otherwise-proven ground. Browser-stored tokens became protected server-side sessions, a genuine security improvement, and login, logout, “remember me,” and password reset behaved identically for every user.
Doing it early was the whole point. An authentication cutover handled as a deliberate, isolated, gated phase on a stable build is a controlled change. Discovering it midway through a PHP upgrade is an incident.
The gate catching things is the system working
Across the gated phases, the live walkthroughs, not the automated suite, caught real defects that would otherwise have reached production: a build configuration pointing part of the app at the production web address, anonymous visitors hitting a server error instead of a login redirect, every date picker rendering blank after the bundler change.
The clearest case came at the Vue 3 checkpoint. A full walkthrough of every module found six front-end bugs the previous checkpoint had missed, including two validation rules on the Service Record screen that had silently stopped running. When modern components replaced old ones, some legacy instructions were left pointing at page elements that no longer existed, and they failed silently. Nothing errored. The instruction just quietly stopped working, and automated tests written against the new components pass cleanly. Only a human driving the real app on real data finds that. Every fix now carries a test that was deliberately proven capable of failing, and the codebase was swept for the same class of problem.
None of it reached a user. All of it existed only in the upgrade branch. That is what the gates are for.
Decisions written before the code, not after
More than thirty architecture decision records were written across the project, each following the same rule: the record is written and approved before the code, never after. Accepted records are never edited, they are superseded.
That matters more than it sounds on a preservation-constrained project. When the correct answer is “do not change that,” you need a written reason that survives the next developer’s good intentions, including future you.
The same discipline applies to open questions. Where a question affects what a saved record means, the answer is not assumed. The clearest example: a technician who types a serial number to look it up is currently recorded as having scanned it. That behavior is identical in the live legacy app, and it has deliberately not been “fixed,” because changing it changes what saved records mean. That is the client’s call, not the developer’s.
Every week I know exactly where things stand: what moved, what's blocking, and what's next. I've never once had to chase Travis for an update. Meanwhile the application my whole business runs on has been brought up to date underneath us, and my team hasn't had to change how they work or lose a single record. That's the part I didn't think was possible.
Questions this raises
Why upgrade the legacy app instead of rebuilding it?
A rebuild had already been attempted and stalled at roughly 18%. On a system carrying hundreds of thousands of live records and a legally meaningful audit trail, the real specification lives in the running application, not in any document. An incremental upgrade preserves that specification by construction: the app never stops working, so parity never has to be reconstructed from memory.
How do you move five major framework versions without breaking a production database?
By treating the schema as an immovable constraint rather than something to optimize around, and enforcing that with automated guard tests that fail if a preserved column or the audit table is ever renamed. Every phase moves exactly one concern, and no phase closes until a live walkthrough confirms the database and audit trail behave identically.
What is a phase-green gate?
A written checklist a phase must pass before it can be tagged complete: the full automated suite green locally and in CI, a live smoke walkthrough in a real browser, legacy parity confirmed, database and audit integrity re-verified, and a recorded QA sign-off. The gate produces a git tag, which makes every phase independently reviewable and revertible.
Did users have to change how they work?
No. Feature parity was the governing constraint throughout. Login, workflows, screens, validation rules, report exports, and even bookmarked URLs behave the same. The visual redesign is deliberately scheduled as its own later phase so the framework migration was never entangled with a change in look and feel.
What happened to the audit trail?
It was preserved unbroken, roughly 975,000 historical rows, still readable and consistent with rows written after the upgrade. The auditing library was version-bumped through each framework phase and re-verified live at multiple gates by editing a real record and confirming the resulting entry. The audit table definition is identical before and after.
Would this approach work for my legacy application?
It fits a specific profile: a business-critical app that still works, real data you cannot afford to lose or migrate, no test coverage, and a stack several versions behind. If a rebuild has already been attempted and stalled, that is a strong signal. If the app is genuinely small, or the data is disposable, a rebuild may still be the right call.
Have something like this to build?
Tell me what you're working with and where it's stuck. If it's a fit, we'll book a scoping call - no obligation, and I respond personally.
Let's Talk →