+1 (415) 599-8902

Subscription Billing in Laravel with Cashier and Stripe: Checkout, Webhooks, Proration and Dunning

Billing is the part of a SaaS application that everyone postpones and then rushes. It is also the part where mistakes are expensive and visible: a webhook you never handled leaves a cancelled customer with full access; a missing idempotency guard double-charges someone; a proration you did not test turns an upgrade into an angry support ticket.

Laravel Cashier (laravel/cashier-stripe) removes most of the boilerplate, but it does not make the decisions for you. This tutorial walks through a subscription billing implementation we would be comfortable putting in front of real customers: Stripe Checkout for the first payment, a billing portal for self-service, webhooks handled properly, plan upgrades with predictable proration, and a test suite that runs without touching the network.

Assumptions: Laravel 11, 12 or 13 on PHP 8.2+, a Stripe account in test mode, and a users table you are happy to treat as the billable entity.

Install and wire up the billable model

composer require laravel/cashier
php artisan vendor:publish --tag="cashier-migrations"
php artisan migrate

The migrations add stripe_id, pm_type, pm_last_four and trial_ends_at to users, plus subscriptions and subscription_items tables. Then add the trait:

use Laravel\Cashier\Billable;

class User extends Authenticatable
{
    use Billable;
}

Environment configuration:

STRIPE_KEY=pk_test_...
STRIPE_SECRET=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
CASHIER_CURRENCY=gbp
CASHIER_CURRENCY_LOCALE=en_GB

One decision to make early: who is billable? If your customers are companies rather than individuals, put Billable on Team (or Organisation, or Tenant) rather than User. Moving it later means migrating Stripe customer IDs across tables, which is doable but tedious. If you are unsure, billing the team is almost always the right answer for B2B.

Model your plans in code, prices in Stripe

Create the products and prices in the Stripe dashboard, then reference them from a config file rather than sprinkling price IDs through controllers:

// config/billing.php
return [
    'plans' => [
        'starter' => [
            'name' => 'Starter',
            'monthly' => env('STRIPE_PRICE_STARTER_MONTHLY'),
            'yearly' => env('STRIPE_PRICE_STARTER_YEARLY'),
            'seats' => 3,
        ],
        'growth' => [
            'name' => 'Growth',
            'monthly' => env('STRIPE_PRICE_GROWTH_MONTHLY'),
            'yearly' => env('STRIPE_PRICE_GROWTH_YEARLY'),
            'seats' => 25,
        ],
    ],
];

Entitlements — seat counts, feature limits, API quotas — belong in your application, not in Stripe metadata. Stripe should answer "is this customer paying, and for what price?" Your app answers "what may they do?" Keeping that boundary clean means a pricing experiment does not require a code deploy, and a Stripe outage does not decide whether users can log in.

Start the subscription with Checkout

Hosted Stripe Checkout handles card collection, 3D Secure, Strong Customer Authentication, tax and wallets for you. Cashier gives you a one-liner:

public function checkout(Request $request, string $plan, string $interval)
{
    $priceId = config("billing.plans.$plan.$interval");

    abort_if($priceId === null, 404);

    return $request->user()->newSubscription('default', $priceId)
        ->trialDays(14)
        ->allowPromotionCodes()
        ->checkout([
            'success_url' => route('billing.success').'?session_id={CHECKOUT_SESSION_ID}',
            'cancel_url' => route('billing.index'),
        ]);
}

Two things worth noting. First, 'default' is the subscription type, not the plan — keep it as default unless you genuinely sell two concurrent subscriptions (say, a platform plan and an add-on). Second, do not grant access on the success URL. The user can close the tab before it loads, and a bookmarked success URL is not proof of payment. The success page should say "thanks, we are activating your account"; the webhook grants the access.

Self-service with the billing portal

Card updates, invoices, cancellations and plan changes can all be delegated to Stripe's hosted portal, which saves you a surprising amount of UI:

Route::get('/billing/portal', function (Request $request) {
    return $request->user()->redirectToBillingPortal(route('billing.index'));
})->middleware(['auth'])->name('billing.portal');

Configure in the Stripe dashboard which actions the portal allows. A common setup: allow invoice history and payment method updates, allow cancellation at period end, but keep plan changes in your own UI so you can show your entitlements alongside the price.

Handle webhooks properly

This is where most implementations are weak. Cashier registers a /stripe/webhook route and keeps subscriptions in sync automatically, but your application still needs to react to state changes.

Exclude the route from CSRF (Laravel 11+ does this in bootstrap/app.php):

->withMiddleware(function (Middleware $middleware) {
    $middleware->validateCsrfTokens(except: ['stripe/*']);
})

Then listen for the event Cashier dispatches:

use Laravel\Cashier\Events\WebhookReceived;

class HandleStripeWebhook
{
    public function handle(WebhookReceived $event): void
    {
        $payload = $event->payload;

        match ($payload['type']) {
            'customer.subscription.deleted' => $this->revokeAccess($payload),
            'invoice.payment_failed' => $this->startDunning($payload),
            'invoice.payment_succeeded' => $this->clearDunning($payload),
            default => null,
        };
    }
}

Rules we apply on every billing project:

  1. Verify signatures. Keep STRIPE_WEBHOOK_SECRET set in every environment. Cashier's middleware rejects unsigned requests; do not disable it "temporarily" in staging.
  2. Be idempotent. Stripe retries. Handling invoice.payment_succeeded twice must not send two receipts or add two credit top-ups. Store the Stripe event ID and ignore ones you have already processed.
  3. Return 2xx fast. Do the work in a queued job. A webhook handler that calls three APIs will eventually time out and get retried, and retries of slow handlers are how duplicate side effects happen.
  4. Do not trust ordering. customer.subscription.updated can arrive before the checkout.session.completed that caused it. Handlers should be written against current state, not against an assumed sequence.
  5. Log everything. Persist raw payloads for at least a billing cycle. When a customer disputes what happened, the payload is your evidence.

