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.
My client runs a subscription SaaS that publishes real estate market data for 85 countries. When I came in to modernize their payment and access layer, I found a Stripe subscription refactor was badly overdue. The system had accumulated years of additions without any corresponding subtractions: plan definitions spread across eight-plus files, five separate Stripe client instantiations, named access methods that no longer described what they actually checked, and roughly 1,350 lines of pricing table Blade code split into two separate files that had to be kept in sync manually.
Adding a single plan required a database change, an admin CRUD update, edits to both pricing tables, and updates to whichever named helper methods the new tier needed. Staging and production had drifted because each environment's database had been edited independently.
The goal was to make the subscription system something one developer could understand and change in a single afternoon. I got there with a 124-line config file and a service layer, and I did not touch the database schema.
Key Takeaway
The net result: +960 lines added, -4,628 lines removed, across 56 files. The system is now simpler, more predictable, and easier to change. Shipping a new plan means adding one config entry and creating the corresponding Stripe product. No database migration required.
The Challenge: When Subscription Logic Lives Everywhere
When I first assessed the subscription system, what I found was not one broken thing but a pattern of small decisions compounded into real maintenance weight.
Plan data lived in an ecom_products database table, managed through an admin CRUD interface. Alongside this, a hardcoded $productNames array held ten entries, and a $static_product variable spanned roughly 460 lines of inline plan definitions inline in a service file. The two representations had drifted from each other, so what the database said a plan included was not always what the code enforced.
Access control was expressed through named methods: has_full_access(), has_download_access(), and a handful of others. These methods had been written to match a three-tier structure that no longer reflected what the plans actually offered. A method named has_full_access() checked for a world map access level of 10, while chart access required level 15, making the method name actively misleading. There were 31 call sites using these methods across the codebase.
The pricing table was another pain point. Two separate Blade partial files (one for monthly billing, one for yearly) totaled around 1,350 lines. Keeping them in sync after any plan change was a manual process with no guardrails. Feature flags (enable_new_pricing, enable_legacy_yearly) controlled which table rendered, adding more conditional logic to trace through.
Five different files each instantiated their own new StripeClient(...). A method called subscribe_partners(), roughly 650 lines long, was dead code that had never been removed.
The practical impact: every plan change was expensive in developer time and fragile by design. There was no single file to look at to understand what plans existed and what they offered.
My Approach: One Source of Truth
The principle behind this Stripe subscription refactor was straightforward. Subscription plan data changes infrequently (a few times a year at most), so it does not need to live in a database. Config files are version-controlled, reviewable, and cacheable. A database table is none of those things by default.
config/plans.php: 124 Lines That Replace Everything
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 plan entry includes:
- Display name and slug
- Stripe Price IDs for both test and live environments
- Display pricing for the billing table
- A numeric access level
- A list of features with a
min_levelthreshold
Switching between test and live Stripe Price IDs is handled by a single condition in the config:
'price_id' => env('APP_ENV') === 'production'
? 'price_live_xxxxxx'
: 'price_test_xxxxxx',
That is the entire staging-and-production story. No database records to sync between environments, no admin panel changes to remember before a deploy. The plan definitions are in version control, which means every change to them is reviewable, attributable, and reversible.
PlanService and a Consolidated StripeClient
I built a PlanService class and registered it as a singleton through a PlanServiceProvider. The service reads from config/plans.php and exposes whatever plan data the rest of the application needs. There are zero database queries involved in loading plan information.
The StripeClient also moved into PlanServiceProvider. The five separate instantiation points across the codebase now resolve from the service container instead:
app(StripeClient::class)->subscriptions->retrieve($id);
One client, one place to configure, one place to update if the API version ever needs to change.
Capability-Based Access Control
The named access methods were replaced with a single can_access($capability) check that compares a user's access level against the threshold defined in config:
public function can_access(string $capability): bool
{
$threshold = config("plans.capabilities.{$capability}.min_level");
return $this->access_level >= $threshold;
}
All 31 call sites in the codebase were updated to use this pattern. The method names no longer have to be correct descriptions of what they check, because the capabilities are now named by what they do (export_data, world_map, advanced_charts) and their thresholds live in config. Restructuring a capability means changing one number, not hunting for all the methods that reference a specific tier.
Pro Tip
Capability-based access control scales better than role-based named methods as plans evolve. When a product team wants to adjust what a plan includes, they change a threshold in one place rather than auditing method names across the codebase to find out which ones need updating.
One Generic Pricing Table
The two ~700-line Blade partials (monthly and yearly) were replaced by a single _plans_table.blade.php partial (296 lines). It derives whether a feature checkmark should display by comparing plan.access_level >= feature.min_level at render time, reading directly from the plan config. The billing period is a variable passed into the partial, not a reason to maintain two separate files.
The ecom_products Table Stays
I removed the application's dependency on ecom_products but did not drop the table. It stays in the database, untouched, as a rollback reference if anything needs to be verified against the previous state. This was a deliberate choice: delete the dependency, not the data. The table can be dropped in a later cleanup pass once confidence is high.
Warning
This config-file approach works well when plan data changes infrequently, which is true for most SaaS subscription tiers. If your product requires dynamic, admin-editable plans (custom pricing per customer, feature sets configurable per account), a config file will not cover that. The trade-off is simplicity in exchange for flexibility you probably do not need.
Before and After
| Area | Before | After |
|---|---|---|
| Plan source of truth | ecom_products DB table + $static_product (~460 lines) | config/plans.php (124 lines) |
| Stripe clients | 5 separate instantiations across 5 files | 1 singleton via PlanServiceProvider |
| Pricing table | Two Blade partials (~700 lines each) | One generic partial (296 lines) |
| Access control | Named methods at 31 call sites | can_access($capability) with config thresholds |
| Adding a plan | DB entry + CRUD update + both pricing tables + named methods | Config entry + Stripe product |
| Env-specific plan config | Manual DB edits per environment | APP_ENV condition in config/plans.php |
| Dead code | subscribe_partners() (~650 lines) | Removed |
What Was Delivered
config/plans.phpdefining a 2-tier plan structure with test and live Stripe Price IDs per planPlanServicesingleton with zero database queries for plan dataPlanServiceProviderconsolidating theStripeClientinto a single injectable instancecan_access($capability)replacing 31 call sites of named access methods- Generic
_plans_table.blade.php(296 lines) replacing two ~700-line Blade variants APP_ENV-based Stripe Price ID switching for clean staging and production separationecom_productstable preserved in place as a rollback reference
Results
The net change across 56 files: +960 lines added, -4,628 lines removed. A net reduction of 3,668 lines of subscription-related code.
What that means in practice:
- Adding a plan means adding one config entry and creating the corresponding Stripe product.
- Changing a price means editing the config and running
php artisan config:cache. - Changing what a capability allows means updating one threshold number.
None of these require a database migration, an admin panel edit, or a cross-file search to find all the places a change needs to be applied. Staging and production are now in sync by default because plan definitions live in version-controlled config, not in environment-specific database records that can diverge silently.
Key Takeaway
This was a Laravel 5.1 codebase running on PHP 7.1 that cannot be upgraded without breaking other dependencies. That constraint made discipline about where state lives more important, not less. Every piece of complexity that does not need to exist in a legacy system is complexity that will quietly cost developer time until someone removes it.
The safest refactor is often the one that removes the most. This project shipped to production in a single pull request, left the old schema in place as insurance, and reduced the surface area of the subscription system by more than 3,600 lines. The next developer who needs to add a plan will spend about five minutes on it.
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.
Modernizing a Legacy Real Estate SaaS Without a Rewrite
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.