Laravel + Stripe in 2026: The Complete Billing Implementation That Doesn’t Cut Corners

Subscription plans, usage-based billing, trial periods, failed payment recovery, webhook verification, proration, invoice generation, and the Cashier gotchas that cost teams days of debugging — everything between “I added my Stripe key” and “billing actually works in production.”


The gap between “Stripe is connected” and “billing works correctly in production” is where most teams spend the debugging time they didn’t budget for. Cashier makes the happy path straightforward. The trial-to-paid transition, the failed payment recovery flow, the webhook idempotency, the proration calculation, the tax handling — these are where the complexity actually lives. This post covers the complete implementation: not just how to call Stripe’s API, but what to do when Stripe calls you back, what happens when a payment fails at 2am, and the specific Cashier behaviours that surprise teams who’ve only tested the happy path.


The Billable Model — Tenant, Not User

The most consequential architecture decision in a SaaS billing implementation: which model is the Stripe customer?

In a multi-tenant SaaS, billing is a tenant-level concern. One organisation (tenant) has one Stripe customer, one subscription, and one payment method on file. Individual users within the tenant don’t have separate billing relationships. A user can leave and rejoin the organisation without affecting the billing relationship. The billing contact can change without creating a new Stripe customer.

// ❌ Billable on User — creates problems in multi-tenant SaaS
class User extends Authenticatable
{
    use \Laravel\Cashier\Billable;
    // User leaves org → subscription disrupted
    // Billing contact changes → new Stripe customer needed
}

// ✅ Billable on Tenant — one customer per organisation
class Tenant extends Model
{
    use \Laravel\Cashier\Billable;

    // The stripe_id, pm_type, pm_last_four, trial_ends_at columns
    // are on the tenants table, not users
}

Migration for the Billable tenant:

composer require laravel/cashier
php artisan vendor:publish --provider="Laravel\Cashier\CashierServiceProvider"

The published migration adds Cashier columns. Run it on tenants, not users:

// Modify the published migration before running
Schema::table('tenants', function (Blueprint $table) {
    $table->string('stripe_id')->nullable()->index();
    $table->string('pm_type')->nullable();
    $table->string('pm_last_four')->nullable();
    $table->timestamp('trial_ends_at')->nullable();
});

Tell Cashier which model is billable:

// config/cashier.php
'model' => App\Models\Tenant::class,

The Stripe Product and Price Setup

Stripe’s model: Products are what you sell. Prices are how you charge for them. One product can have multiple prices — monthly, annual, different currencies.

// Create plans in Stripe Dashboard or via Stripe CLI
// Store the price IDs in config, not hardcoded

// config/billing.php
return [
    'plans' => [
        'free'       => null,  // no Stripe price — no subscription
        'starter'    => [
            'monthly'  => env('STRIPE_STARTER_MONTHLY_PRICE_ID'),
            'annual'   => env('STRIPE_STARTER_ANNUAL_PRICE_ID'),
        ],
        'pro'        => [
            'monthly'  => env('STRIPE_PRO_MONTHLY_PRICE_ID'),
            'annual'   => env('STRIPE_PRO_ANNUAL_PRICE_ID'),
        ],
        'enterprise' => [
            'monthly'  => env('STRIPE_ENTERPRISE_MONTHLY_PRICE_ID'),
            'annual'   => env('STRIPE_ENTERPRISE_ANNUAL_PRICE_ID'),
        ],
    ],
    'trial_days' => 14,
];

Subscription Creation — The Complete Flow

The subscription creation flow has five steps that all have to work for a subscription to be active:

