Laravel Stripe subscription refactor deleting 4628 lines of legacy code

How I Refactored a Laravel Stripe Subscription and Deleted 4,628 Lines

Rol Torralba
April 16, 2026
Updated June 12, 2026
9 min read

Years of ever-changing client requirements had turned a Laravel subscription system into spaghetti code. Here is how I rebuilt it around a single config file and cut 4,628 lines.

In my previous post, I talked about developing a paywall and subscription system for my client who publishes global real estate market data. Over the years, it went through multiple changes, stacking feature after feature on top of one another. While everything ran smoothly on the front end, operation and maintainability took the hardest hit.

The system had accumulated years of additions without any corresponding subtractions: plan definitions spread across eight-plus files, five separate Stripe client instantiations, badly implemented access methods that only work for the current gating model, and roughly 1,350 lines of pricing table code split into two separate files that had to be kept in sync manually.

The goal was to make the subscription system something one developer could understand and change in a single afternoon. I got there with a 190-line config file and a Laravel service layer, without touching the database schema.

Developer Notes: One of the lessons from The Pragmatic Programmer by Andy Hunt and Dave Thomas is that this kind of rot can't be prevented by thorough planning alone. No matter how careful we are in the planning process, we cannot predict an uncertain future. This was my biggest mistake when starting the project: I tunnel-visioned on the requirements to keep the code small and self-contained.


The Challenge: When Subscription Logic Lives Everywhere

This is what a typical update process looked like across multiple surfaces:

  • To change a product price, production and staging price keys had to be updated through an admin panel for each pricing plan. Two trips to the browser, several times a month.
  • To update the design, two identical Laravel Blade templates had to be edited — one for each pricing plan. At least once a month.
  • Here's the worst part. To update the gating logic (e.g. for a new plan tier): create new access methods, refactor the core system, and update multiple Blade templates across the site. If the new plan tier was no longer needed, undo the template changes — but keep the new methods, just in case — to restore the original logic.

The biggest challenge was not the update process — I've been doing that for years — but the fact that the risk of something breaking during a large refactor only grows as the subscriber base and codebase get larger.

When I reassessed the subscription system, what I found was a pattern of small decisions compounded into real maintenance weight.

Plan data lived in a 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 in a service file. The two representations had drifted apart, so what the database said a plan included was not always what the code enforced.

Old code implementation

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 method calls like these 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 design (or copy) change was a manual process.

Feature flags 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.

Developer Notes: On feature flags and dead code. One sign that your codebase has grown rigid is the accumulation of dead code and feature flags — at least in my case. You only reach for a feature flag and keep dead code around when you feel you need to preserve the old path while introducing a new one, which shouldn't be necessary if the code was flexible enough to adapt in the first place. Don't make the same mistake I did.

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 — at most a few times a month — 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.

Plans configuration example code

config/plans.php: 190 Lines That Replace Everything

I wrote a single config/plans.php file (190 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:

'stripe_prices' => [
    'test' => [
        'monthly' => 'price_xxxxx',
        'yearly'  => 'price_xxxxx',
    ],
    'live' => [
        'monthly' => 'price_xxxxx',
        'yearly'  => 'price_xxxxx',
    ],
],

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($capability)
{
    $required = config('plans.capabilities.' . $capability, 0);
    return $this->is_subscribed() && $this->get_access_level() >= $required;
}

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, interactive_charts, view_data) 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 (190 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

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.

Diff view: old vs new

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.

Tags: LaravelStripeSubscriptionsRefactoringLegacy-codeSaas