Home Services Blog Developers Job Board Find Jobs with AI Add Profile
•••• •••• •••• 4242
Card Holder
John Doe
Expires
12/27
Active Subscription · Pro Plan
SaaS · Billing

STRIPE
SUBSCRIPTIONS
WITH CASHIER

Implement SaaS billing with Stripe + Laravel Cashier. Covers plans, trials, webhooks, invoices and cancellations — complete production-ready tutorial.

👨‍💻
Sonu Sahani
Full Stack Developer · CodeCraft Systems
📅 Dec 22, 2024 ⏱ 11 min read 👁 3.2k views
Stripe Cashier SaaS Billing Webhooks Laravel 11 Trials

Why Laravel Cashier?

Building SaaS billing from scratch — handling Stripe checkout sessions, webhook verification, subscription state, invoice PDFs, trial logic — is weeks of work and easy to get wrong. Laravel Cashier wraps all of this into an elegant API that plugs directly into your Eloquent User model.

With Cashier you get: one-line subscription creation, automatic webhook handling, built-in trial periods, grace period cancellations, invoice generation, proration, and coupon support — all battle-tested and maintained by the Laravel team.

📦 Stack Used

Laravel 11, PHP 8.3, laravel/cashier v15, Stripe API v3, MySQL 8. Assumes Sanctum auth already configured. Uses Stripe test mode — swap keys for production.

Subscription Lifecycle

🔬
Trial
Active
💳
Past Due
Canceled
Resumed

Install & Configure Cashier

terminal
# Install Cashier
composer require laravel/cashier

# Publish + run migrations (creates subscriptions, subscription_items tables)
php artisan vendor:publish --tag="cashier-migrations"
php artisan migrate

# Install Stripe CLI for local webhook testing
brew install stripe/stripe-cli/stripe
stripe login
.env — Stripe keys
STRIPE_KEY=pk_test_xxxxxxxxxxxxxxxxxxxx       # Publishable key
STRIPE_SECRET=sk_test_xxxxxxxxxxxxxxxxxxxx   # Secret key
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxx       # From Stripe dashboard or CLI
CASHIER_CURRENCY=usd
CASHIER_CURRENCY_LOCALE=en
app/Models/User.php
use Laravel\Cashier\Billable;

class User extends Authenticatable
{
    use Billable;   // ← this one trait adds everything

    // Now User has: subscribe(), subscription(), invoices(),
    // onTrial(), subscribed(), cancel(), swap() etc.
}

Define Pricing Plans

Create your products and prices in the Stripe Dashboard (or via API). Copy the price_xxx IDs into your config. Never hardcode plan details in code — keep them in config so you can change prices without a deploy.

STARTER
$9
/ month
  • 5 Projects
  • 10 GB Storage
  • Email Support
  • API Access
  • Custom Domain
BUSINESS
$79
/ month
  • Unlimited Projects
  • 1 TB Storage
  • 24/7 Support
  • API Access
  • Custom Domain
config/plans.php
return [
    'starter'  => [
        'name'       => 'Starter',
        'stripe_id'  => 'price_1234567890starter',   // from Stripe dashboard
        'price'      => 9,
        'trial_days' => 14,
    ],
    'pro'      => [
        'name'       => 'Pro',
        'stripe_id'  => 'price_1234567890pro',
        'price'      => 29,
        'trial_days' => 14,
    ],
    'business' => [
        'name'       => 'Business',
        'stripe_id'  => 'price_1234567890business',
        'price'      => 79,
        'trial_days' => 14,
    ],
];

Stripe Checkout & Subscribe

The cleanest approach: use Stripe Checkout (hosted payment page) — handles card validation, 3D Secure, SCA compliance, and saves you building a card form.