1. Create or retrieve Stripe customer for the tenant
2. Attach the payment method to the customer
3. Set as the default payment method
4. Create the subscription
5. Handle Payment Intent confirmation (3D Secure / SCA)
class SubscriptionController extends Controller
{
    public function store(Request $request): JsonResponse
    {
        $request->validate([
            'payment_method_id' => ['required', 'string'],
            'price_id'          => ['required', 'string'],
            'billing_interval'  => ['required', Rule::in(['monthly', 'annual'])],
        ]);

        $tenant = $request->user()->currentTenant;

        // Ensure a Stripe customer exists for this tenant
        $tenant->createOrGetStripeCustomer([
            'name'  => $tenant->name,
            'email' => $request->user()->email,
            'metadata' => [
                'tenant_id' => $tenant->id,
                'tenant_slug' => $tenant->slug,
            ],
        ]);

        // Attach and set default payment method
        $tenant->addPaymentMethod($request->payment_method_id);
        $tenant->updateDefaultPaymentMethod($request->payment_method_id);

        try {
            $subscription = $tenant->newSubscription('default', $request->price_id)
                ->trialDays(config('billing.trial_days'))
                ->create($request->payment_method_id, [
                    'metadata' => ['tenant_id' => $tenant->id],
                ]);

            $tenant->update(['plan' => $this->planFromPriceId($request->price_id)]);

            return response()->json([
                'subscription' => [
                    'id'         => $subscription->stripe_id,
                    'status'     => $subscription->stripe_status,
                    'trial_end'  => $subscription->trial_ends_at?->toIso8601String(),
                ],
            ]);

        } catch (\Laravel\Cashier\Exceptions\IncompletePayment $e) {
            // Payment requires additional action (3D Secure)
            return response()->json([
                'requires_action'    => true,
                'payment_intent_id'  => $e->payment->id,
                'client_secret'      => $e->payment->clientSecret(),
            ], 402);
        }
    }

    private function planFromPriceId(string $priceId): string
    {
        foreach (config('billing.plans') as $plan => $prices) {
            if (is_array($prices) && in_array($priceId, $prices)) {
                return $plan;
            }
        }
        return 'starter';
    }
}

The SCA/3D Secure flow on the frontend:

// After receiving requires_action: true
const { error } = await stripe.confirmCardPayment(clientSecret)

if (error) {
  // Payment failed — show error to user
  showError(error.message)
} else {
  // Payment confirmed — subscription is active
  // Reload or redirect
  window.location.reload()
}

Skipping SCA handling means European users (and increasingly all users with SCA-compliant banks) can’t subscribe. The IncompletePayment exception is not an edge case — it’s the normal flow for a significant percentage of cards.


Trial Periods — The Cashier Gotchas

Trials are where most billing implementations have silent bugs.

Gotcha 1: onTrial() returns true even after the trial ends if no payment method is attached.

// ❌ This is always true until the subscription is paid, not just during trial
if ($tenant->onTrial()) {
    return true;
}

// ✅ Check trial end date directly
if ($tenant->trial_ends_at && $tenant->trial_ends_at->isFuture()) {
    return 'on_trial';
}

if ($tenant->subscribed('default') && $tenant->subscription('default')->active()) {
    return 'active';
}

return 'expired';

Gotcha 2: trialDays() is calculated from subscription creation, not from today.

When a user starts a trial and then immediately enters a payment method, the trial end date is still 14 days from when newSubscription()->trialDays(14) was called — not 14 days from when they confirmed the subscription. This is correct behaviour but surprising if your UI implies “14 days from today” at any point in the flow.

Gotcha 3: Webhooks fire for trial subscriptions.

When a subscription is created with a trial, Stripe fires customer.subscription.created immediately with status trialing. The subscription won’t charge until the trial ends. If your webhook handler marks subscription status as active on customer.subscription.created, you’ve incorrectly marked a trialing subscription as active.

// ✅ Preserve Stripe's subscription status exactly
public function handleCustomerSubscriptionCreated(array $payload): Response
{
    $subscription = $this->getSubscriptionFromPayload($payload);
    if (!$subscription) return $this->successMethod();

    $tenant = $subscription->billable;
    $stripeStatus = $payload['data']['object']['status']; // 'trialing', 'active', etc.

    $tenant->update([
        'stripe_status' => $stripeStatus,  // not hardcoded 'active'
        'plan'          => $this->planFromSubscription($payload['data']['object']),
    ]);

    return $this->successMethod();
}

Webhook Handling — The Right Architecture

Webhooks are where the real billing logic lives. The subscription is created on the frontend, but the state of the subscription is managed by webhooks. An application that trusts its local state without webhook verification will have billing state that drifts from Stripe’s state.

