How I Refactored a Laravel Stripe Subscription and Deleted 4,628 Lines
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.

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.

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_levelthreshold
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
| Area | Before | After |
|---|---|---|
| Plan source of truth | ecom_products DB table + $static_product (~460 lines) | config/plans.php (190 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 |
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.
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.
How I Built a Custom Webflow Booking System with Acuity and Beam APIs
I was hired by a software agency as a JavaScript specialist to build a booking system for their client who runs a luxury spa in Bangkok. Bridging Acuity Scheduling API and Beam Checkout API.
Building Real-Time Charts from Google Sheets: My Client-Side Scraping Approach
My client needed interactive, country-specific data charts embedded across their site. Rather than querying the server or paying for APIs, I built a client-side scraper to fetch data directly from Google Sheets in real-time.
Law Firm Redesign: Simplification Without Sacrificing Performance
After initial success with a custom WordPress build, the law firm's homepage had grown unwieldy. We refined the design, removed visual clutter, and improved scannability while maintaining the performance gains.