Test locally with the Stripe CLI:

stripe listen --forward-to localhost:8000/stripe/webhook
stripe trigger invoice.payment_failed

Gate features on subscription state

Cashier's helpers read from your local subscriptions table, so they are fast and safe to call in a request cycle:

$user->subscribed('default');              // active or on trial
$user->subscription('default')->onTrial();
$user->subscription('default')->canceled(); // cancelled but maybe still in grace
$user->subscription('default')->onGracePeriod();
$user->subscription('default')->pastDue();

Express entitlements as one gate rather than scattering subscribed() checks through controllers and Blade files:

Gate::define('use-api', function (User $user) {
    return $user->subscribed('default')
        && ! $user->subscription('default')->pastDue();
});

Middleware then becomes ->middleware('can:use-api'), and a pricing change touches one file.

Upgrades, downgrades and proration

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

// Immediate upgrade, prorated: customer pays the difference now.
$subscription->swap(config('billing.plans.growth.monthly'));

// Downgrade at renewal: no credit note, no surprise invoice.
$subscription->noProrate()->swap(config('billing.plans.starter.monthly'));

// Seat-based billing.
$subscription->incrementQuantity(5);
$subscription->updateQuantity(12);

The convention that generates the fewest support tickets: upgrades take effect immediately and are prorated; downgrades take effect at the end of the current period. The customer never feels short-changed, and you never issue a credit note for a plan someone used all month. Whichever you choose, show the customer the amount before they confirm — $subscription->previewInvoice($priceId) (or Stripe's upcoming invoice endpoint) gives you the number to display.

Also apply the entitlement downgrade at the same moment as the billing change. If Growth allows 25 seats and Starter allows 3, downgrading a team with 12 active members needs a defined rule: block the downgrade, or deactivate the excess seats at period end. Decide it deliberately rather than discovering it in production.

Failed payments and dunning

Payments fail constantly — expired cards, insufficient funds, issuer declines. Configure Stripe's Smart Retries and let it email the customer, then mirror the state in your app:

protected function startDunning(array $payload): void
{
    $user = Cashier::findBillable($payload['data']['object']['customer']);

    $user?->forceFill(['dunning_started_at' => now()])->save();
}

Show an in-app banner while dunning_started_at is set — in-app notices convert far better than billing emails, which are routinely filtered. Give a grace window (Stripe's default retry schedule runs about two weeks) before restricting anything, and restrict progressively: read-only first, suspension last. Customers whose card simply expired should not lose data.

Tax, invoices and compliance

For EU/UK VAT or US sales tax, enable Stripe Tax and let Cashier pass it through:

// AppServiceProvider::boot()
Cashier::calculateTaxes();

Collect billing addresses at Checkout ('tax_id_collection' => ['enabled' => true] for B2B VAT numbers). Cashier can also render invoice PDFs:

return $request->user()->downloadInvoice($invoiceId, [
    'vendor' => 'Polish & Pixel',
    'product' => 'Platform subscription',
]);

Always authorise the download. findInvoice() scoped to the authenticated billable prevents the classic bug where an incrementing invoice ID exposes another customer's document.

Test it without hitting Stripe

Fast tests keep billing code maintained. Two layers:

Unit-ish tests fake the state and assert the behaviour you actually care about — entitlements:

it('blocks api access when the subscription is past due', function () {
    $user = User::factory()->create();

    $user->subscriptions()->create([
        'type' => 'default',
        'stripe_id' => 'sub_test',
        'stripe_status' => 'past_due',
        'stripe_price' => 'price_growth_monthly',
        'quantity' => 1,
    ]);

    expect($user->can('use-api'))->toBeFalse();
});

Webhook tests post a fixture payload at the route and assert the resulting state:

it('revokes access when the subscription is deleted', function () {
    $user = User::factory()->subscribed()->create(['stripe_id' => 'cus_test']);

    postJson('/stripe/webhook', [
        'id' => 'evt_test_1',
        'type' => 'customer.subscription.deleted',
        'data' => ['object' => ['id' => 'sub_test', 'customer' => 'cus_test']],
    ])->assertOk();

    expect($user->fresh()->subscribed('default'))->toBeFalse();
});

Send the same event twice in one test to prove your handler is idempotent. Keep a small set of genuine end-to-end tests against Stripe test mode, run on a schedule rather than on every commit, so a Stripe API change does not silently break checkout.

A pre-launch checklist

  • Webhook endpoint registered in live mode, with the live signing secret deployed.
  • Every webhook handler is queued, idempotent and covered by a test.
  • Access is granted by webhook, never by the Checkout success page.
  • Upgrade and downgrade proration behaviour is documented and shown to the customer before confirmation.
  • Entitlement downgrades have a defined rule when usage exceeds the new plan.
  • Dunning banner, grace period and progressive restriction are implemented.
  • Tax calculation and tax ID collection enabled if you sell across borders.
  • Invoice downloads are authorised against the authenticated billable.
  • Cancellation keeps access until ends_at, and resubscription within the grace period restores it.
  • Someone other than the original developer can explain what happens when a card fails.

Where teams get stuck

The recurring failure mode is not the happy path — Checkout works on day one. It is the long tail: an unhandled webhook, a proration nobody modelled, a downgrade that leaves eleven users on a three-seat plan, a Stripe customer ID attached to the wrong table after a "temporary" decision.

If you are adding subscriptions to an existing Laravel application, or your current billing has drifted out of sync with Stripe, get in touch. We do billing implementations, reconciliation audits, and migrations from bespoke Stripe integrations onto Cashier — and we can hand it back with tests you will keep running.