The webhook endpoint:

// routes/web.php — webhook endpoint bypasses CSRF
Route::post('/stripe/webhook', [WebhookController::class, 'handleWebhook'])
     ->name('cashier.webhook');

Signature verification — the step most tutorials skip:

Every incoming Stripe webhook must be verified using the webhook signing secret. Without verification, anyone can POST to your webhook endpoint and simulate billing events.

// Cashier handles this automatically when you extend the base controller
class WebhookController extends \Laravel\Cashier\Http\Controllers\WebhookController
{
    // Cashier's base controller verifies the Stripe-Signature header
    // before any handler method is called
    // Your subclass only needs to implement the specific event handlers
}

The signing secret is different from the API key. Set it in .env:

STRIPE_WEBHOOK_SECRET=whsec_...

In Stripe Dashboard → Developers → Webhooks → your endpoint → Signing secret.

The events to handle:

class WebhookController extends \Laravel\Cashier\Http\Controllers\WebhookController
{
    /**
     * Subscription created (including trials).
     * Status: 'trialing' or 'active'
     */
    public function handleCustomerSubscriptionCreated(array $payload): Response
    {
        $sub    = $payload['data']['object'];
        $tenant = $this->findTenantByStripeId($sub['customer']);

        if (!$tenant) return $this->successMethod();

        $tenant->update([
            'plan'          => $this->planFromPriceId($sub['items']['data'][0]['price']['id']),
            'stripe_status' => $sub['status'],
        ]);

        SubscriptionCreated::dispatch($tenant, $sub);

        return $this->successMethod();
    }

    /**
     * Payment succeeded — most important for subscription activation.
     */
    public function handleInvoicePaymentSucceeded(array $payload): Response
    {
        $invoice = $payload['data']['object'];

        // Only act on subscription invoices, not one-time charges
        if (empty($invoice['subscription'])) return $this->successMethod();

        $tenant = $this->findTenantByStripeId($invoice['customer']);
        if (!$tenant) return $this->successMethod();

        $tenant->update(['stripe_status' => 'active']);
        TenantPaymentSucceeded::dispatch($tenant, $invoice);

        return $this->successMethod();
    }

    /**
     * Payment failed — the most important handler for retention.
     */
    public function handleInvoicePaymentFailed(array $payload): Response
    {
        $invoice = $payload['data']['object'];
        $tenant  = $this->findTenantByStripeId($invoice['customer']);

        if (!$tenant) return $this->successMethod();

        $attemptCount = $invoice['attempt_count'];
        $nextAttempt  = $invoice['next_payment_attempt']
            ? \Carbon\Carbon::createFromTimestamp($invoice['next_payment_attempt'])
            : null;

        $tenant->update(['stripe_status' => 'past_due']);

        // Notify progressively — first failure is a gentle reminder
        // subsequent failures are more urgent
        match ($attemptCount) {
            1 => TenantPaymentFailed::dispatch($tenant, 'first_attempt', $nextAttempt),
            2 => TenantPaymentFailed::dispatch($tenant, 'second_attempt', $nextAttempt),
            3 => TenantPaymentFailed::dispatch($tenant, 'final_attempt', $nextAttempt),
            default => TenantPaymentFailed::dispatch($tenant, 'dunning_complete', null),
        };

        return $this->successMethod();
    }

    /**
     * Subscription cancelled — by user or by Stripe after dunning.
     */
    public function handleCustomerSubscriptionDeleted(array $payload): Response
    {
        // Call parent first — Cashier updates its local subscription records
        parent::handleCustomerSubscriptionDeleted($payload);

        $sub    = $payload['data']['object'];
        $tenant = $this->findTenantByStripeId($sub['customer']);

        if (!$tenant) return $this->successMethod();

        $tenant->update([
            'plan'          => 'free',
            'stripe_status' => 'canceled',
        ]);

        SubscriptionCancelled::dispatch($tenant);

        return $this->successMethod();
    }

