My client's legacy Laravel 5.1 SaaS needed modernization across payments, search, and mapping — all on a codebase that cannot be upgraded without breaking dependencies. I overhauled all three layers in two weeks, without a rewrite and without a database migration.
I spent two weeks working on a legacy Laravel 5.1 codebase for a global real estate data publisher covering 85 countries. In that time I overhauled the subscription and paywall system, improved Algolia search precision so city-name queries matched the right country pages, and fixed an interactive world map that was rendering empty for certain metric categories. None of it required a framework upgrade, a database migration, or a rewrite.
That is the version of legacy SaaS modernization that actually ships. Not a ground-up rebuild that takes six months and carries its own risks. Not a "we will deal with it later" that leaves the technical debt compounding. A disciplined pass through the system that leaves each layer smaller, clearer, and easier to change than it was before.
Key Takeaway
The same principle drove every improvement on this project: reduce the number of places a developer needs to look to understand or change something. In the subscription system that meant a config file. In search it meant structured metadata. In the map it meant fixing execution order. Different layers, same discipline.
The codebase constraint mattered here. Laravel 5.1 on PHP 7.1 cannot be upgraded to a modern version without breaking other dependencies. That meant no escape hatch to a framework feature that would solve the problem for free. Every improvement had to work within the limits of what was already there.
What I Inherited
The client's codebase had the signature shape of a system that had grown organically over several years without a corresponding cleanup pass.
The subscription system was the most complex area. Plan definitions were scattered across eight-plus files: a database table, a hardcoded product names array, a 460-line variable of inline plan definitions, and two Blade pricing tables totaling around 1,350 lines that had to be kept in sync manually. Five different files each instantiated their own Stripe client. Access control was expressed through named methods (has_full_access(), has_download_access()) that had been written for a three-tier plan structure that the product had since evolved past, so the method names were no longer accurate descriptions of what they checked. Staging and production had drifted because plan data lived in the database, and each environment had been edited independently.
The search layer had a precision problem. Users searching for a city name like "Taguig" or "Cebu" were not finding the Philippines square-meter prices page where that city's data actually lived. The country page mentioned the city in body text, but Algolia was doing fuzzy matches against the full content and producing noise.
The world map had a rendering bug. When a user switched from a country-level metric (HPI change, for example) to a city-only metric (square-meter prices), the city bubble layer came up empty. The data was there. The map just was not showing it.
Rebuilding the Subscription System
The subscription refactor was the largest piece of work and the clearest example of the principle: plan data changes infrequently, so it does not need to live in a database.
I wrote a single config/plans.php file (124 lines) that serves as the sole definition of what plans exist, what they cost, and what access level they grant. Each entry includes the plan name, Stripe Price IDs for both test and live environments, display pricing, a numeric access level, and a list of features with minimum access thresholds. Switching between test and live Price IDs is a single APP_ENV condition inside the config. No database records to sync between environments, no admin panel changes to remember before a deploy.
The StripeClient moved into a PlanServiceProvider singleton, consolidating five separate instantiation points across the codebase into one injectable instance. A new PlanService class reads from the config and exposes plan data with zero database queries.
The named access methods were replaced with can_access($capability), which checks a user's access level against a threshold defined in config. All 31 call sites were updated to use this pattern. Restructuring a capability now means changing one number.
The two ~700-line pricing table Blade partials were replaced by a single 296-line generic partial that derives checkmarks from plan.access_level >= feature.min_level at render time. The old ecom_products database table was left in place, untouched. The dependency on it was removed; the data was not.
Net result: +960 lines added, -4,628 lines removed, across 56 files. Adding a plan now means adding one config entry and creating the Stripe product.
Pro Tip
When refactoring away from a database-driven configuration system, consider leaving the table in place rather than immediately dropping it. The data becomes a rollback reference and a sanity-check source. Once confidence in the new system is high, dropping it is a five-minute cleanup task. Dropping it prematurely and needing to restore it is not.
Fixing Algolia Search Precision
The search problem was a precision-versus-recall issue. Algolia was finding the right content but not ranking it first, because city names like "Taguig" or "Cebu" appeared in body text on multiple page types, and body-text matches are inherently noisy.
The fix was structural: rather than tuning Algolia's ranking algorithm, I changed what I was telling Algolia about each page.
I added an algolia:cities meta tag to country and continent data pages, listing the cities that have time-series data for the series being viewed on that page. Four cached helpers in TsData (one for country-series combinations, one for all cities in a country, and equivalents for continent pages) power the lookups without hammering the database on every page load.
I also removed the page content field from Algolia's searchable attributes entirely. Body-text matches were contributing more noise than signal. The structured metadata was doing the job more precisely than the full text ever could.
The first pass over-indexed. The city tags were emitting on article pages and HPI trend pages where city-level data was not the point, so searching a city name was still matching pages that were not useful. A follow-up commit restricted city emission to pages with an active city-level series query. Article and trend pages were left empty.
After both commits: city-name searches match the right country data page. Continent searches rank continent pages above country pages for continent-name queries. False matches on unrelated content pages are gone. The Algolia ranking algorithm was not touched.
Warning
Removing fields from Algolia's searchable attributes is a meaningful change that affects all queries, not just the ones you are trying to fix. Test on a staging index before pushing to production. In this case, removing the content field was the right call because the structured metadata covered every legitimate search pattern the site needed. That will not always be true.
Debugging the Mapbox Race Condition
The world map displays 38-plus real estate metrics across countries and cities on an interactive Mapbox globe. The bug was subtle: switching from a country-level metric to a city-only metric resulted in an empty map. The city bubble layer would render with no data.
The cause was an execution order problem in gpgWorldMap.js. When a country-level metric was active, a flag called citiesVisible was set to false. When the user switched to a city-only metric, the code path that loaded city data checked if (hasCityData && citiesVisible) before the branch that was supposed to reset citiesVisible to true. By the time the reset ran, setData() had already been called on an empty source.
The fix was to hoist the isCityOnly check above the data-loading block entirely. Force citiesVisible = true and update the toggle button's active state before setData() runs. The same commit also disabled the choropleth view radio button for city-only metrics, since choropleth rendering is not supported when only point data is available. The button gets :disabled CSS at 0.3 opacity with pointer-events: none, so users cannot select a view mode the data does not support.
One commit. City bubbles now render reliably on the first switch. The UI communicates what is possible, not just what is implemented.
The Pattern Across All Three
Each of these problems looked different on the surface, but the underlying cause was the same: something was being told about the system's state in too many places, or in the wrong order, or in a way that had drifted from reality.
The subscription system had plan state scattered across a database, an admin UI, inline variables, named methods, and two parallel Blade files. The fix was one config file.
The search index had city data buried in unstructured body content. The fix was structured metadata emitted from a controlled source, and removing the content field that was generating noise.
The world map had visibility state set before data was loaded. The fix was enforcing the correct order and making the UI reflect the constraint.
None of these required upgrading the framework. None required a database migration. None required rewriting the feature from scratch. They required understanding what was actually happening and reducing the number of moving parts until the system could be reasoned about clearly.
Results
Across the subscription refactor alone: 4,628 lines removed, 960 added, net reduction of 3,668 lines across 56 files. Plan management that previously required coordinated changes across multiple files and a database now requires a config entry and a Stripe product.
Algolia search now surfaces the correct country data page for city-name queries without tuning the ranking algorithm. The fix was feeding the index better information.
The world map renders city-level data reliably on metric switches. Users can no longer select a view mode the underlying data does not support.
Key Takeaway
A legacy codebase at this age has usually not been rewritten because rewriting it is genuinely risky. Dependencies are frozen, production behavior is assumed, and the cost of getting something wrong is high. The alternative is disciplined incremental improvement: identify where complexity has accumulated, reduce it, and leave each area cleaner than you found it.
That is what this project was. The codebase cannot run on a modern Laravel version, and it did not need to for any of these improvements to ship. The constraint was real. The work got done anyway.
If you are evaluating a developer for an inherited-codebase engagement, the question is not whether they can build something new in a modern stack. It is whether they can take ownership of something old, understand it accurately, and improve it without making it worse. That is a different skill, and it is what this project demonstrates.
Get a Free Website Audit
Find out what's slowing your site down, where the security gaps are, and what you can improve. Takes 30 seconds to request.
Related Posts
How I Built a Mapbox Globe for 38+ Real Estate Metrics
My client publishes real estate data for 80+ countries and wanted a single interactive view that could replace dozens of separate comparison tables. I built a Mapbox GL JS globe with 38+ switchable metrics, bubble and choropleth view modes, city drill-down, currency toggle, and pinned popups that deep-link into the country pages.
A Docker Stack That Rewrites WordPress URLs on First Boot
Every WordPress dev knows the problem: restore a prod database locally and your browser immediately redirects to the live site. I moved the URL rewrite into MySQL's init hook so it runs automatically on first boot — no manual steps, no redirect loops.
E-E-A-T Isn't a Plugin: Author Schema for a Law Firm WordPress Site
My law firm client's inner pages had no visible author attribution — a real problem for YMYL legal content. I added Schema.org Person microdata to the hero, intentionally bypassed the WordPress author field, and built a per-page ACF toggle for opt-out.
The Stripe Subscription Refactor That Deleted 4,628 Lines
My client's Stripe subscription system was spread across a database table, eight-plus files, five separate client instances, and two 700-line pricing tables. I replaced it with a single config file and a service layer, deleting 4,628 lines without touching the database schema.