Introduction: The Checkout Page Was Never the Hard Part#
Every subscription billing integration starts the same way: wire up Stripe Checkout, redirect on success, flip a is_subscribed boolean, ship it. It works the first time a customer signs up. It works in the demo. Then real usage starts, and the questions that never came up in the sandbox all land at once. A customer upgrades mid-cycle - do they get charged the difference today, or does it wait for renewal? A customer downgrades from a 50-screen plan to a 10-screen plan while they're actively using 34 screens - what happens to the other 24? A card expires and the renewal fails - does access vanish instantly, or is there a grace period? A webhook for invoice.payment_failed arrives twice because Stripe retries delivery - does your dunning logic double-send the "your card was declined" email?
None of that is an edge case. It's the actual job. I've built and operated tier-based subscription billing across three different SaaS products: MindWrite AI, a subscription-based AI writing tool where content generation itself is gated by plan tier and monthly credit limits; SignageFlow, a cloud-based digital signage platform where pricing scales by screen count and customers move up and down that scale constantly as their screen fleets grow or shrink; and SafetySpace, the AI-powered safety management platform I run as CTO, where business customers subscribe at different tiers that unlock different customization depth - different form builders, different AI generation limits, different workflow complexity. Three different pricing shapes - usage-based, seat/resource-based, and feature-tier-based - but the same underlying architecture problem: how do you keep a customer's actual entitlements in the product perfectly in sync with what Stripe (or any billing provider) believes they're paying for, at every point in a billing cycle that's constantly changing underneath you.
This is a breakdown of how to architect subscription billing in Laravel so it survives upgrades, downgrades, failed payments, and the webhook chaos that a two-tier "free vs. paid" demo never has to deal with.
Architecture: Stripe Is the Source of Truth for Money, Your Database Is the Source of Truth for Access#
The mindset shift that matters most: never let your application decide what a customer is entitled to by re-deriving it from a cached Stripe status you read at login. Billing state changes asynchronously, outside the request that's checking it - a payment can fail, a plan can change, a subscription can cancel, all while a user is mid-session. Your database needs its own explicit, current record of entitlements, kept in sync by webhooks, not by hoping the frontend re-checks Stripe often enough.
1. Model the subscription and its tier as separate, explicit concepts#
The mistake I inherit most often is a users table with a stripe_plan string column that the rest of the app pattern-matches against (if ($user->stripe_plan === 'pro')). That falls apart the moment pricing changes, because now "pro" means something different for customers who signed up in March versus customers who signed up in September.
class SubscriptionTier extends Model
{
protected $casts = ['limits' => 'array', 'feature_flags' => 'array'];
}
class Subscription extends Model
{
protected $casts = [
'current_period_end' => 'datetime',
'cancel_at_period_end' => 'boolean',
];
public function tier(): BelongsTo
{
return $this->belongsTo(SubscriptionTier::class);
}
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
}SubscriptionTier is a versioned record of what a plan actually includes - screen limits, AI generation credits, seat counts, feature flags - independent of Stripe's own price object. A pricing change becomes a new SubscriptionTier row, not a global find-and-replace across every if statement that checks plan name.
2. Never trust the client-side redirect from checkout - wait for the webhook#
Stripe Checkout's success redirect tells you the customer's browser reached the success URL. It does not guarantee the payment actually settled, and it never fires at all for renewals, dunning recoveries, or admin-initiated plan changes. The only reliable source of truth is the webhook stream, processed idempotently - the same discipline that matters for any webhook, but here the cost of getting it wrong is a customer with real access to a product they didn't pay for, or worse, one who paid and got locked out.
Route::post('/webhooks/stripe', function (Request $request) {
$event = Webhook::constructEvent(
$request->getContent(),
$request->header('Stripe-Signature'),
config('services.stripe.webhook_secret')
);
if (ProcessedWebhookEvent::where('event_id', $event->id)->exists()) {
return response()->json(['status' => 'already_processed']);
}
DB::transaction(function () use ($event) {
ProcessedWebhookEvent::create(['event_id' => $event->id]);
SubscriptionWebhookHandler::dispatchFor($event);
});
return response()->json(['status' => 'ok']);
});The success page just shows a "setting up your account" state and polls your own API until the webhook has actually landed and flipped the subscription record - it never grants access itself.
3. Apply downgrades at period end, not immediately#
An upgrade should take effect right away - the customer is paying more, give them the value immediately, and let Stripe prorate the difference on the next invoice. A downgrade is the opposite: applying it immediately means a customer who paid for the full month of the higher tier loses access to what they already paid for. On SignageFlow, a customer stepping down from a 100-screen plan to a 25-screen plan keeps the 100-screen limits until the current period ends, with the new tier scheduled to take effect at renewal:
class ChangeSubscriptionTier
{
public function downgrade(Subscription $subscription, SubscriptionTier $newTier): void
{
Stripe::subscriptions()->update($subscription->stripe_id, [
'items' => [['id' => $subscription->stripe_item_id, 'price' => $newTier->stripe_price_id]],
'proration_behavior' => 'none',
]);
// Entitlements don't change yet - only the pending intent does
$subscription->update([
'pending_tier_id' => $newTier->id,
'pending_tier_effective_at' => $subscription->current_period_end,
]);
}
public function upgrade(Subscription $subscription, SubscriptionTier $newTier): void
{
Stripe::subscriptions()->update($subscription->stripe_id, [
'items' => [['id' => $subscription->stripe_item_id, 'price' => $newTier->stripe_price_id]],
'proration_behavior' => 'always_invoice',
]);
// Upgrades apply immediately - the customer paid for it now
$subscription->update(['tier_id' => $newTier->id]);
}
}The customer.subscription.updated webhook, when it lands at renewal, is what actually promotes pending_tier_id into tier_id - the scheduling logic above only expresses intent, the webhook handler is what makes it real.
Step-by-Step: Making Entitlements Actually Enforceable#
- Gate every feature and limit through one policy layer, backed by the tier's stored limits - never a hardcoded plan-name check. This is the same pattern that matters for any tier-gated feature: one place to change when pricing changes, not a scattered set of
if ($plan === 'enterprise')checks across the codebase.
class ScreenLimitPolicy
{
public function canAddScreen(Tenant $tenant): bool
{
$limit = $tenant->subscription->effectiveTier()->limits['max_screens'];
return $tenant->screens()->count() < $limit;
}
}effectiveTier() resolves pending_tier_id vs tier_id based on whether the pending change has actually taken effect yet - so a downgrade scheduled for next month never silently restricts today's usage.
- Handle overage at the downgrade boundary explicitly, don't just let the count go negative. When SignageFlow's scheduled downgrade actually takes effect and a tenant is using 34 screens against a new 25-screen limit, the product doesn't auto-delete 9 screens - it locks the oldest-created screens past the new limit into a read-only state and prompts the tenant to choose which to deactivate. Silently degrading data is worse than asking the customer to make the call.
- Treat
invoice.payment_failedas the start of a dunning state machine, not a single event. A failed card is not "cancel the subscription" - it's the first step of Stripe's own retry schedule (Smart Retries), and your app needs its own visible state through that process:
class HandlePaymentFailed
{
public function handle(StripeInvoicePaymentFailed $event): void
{
$subscription = Subscription::where('stripe_id', $event->subscriptionId)->firstOrFail();
$subscription->update([
'status' => 'past_due',
'payment_retry_count' => $subscription->payment_retry_count + 1,
]);
Notification::send($subscription->tenant->billingContact(), new PaymentFailedNotice($subscription));
}
}past_due is a distinct status from active and from canceled - access stays on with a visible billing warning through the retry window, and only flips to restricted access once Stripe's own retry schedule is exhausted and the subscription actually cancels. Cutting access on the first failed attempt punishes customers for a card that auto-updates two days later.
- Reconcile on a schedule, don't rely purely on webhooks arriving. Webhooks can be missed - a deploy mid-delivery, an endpoint briefly down. A nightly job that pulls each active subscription from Stripe's API and diffs it against your stored record catches drift before a customer notices it as a support ticket:
class ReconcileSubscriptions implements ShouldQueue
{
public function handle(): void
{
Subscription::where('status', '!=', 'canceled')->chunk(100, function ($subscriptions) {
foreach ($subscriptions as $subscription) {
$stripeSub = Stripe::subscriptions()->retrieve($subscription->stripe_id);
if ($stripeSub->status !== $subscription->status) {
SubscriptionWebhookHandler::syncFromStripeObject($stripeSub);
}
}
});
}
}- Count seats and usage from your own data, not from what the customer last selected at checkout. MindWrite AI's tier limits are enforced against actual generation counts logged at request time, the same discipline used for AI cost tracking generally - a plan says "500 generations/month," and the policy layer checks a real counter, not a value cached from the pricing page.
Pitfalls I've Seen Cost Real Revenue and Trust#
Granting access from the checkout redirect instead of the webhook. This is the single most common billing bug: a customer's browser hits the success URL, the frontend flips access on optimistically, and the payment itself fails moments later on Stripe's side (insufficient funds, 3D Secure abandoned). Access should always come from a confirmed webhook event, never the redirect alone.
Immediate hard cancellation on the first failed payment. Cards expire and get reissued constantly - that's normal, not fraud. A past_due grace period through Stripe's retry schedule, with proactive email, recovers revenue that an instant cutoff throws away along with the customer relationship.
Prorating downgrades the same way as upgrades. Immediately prorating a downgrade mid-cycle and refunding the difference sounds customer-friendly, but it also means the entitlement drop should happen immediately too - and doing that mid-cycle, mid-session, is how a customer loses access to screens or seats they're actively using right now, with no warning.
No idempotency on subscription webhooks. Exactly the same failure mode as any webhook integration - Stripe explicitly documents that events can be delivered more than once - but here a duplicate customer.subscription.updated re-applying the same tier change usually isn't destructive, while a duplicate invoice.payment_succeeded triggering a second "welcome, you're upgraded" email or a second usage-credit grant absolutely is.
Treating "canceled" as one state. cancel_at_period_end (customer canceled but retains access through what they paid for) and immediate cancellation are different states with different UX and different entitlement rules - collapsing them into a single is_canceled boolean loses the distinction the entire cancellation flow depends on.
No reconciliation job. Webhook-only sync feels sufficient until the one week it isn't - an endpoint misconfiguration, a brief outage during a deploy - and the drift it causes is invisible until a customer emails asking why they're locked out of a plan they're actively paying for.
Key Takeaways#
Subscription billing isn't the Stripe API call - it's the state machine your product runs on top of it so that money and access always agree, even while both are changing asynchronously. The billing systems that hold up in production share the same shape:
- Tiers and limits modeled as explicit, versioned records - never a plan-name string pattern-matched across the codebase
- Access granted from confirmed webhooks, never from a client-side checkout redirect
- Upgrades applied immediately, downgrades scheduled to the period boundary, with an explicit plan for what happens to usage that no longer fits
- Failed payments treated as a multi-step dunning state, not an instant cutoff
- A scheduled reconciliation job that catches drift webhooks alone will eventually miss
- Idempotent webhook handling, because Stripe retries delivery by design
I've built this pattern into a usage-gated AI subscription product, a resource-tiered signage platform where customers move up and down the pricing scale constantly, and a feature-tiered safety platform I run as CTO. The pricing model changes - usage-based, seat-based, feature-based - the discipline that keeps entitlements and billing in sync doesn't.
If you're adding subscription billing to an existing product, or inheriting one where renewal day is when the support tickets spike, get in touch about your billing architecture or see the full case studies from platforms where these patterns are running today.
Share this technical insight with your network
Share to LinkedIn or Facebook with key takeaways, featured media, and direct links.
Case Study: Stripe Lead Billing & Payment Automation
Built a secure Stripe payment workflow that allows lead-generation clients to save payment methods without an immediate charge, then automatically charge those methods when qualified leads are delivered. Integrated the payment lifecycle with GoHighLevel and designed webhook-driven status synchronization for successful and failed payments.
Related Technical Articles
View all articles →
Third-Party API Integration Best Practices: Lessons From 8 Years in Production
Most API integrations don't fail on day one - they fail at 2am, six months later. Here's how to build ones that don't.

From Database to Intelligence: Building an AI-Powered Chatbot with Laravel and OpenAI
Users needed answers, not database screens. I built an AI-powered Laravel chatbot that connects OpenAI with real application data.
Have a complex technical project in mind?
Available for full-stack engineering, performance audits, cloud deployments, and high-concurrency systems architecture.