    /**
     * Subscription updated — plan change, quantity change, or status change.
     */
    public function handleCustomerSubscriptionUpdated(array $payload): Response
    {
        parent::handleCustomerSubscriptionUpdated($payload);

        $sub    = $payload['data']['object'];
        $tenant = $this->findTenantByStripeId($sub['customer']);

        if (!$tenant) return $this->successMethod();

        $tenant->update([
            'plan'          => $this->planFromPriceId($sub['items']['data'][0]['price']['id']),
            'stripe_status' => $sub['status'],
        ]);

        return $this->successMethod();
    }

    private function findTenantByStripeId(string $stripeId): ?Tenant
    {
        return Tenant::where('stripe_id', $stripeId)->first();
    }

    private function planFromPriceId(string $priceId): string
    {
        foreach (config('billing.plans') as $plan => $prices) {
            if (is_array($prices) && in_array($priceId, $prices)) {
                return $plan;
            }
        }
        return 'starter';
    }
}

Webhook idempotency:

Stripe can send the same webhook more than once. Your handlers must be idempotent — running them twice for the same event must not cause duplicate effects.

// Idempotency pattern — check if the event has already been processed
public function handleInvoicePaymentSucceeded(array $payload): Response
{
    $eventId = $payload['id']; // Stripe event ID is unique

    // Skip if already processed
    if (Cache::has("stripe:event:{$eventId}")) {
        return $this->successMethod();
    }

    // Process the event
    $invoice = $payload['data']['object'];
    // ... handler logic ...

    // Mark as processed — 24 hours is sufficient
    Cache::put("stripe:event:{$eventId}", true, now()->addHours(24));

    return $this->successMethod();
}

Failed Payment Recovery — Dunning Done Right

Dunning is the process of recovering failed payments. Stripe’s Smart Retries attempt failed payments on an optimised schedule. But Stripe’s retries alone aren’t a complete dunning flow — you need to notify the customer and give them a path to update their payment method.

The email sequence:

Day 0 (payment fails, attempt 1): "Payment failed — update your card"
Day 3 (attempt 2): "Payment failed again — your account may be suspended"
Day 5 (attempt 3): "Final attempt — update payment to avoid suspension"
Day 7 (Stripe cancels subscription): "Your subscription has been cancelled"
// App\Listeners\HandleTenantPaymentFailed
class HandleTenantPaymentFailed
{
    public function handle(TenantPaymentFailed $event): void
    {
        $tenant      = $event->tenant;
        $attempt     = $event->attempt; // 'first_attempt', 'second_attempt', etc.
        $nextAttempt = $event->nextAttempt;

        $billingContact = $tenant->users()
            ->wherePivot('role', 'owner')
            ->first();

        if (!$billingContact) return;

        Mail::to($billingContact->email)
            ->send(new PaymentFailedMailable($tenant, $attempt, $nextAttempt));

        // Generate a billing portal session for the update payment link in the email
        $portalUrl = $tenant->billingPortalUrl(route('billing.index'));

        // Store the portal URL for the email to reference
        Cache::put(
            "billing:portal:{$tenant->id}",
            $portalUrl,
            now()->addHours(48)
        );
    }
}

The Stripe Billing Portal — the correct way to let users manage their own payment methods:

class BillingController extends Controller
{
    public function portal(Request $request): RedirectResponse
    {
        $tenant = $request->user()->currentTenant;

        $this->authorize('manage-billing', $tenant);

        return redirect($tenant->billingPortalUrl(route('billing.index')));
    }
}

The Stripe Billing Portal handles payment method updates, invoice history, subscription cancellation, and plan changes — without you building any of that UI. The portal is configurable in the Stripe Dashboard (which features to show, what cancellation flow to use, which plans are available for self-service upgrades).


Plan Changes and Proration

When a user upgrades or downgrades mid-billing-period, Stripe prorates the charges. Cashier handles the API calls; you need to decide the proration behaviour.