SubscriptionController.php
class SubscriptionController extends Controller
{
    /** Create Stripe Checkout session → redirect to hosted page */
    public function checkout(Request $request, string $plan): RedirectResponse
    {
        $planConfig = config("plans.{$plan}");
        abort_unless($planConfig, 404);

        // Ensure user has a Stripe customer record
        $request->user()->createOrGetStripeCustomer([
            'email' => $request->user()->email,
            'name'  => $request->user()->name,
        ]);

        // Build checkout session with trial
        return $request->user()
            ->newSubscription('default', $planConfig['stripe_id'])
            ->trialDays($planConfig['trial_days'])
            ->allowPromotionCodes()
            ->checkout([
                'success_url' => route('billing.success'),
                'cancel_url'  => route('billing.cancel'),
                'metadata'    => ['plan' => $plan],
            ]);
    }

    /** Swap plan (upgrade/downgrade) */
    public function swap(Request $request, string $plan): JsonResponse
    {
        $planConfig = config("plans.{$plan}");
        abort_unless($planConfig && $request->user()->subscribed('default'), 422);

        // Swap immediately with proration
        $request->user()
            ->subscription('default')
            ->swap($planConfig['stripe_id']);

        return $this->success(null, "Switched to {$plan} plan");
    }

    /** Cancel — at end of billing period (grace period) */
    public function cancel(Request $request): JsonResponse
    {
        $request->user()->subscription('default')->cancel();
        return $this->success(null, 'Subscription will cancel at period end');
    }

    /** Resume a canceled (on grace period) subscription */
    public function resume(Request $request): JsonResponse
    {
        $request->user()->subscription('default')->resume();
        return $this->success(null, 'Subscription resumed');
    }
}

Webhooks — The Critical Part

Webhooks are where most Cashier implementations break in production. Stripe sends events to your server for every billing lifecycle change — you must handle these or your subscription state will be out of sync.

🚨 Most Common Production Bug

Forgetting to handle customer.subscription.deleted means users keep access after their subscription expires. Always verify webhook signatures and handle every critical event.

Stripe EventWhat HappenedAction
checkout.session.completedUser paid, subscription starts✓ Activate user
customer.subscription.updatedPlan changed, trial ended↻ Sync subscription
customer.subscription.deletedSubscription expired/cancelled✗ Revoke access
invoice.payment_succeededMonthly renewal paid✓ Extend period
invoice.payment_failedCard declined⚠ Email user
customer.subscription.trial_will_endTrial ends in 3 days⚠ Send reminder

Cashier automatically handles most webhook events. Register the built-in webhook route, and extend the WebhookController for custom logic:

routes/api.php
use Laravel\Cashier\Http\Controllers\WebhookController;

// Cashier handles all Stripe events automatically on this route
Route::post('/stripe/webhook', WebhookController::class)
    ->name('cashier.webhook');

// ⚠ Exclude from CSRF middleware in bootstrap/app.php or VerifyCsrfToken:
// protected $except = ['/stripe/webhook'];
app/Http/Controllers/StripeWebhookController.php
use Laravel\Cashier\Http\Controllers\WebhookController as CashierWebhook;

class StripeWebhookController extends CashierWebhook
{
    /** Trial ending soon — send reminder email */
    public function handleCustomerSubscriptionTrialWillEnd(array $payload): Response
    {
        $user = $this->getUserByStripeId($payload['data']['object']['customer']);

        if ($user) {
            Mail::to($user)->send(new TrialEndingEmail($user));
        }

        return $this->successMethod();
    }

    /** Payment failed — notify user to update card */
    public function handleInvoicePaymentFailed(array $payload): Response
    {
        $user = $this->getUserByStripeId($payload['data']['object']['customer']);

        if ($user) {
            Mail::to($user)->send(new PaymentFailedEmail($user));
        }

        return $this->successMethod();
    }
}
terminal — test webhooks locally
# Forward Stripe events to your local server
stripe listen --forward-to localhost:8000/stripe/webhook

# Test specific events:
stripe trigger customer.subscription.deleted
stripe trigger invoice.payment_failed

Subscription Gates & Middleware

Gate your features based on subscription status using Laravel's built-in helpers. Never query Stripe on every request — Cashier caches subscription state in your database.

