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_level threshold

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

AreaBeforeAfter
Plan source of truthecom_products DB table + $static_product (~460 lines)config/plans.php (124 lines)
Stripe clients5 separate instantiations across 5 files1 singleton via PlanServiceProvider
Pricing tableTwo Blade partials (~700 lines each)One generic partial (296 lines)
Access controlNamed methods at 31 call sitescan_access($capability) with config thresholds
Adding a planDB entry + CRUD update + both pricing tables + named methodsConfig entry + Stripe product
Env-specific plan configManual DB edits per environmentAPP_ENV condition in config/plans.php
Dead codesubscribe_partners() (~650 lines)Removed

What Was Delivered

  • config/plans.php defining a 2-tier plan structure with test and live Stripe Price IDs per plan
  • PlanService singleton with zero database queries for plan data
  • PlanServiceProvider consolidating the StripeClient into a single injectable instance
  • can_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 separation
  • ecom_products table 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.

Tags: Laravel Stripe Refactoring Legacy-code Saas

Related Posts