class PlanChangeController extends Controller
{
    public function update(Request $request): JsonResponse
    {
        $request->validate([
            'price_id' => ['required', 'string'],
        ]);

        $tenant = $request->user()->currentTenant;
        $this->authorize('manage-billing', $tenant);

        $subscription = $tenant->subscription('default');

        if (!$subscription || !$subscription->active()) {
            return response()->json(['message' => 'No active subscription.'], 422);
        }

        try {
            // Upgrade immediately with proration
            $subscription->swap($request->price_id);

            // Or: schedule the change for the next billing period (no proration)
            // $subscription->swapAndInvoice($request->price_id);

            // Or: downgrade at end of period (common for plan downgrades)
            // $subscription->swap($request->price_id, ['proration_behavior' => 'none']);

            $tenant->update([
                'plan' => $this->planFromPriceId($request->price_id),
            ]);

            return response()->json([
                'message' => 'Plan updated successfully.',
                'plan'    => $tenant->fresh()->plan,
            ]);

        } catch (\Laravel\Cashier\Exceptions\IncompletePayment $e) {
            return response()->json([
                'requires_action'   => true,
                'payment_intent_id' => $e->payment->id,
                'client_secret'     => $e->payment->clientSecret(),
            ], 402);
        }
    }
}

Proration behaviour options:

// Immediate proration (default) — charge/credit the difference immediately
$subscription->swap($newPriceId);

// Prorate but don't charge immediately — add to next invoice
$subscription->swap($newPriceId, ['proration_behavior' => 'create_prorations']);

// No proration — switch at the next billing period
$subscription->swap($newPriceId, ['proration_behavior' => 'none']);

The right choice depends on UX: upgrades usually want immediate proration (the user gets access now, pays now). Downgrades usually want end-of-period switching (the user paid for the current period, let them use it).


Usage-Based Billing

For products that charge based on consumption — API calls, seats, storage — Stripe’s usage metering API handles the tracking.

// config/billing.php — add metered price IDs
'metered' => [
    'api_calls' => env('STRIPE_API_CALLS_PRICE_ID'),
    'storage_gb' => env('STRIPE_STORAGE_PRICE_ID'),
],
class UsageMeteringService
{
    public function recordApiCall(Tenant $tenant, int $count = 1): void
    {
        $subscription = $tenant->subscription('default');
        if (!$subscription) return;

        // Find the subscription item for API calls
        $subscriptionItemId = $subscription->items()
            ->where('stripe_price', config('billing.metered.api_calls'))
            ->value('stripe_id');

        if (!$subscriptionItemId) return;

        // Report usage to Stripe
        $tenant->reportUsageFor(
            $subscriptionItemId,
            $count,
            now()->timestamp,
        );
    }

    public function recordStorageUsage(Tenant $tenant, float $gigabytes): void
    {
        $subscription = $tenant->subscription('default');
        if (!$subscription) return;

        $subscriptionItemId = $subscription->items()
            ->where('stripe_price', config('billing.metered.storage_gb'))
            ->value('stripe_id');

        if (!$subscriptionItemId) return;

        // SET action replaces the current period's usage (for storage)
        // INCREMENT adds to it (for API calls)
        $tenant->reportUsageFor(
            $subscriptionItemId,
            (int) ceil($gigabytes),
            now()->timestamp,
            'set',  // 'set' or 'increment'
        );
    }
}

Buffer usage reports:

Calling reportUsageFor on every API call creates a Stripe API call for every application API call. Buffer usage and report in batches:

// Track usage in Redis, flush to Stripe every minute
class BufferedUsageMeteringService
{
    public function increment(int $tenantId, string $metric, int $count = 1): void
    {
        $key = "usage:{$tenantId}:{$metric}:" . now()->format('Y-m-d-H-i');
        Cache::increment($key, $count);
        Cache::expire($key, 3600); // 1 hour TTL
    }

    public function flush(): void
    {
        // Called by a scheduled command every minute
        $tenants = Tenant::has('subscription')->get();

        foreach ($tenants as $tenant) {
            foreach (['api_calls', 'storage_gb'] as $metric) {
                $key   = "usage:{$tenant->id}:{$metric}:" . now()->subMinute()->format('Y-m-d-H-i');
                $count = (int) Cache::get($key, 0);

                if ($count > 0) {
                    app(UsageMeteringService::class)->reportApiCall($tenant, $count);
                    Cache::forget($key);
                }
            }
        }
    }
}