app/Http/Middleware/RequiresSubscription.php
class RequiresSubscription
{
    public function handle(Request $request, Closure $next, string ...$plans)
    {
        $user = $request->user();

        // Allow trial users full access
        if ($user->onTrial('default')) {
            return $next($request);
        }

        // Check active subscription to specific plan
        if (!empty($plans)) {
            foreach ($plans as $plan) {
                if ($user->subscribedToPrice(config("plans.{$plan}.stripe_id"))) {
                    return $next($request);
                }
            }
        }

        // Any active subscription
        if ($user->subscribed('default')) {
            return $next($request);
        }

        return response()->json(['message' => 'Subscription required'], 402);
    }
}

// Usage in routes:
// Route::middleware('subscribed:pro,business')->group(fn() => ...);

Invoices & Billing Portal

BillingController.php
class BillingController extends Controller
{
    /** Download invoice PDF */
    public function downloadInvoice(Request $request, string $invoiceId): Response
    {
        return $request->user()->downloadInvoice($invoiceId, [
            'vendor'  => 'CodeCraft Systems',
            'product' => 'Pro Subscription',
        ]);
    }

    /** List all invoices (for billing history page) */
    public function invoices(Request $request): JsonResponse
    {
        $invoices = $request->user()->invoices()->map(fn($inv) => [
            'id'     => $inv->id,
            'date'   => $inv->date()->toDateString(),
            'amount' => $inv->total(),
            'paid'   => $inv->paid,
            'pdf'    => route('billing.invoice.download', $inv->id),
        ]);

        return $this->success($invoices);
    }

    /** Redirect to Stripe's hosted billing portal (update card, cancel, etc.) */
    public function portal(Request $request): RedirectResponse
    {
        return $request->user()->redirectToBillingPortal(
            route('dashboard')   // return URL after portal
        );
    }
}
✅ Use Stripe Billing Portal

The redirectToBillingPortal() method sends users to Stripe's hosted portal — they can update cards, view invoices, and cancel all without you writing a single UI component. Enable it in your Stripe Dashboard under Settings → Billing → Customer portal.

Check Subscription Status

Cashier helper methods cheatsheet
$user = Auth::user();

// Is subscribed to any plan?
$user->subscribed('default');               // true

// Is on trial?
$user->onTrial('default');                  // true during trial
$user->onGenericTrial();                     // true if trial, no subscription needed

// Is subscribed to a specific price?
$user->subscribedToPrice('price_pro_id');  // true

// Is subscription cancelled but still on grace period?
$user->subscription('default')->onGracePeriod(); // true

// Get subscription end date
$user->subscription('default')->ends_at;   // Carbon or null

// Get Stripe subscription object
$user->subscription('default')->asStripeSubscription();

// Full status check for API response
return [
    'subscribed'    => $user->subscribed('default'),
    'on_trial'      => $user->onTrial('default'),
    'on_grace'      => $user->subscription('default')?->onGracePeriod(),
    'trial_ends_at' => $user->trialEndsAt('default')?->toDateString(),
    'renews_at'     => $user->subscription('default')?->asStripeSubscription()->current_period_end,
];
⚠️ Always Check onGracePeriod()

When a user cancels, subscribed() returns false but they still paid until the period ends. Always check onGracePeriod() too — or use subscribed() || onGracePeriod() to allow access until their paid period truly expires.

🏁 Key Takeaways

Cashier + Stripe Checkout is the fastest path to production SaaS billing. You get PCI compliance, 3D Secure, global payment methods, and SCA handling for free by delegating to Stripe's hosted UI.

Webhooks are non-negotiable — local state drifts from Stripe without them. Use stripe listen in development and register your production webhook URL in the Stripe Dashboard under Developers → Webhooks.

The Billing Portal is underusedredirectToBillingPortal() replaces an entire billing settings UI. Let Stripe handle card updates, invoice history, and cancellation. Your job is just the product.

SHARE THIS ARTICLE: 𝕏 Twitter 💼 LinkedIn
👨‍💻
Sonu Sahani
Full Stack Developer · CodeCraft Systems

6+ years building SaaS products with Stripe + Laravel for UK, USA & global clients. Built billing systems handling £500k+ ARR using Cashier — this guide is from real production implementations at Lifelancer (UK).