// Scheduled in routes/console.php
Schedule::call(fn () => app(BufferedUsageMeteringService::class)->flush())
    ->everyMinute()
    ->withoutOverlapping();

Invoice Generation and PDF Access

Stripe generates invoices automatically. Cashier provides access to them:

class InvoiceController extends Controller
{
    public function index(Request $request): JsonResponse
    {
        $tenant = $request->user()->currentTenant;
        $this->authorize('view-billing', $tenant);

        $invoices = $tenant->invoices();

        return response()->json([
            'invoices' => $invoices->map(fn ($invoice) => [
                'id'           => $invoice->id,
                'date'         => $invoice->date()->toIso8601String(),
                'total'        => $invoice->total(),
                'status'       => $invoice->status,
                'download_url' => route('billing.invoices.download', $invoice->id),
            ]),
        ]);
    }

    public function download(Request $request, string $invoiceId): Response
    {
        $tenant = $request->user()->currentTenant;
        $this->authorize('view-billing', $tenant);

        // Verify this invoice belongs to this tenant before serving
        $invoice = $tenant->findInvoice($invoiceId);

        if (!$invoice) {
            abort(404, 'Invoice not found.');
        }

        return $tenant->downloadInvoice($invoiceId, [
            'vendor'  => config('app.name'),
            'product' => 'Software Subscription',
        ]);
    }
}

Custom invoice line items and metadata:

// Add custom line items for one-time charges
$tenant->invoiceFor('Setup Fee', 15000); // $150.00 in cents

// Add metadata to invoices for accounting integration
$tenant->tab('Custom Development', 50000, [
    'invoice_settings' => [
        'metadata' => [
            'project_id' => '12345',
            'department' => 'Engineering',
        ],
    ],
]);

The Cashier Gotchas That Cost Teams Days

Gotcha 1: subscribed() returns true during the grace period after cancellation.

// ❌ This returns true even after cancellation if within the grace period
if ($tenant->subscribed('default')) {
    // User cancelled but is in grace period — this returns true
}

// ✅ Check the full state
if ($tenant->subscription('default')?->active()) {
    // Active includes grace period — is this what you want?
}

if ($tenant->subscription('default')?->recurring()) {
    // Recurring excludes cancelled subscriptions, even in grace period
}

The active() method returns true for active subscriptions AND for cancelled subscriptions still within their grace period. The recurring() method returns true only for non-cancelled subscriptions. Use recurring() when you want to exclude cancellations, active() when you want to include the grace period.

Gotcha 2: swap() doesn’t immediately update the local stripe_status column.

$subscription->swap($newPriceId);
// At this point: $tenant->subscription('default')->stripe_status is still the old value
// Cashier updates local records via webhook

// ✅ Refresh after swap
$subscription->fresh();
// Or rely on the webhook to update local state

Gotcha 3: Webhook events arrive out of order.

Stripe doesn’t guarantee webhook delivery order. A customer.subscription.updated event can arrive before the invoice.payment_succeeded event for the same billing cycle. Your handlers must be order-independent — they should set state based on the event payload, not assume the current state represents what came before.

// ❌ Assumes payment succeeded before this runs
public function handleCustomerSubscriptionUpdated(array $payload): Response
{
    // Might receive this before invoice.payment_succeeded
    // tenant.stripe_status might not be 'active' yet
}

// ✅ Set state from the event payload, regardless of current state
public function handleCustomerSubscriptionUpdated(array $payload): Response
{
    $sub = $payload['data']['object'];
    $tenant->update(['stripe_status' => $sub['status']]); // use Stripe's status
}

Gotcha 4: createOrGetStripeCustomer() doesn’t update existing customer details.

// This creates a new customer if none exists, but if one does exist,
// it returns the existing one WITHOUT updating name or email
$tenant->createOrGetStripeCustomer(['name' => $tenant->name, 'email' => $email]);

// ✅ Update explicitly when customer details change
if ($tenant->stripeId()) {
    $tenant->updateStripeCustomer(['name' => $tenant->name]);
}

Gotcha 5: The past_due status doesn’t prevent login.

Cashier has no built-in middleware that blocks access for past_due subscriptions. Teams often discover this when a customer’s card has been failing for weeks and they still have full access. Your RequireActiveSubscription middleware needs to explicitly handle past_due:

class RequireActiveSubscription
{
    public function handle(Request $request, Closure $next): Response
    {
        $tenant = $request->user()?->currentTenant;
        if (!$tenant) return $next($request);

        $status = $tenant->subscription('default')?->stripe_status;

        if (in_array($status, ['past_due', 'unpaid'])) {
            // Allow access to billing routes for recovery
            if ($request->routeIs('billing.*')) return $next($request);

            return redirect()->route('billing.index')
                ->with('warning', 'Your payment has failed. Please update your payment method.');
        }

        return $next($request);
    }
}

Testing Billing — The Stripe CLI Setup

# Install Stripe CLI
brew install stripe/stripe-cli/stripe

# Log in
stripe login

# Forward webhooks to local app
stripe listen --forward-to localhost:8000/stripe/webhook

# Trigger specific events for testing
stripe trigger customer.subscription.created
stripe trigger invoice.payment_failed
stripe trigger customer.subscription.deleted

The Stripe CLI prints the webhook signing secret for the local listener:

Your webhook signing secret is whsec_... (^C to quit)

Set this in .env as STRIPE_WEBHOOK_SECRET while testing locally. It’s different from the production webhook secret.

Test cards:

4242 4242 4242 4242  → Always succeeds
4000 0025 0000 3155  → Requires 3D Secure authentication
4000 0000 0000 9995  → Always declines (insufficient funds)
4000 0000 0000 0341  → Attaching succeeds, charge fails

Cashier’s test mode:

// In tests — mock Cashier's Stripe API calls
use Laravel\Cashier\Cashier;

Cashier::fake();

// Now Cashier won't make real Stripe API calls
$tenant->newSubscription('default', 'price_test_123')->create('pm_card_visa');

The Production Launch Checklist

# Before going live:

# 1. Switch to live mode keys
STRIPE_KEY=pk_live_...
STRIPE_SECRET=sk_live_...

# 2. Register the webhook endpoint in Stripe Dashboard
# Add /stripe/webhook as a webhook endpoint
# Select all events you handle
# Copy the signing secret to STRIPE_WEBHOOK_SECRET=whsec_live_...

# 3. Verify webhook signing is working
# Send a test event from Stripe Dashboard → should return 200

# 4. Test each handler with real cards in live mode
# Use a real card with $1 charge to verify the full flow

# 5. Set up Radar rules for fraud prevention
# Stripe Dashboard → Radar → Rules

# 6. Configure Smart Retries in Stripe Dashboard
# Billing → Settings → Retry schedule

# 7. Configure the customer portal
# Billing → Customer portal → Configure

# 8. Set up tax collection if applicable
# Stripe Tax or manual tax rates

The Billing State Machine

Every tenant’s billing state can be represented precisely:

States:
  free         → No subscription, no trial
  trialing     → Stripe status: trialing, trial_ends_at in future
  active       → Stripe status: active, subscription current
  past_due     → Stripe status: past_due, payment failing
  unpaid       → Stripe status: unpaid, dunning exhausted
  canceled     → Stripe status: canceled, subscription ended
  grace_period → Stripe status: canceled, current_period_end in future

Transitions:
  free → trialing          (newSubscription with trialDays)
  trialing → active        (invoice.payment_succeeded after trial)
  active → past_due        (invoice.payment_failed)
  past_due → active        (invoice.payment_succeeded after retry)
  past_due → unpaid        (exhausted retries)
  active → grace_period    (subscription cancelled, current period not expired)
  grace_period → canceled  (current period expires)
  canceled → active        (resubscription)

The webhook handlers maintain this state machine. The application reads the state and gates access accordingly. The two are decoupled — Stripe tells you what happened, you store the result, the application reads it.

Leave a Reply

Your email address will not be published. Required fields are marked *