L

Laravel Cashier Stripe

LaravelStripeSaaSPaymentsCashier

Laravel Cashier Stripe: Complete Subscription Billing Guide

Build production-ready subscription billing with Laravel Cashier and Stripe. Cover plans, trials, upgrades, webhooks, invoices, and handling edge cases.

Laravel Cashier Stripe: Complete Subscription Billing Guide

Laravel Cashier provides an elegant interface to Stripe's subscription billing services. This guide covers everything from basic setup to handling complex billing scenarios in production.

Installation and Setup

Install Cashier

bash
composer require laravel/cashier
php artisan migrate

Configure the Billable Model

php
// app/Models/Team.php (or User.php)
use Laravel\Cashier\Billable;

class Team extends Model
{
    use Billable;

    protected $casts = [
        'trial_ends_at' => 'datetime',
    ];
}

Environment Variables

env
STRIPE_KEY=pk_test_...
STRIPE_SECRET=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
CASHIER_CURRENCY=usd

Creating Subscription Plans

Define Plans in Stripe

First, create products and prices in Stripe Dashboard or via API:

php
// database/seeders/StripePlanSeeder.php
use Stripe\StripeClient;

class StripePlanSeeder extends Seeder
{
    public function run()
    {
        $stripe = new StripeClient(config('cashier.secret'));

        // Create product
        $product = $stripe->products->create([
            'name' => 'SaaS Pro Plan',
            'description' => 'Full access to all features',
        ]);

        // Create monthly price
        $stripe->prices->create([
            'product' => $product->id,
            'unit_amount' => 2900, // $29.00
            'currency' => 'usd',
            'recurring' => ['interval' => 'month'],
            'lookup_key' => 'pro_monthly',
        ]);

        // Create yearly price (2 months free)
        $stripe->prices->create([
            'product' => $product->id,
            'unit_amount' => 29000, // $290.00
            'currency' => 'usd',
            'recurring' => ['interval' => 'year'],
            'lookup_key' => 'pro_yearly',
        ]);
    }
}

Local Plan Configuration

php
// config/plans.php
return [
    'starter' => [
        'name' => 'Starter',
        'stripe_price_monthly' => 'price_starter_monthly',
        'stripe_price_yearly' => 'price_starter_yearly',
        'features' => [
            'projects' => 5,
            'storage_gb' => 10,
            'team_members' => 3,
        ],
    ],
    'pro' => [
        'name' => 'Professional',
        'stripe_price_monthly' => 'price_pro_monthly',
        'stripe_price_yearly' => 'price_pro_yearly',
        'features' => [
            'projects' => 50,
            'storage_gb' => 100,
            'team_members' => 10,
        ],
    ],
    'enterprise' => [
        'name' => 'Enterprise',
        'stripe_price_monthly' => 'price_enterprise_monthly',
        'stripe_price_yearly' => 'price_enterprise_yearly',
        'features' => [
            'projects' => -1, // Unlimited
            'storage_gb' => 1000,
            'team_members' => -1,
        ],
    ],
];

Subscription Management

Creating Subscriptions

php
// Subscription with trial
$team->newSubscription('default', 'price_pro_monthly')
    ->trialDays(14)
    ->create($paymentMethodId);

// Subscription without trial
$team->newSubscription('default', 'price_pro_monthly')
    ->create($paymentMethodId);

// Multiple prices (addons)
$team->newSubscription('default', [
    'price_pro_monthly',
    'price_extra_storage',
])->create($paymentMethodId);

Checking Subscription Status

php
// Basic checks
$team->subscribed('default');           // Has active subscription
$team->subscribedToProduct('prod_xxx'); // Subscribed to specific product
$team->subscribedToPrice('price_pro');  // Subscribed to specific price

// Status checks
$team->subscription('default')->active();
$team->subscription('default')->onTrial();
$team->subscription('default')->canceled();
$team->subscription('default')->onGracePeriod();
$team->subscription('default')->ended();

// Middleware protection
Route::middleware(['subscribed:default'])->group(function () {
    // Only accessible to subscribers
});

Changing Plans

php
// Upgrade/downgrade (immediate)
$team->subscription('default')->swap('price_enterprise_monthly');

// Swap at end of billing period
$team->subscription('default')->noProrate()->swap('price_starter_monthly');

// Swap with prorating
$team->subscription('default')
    ->swapAndInvoice('price_pro_monthly');

Canceling Subscriptions

php
// Cancel at period end (grace period)
$team->subscription('default')->cancel();

// Cancel immediately
$team->subscription('default')->cancelNow();

// Resume canceled subscription (during grace period)
$team->subscription('default')->resume();

Handling Payments

Checkout Sessions

php
// Create checkout session
public function checkout(Request $request)
{
    return $request->user()->currentTeam
        ->newSubscription('default', 'price_pro_monthly')
        ->trialDays(14)
        ->checkout([
            'success_url' => route('billing.success'),
            'cancel_url' => route('billing.cancel'),
        ]);
}

Payment Methods

php
// Get default payment method
$paymentMethod = $team->defaultPaymentMethod();

// Update payment method
$team->updateDefaultPaymentMethod($paymentMethodId);

// Delete payment method
$team->deletePaymentMethod($paymentMethodId);

// List all payment methods
$paymentMethods = $team->paymentMethods();

Handling Failed Payments

php
// In webhook handler
protected function handleInvoicePaymentFailed($payload)
{
    $team = Team::where('stripe_id', $payload['data']['object']['customer'])->first();

    if ($team) {
        // Notify team owner
        $team->owner->notify(new PaymentFailed(
            $payload['data']['object']['amount_due'] / 100
        ));

        // Downgrade to free after X failures
        if ($team->failedPaymentCount() >= 3) {
            $team->subscription('default')->cancelNow();
            $team->update(['plan' => 'free']);
        }
    }
}

Webhooks

Register Webhook Routes

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

Custom Webhook Handler

php
// app/Http/Controllers/WebhookController.php
use Laravel\Cashier\Http\Controllers\WebhookController as CashierController;

class WebhookController extends CashierController
{
    protected function handleCustomerSubscriptionCreated(array $payload): Response
    {
        parent::handleCustomerSubscriptionCreated($payload);

        $team = $this->getUserByStripeId($payload['data']['object']['customer']);

        // Send welcome email
        $team->owner->notify(new SubscriptionActivated());

        // Provision resources
        ProvisionTeamResources::dispatch($team);

        return $this->successMethod();
    }

    protected function handleCustomerSubscriptionDeleted(array $payload): Response
    {
        $team = $this->getUserByStripeId($payload['data']['object']['customer']);

        // Cleanup resources
        CleanupTeamResources::dispatch($team);

        // Notify user
        $team->owner->notify(new SubscriptionCanceled());

        return parent::handleCustomerSubscriptionDeleted($payload);
    }

    protected function handleInvoicePaid(array $payload): Response
    {
        $team = $this->getUserByStripeId($payload['data']['object']['customer']);

        // Record payment
        Payment::create([
            'team_id' => $team->id,
            'stripe_invoice_id' => $payload['data']['object']['id'],
            'amount' => $payload['data']['object']['amount_paid'],
            'status' => 'paid',
        ]);

        return $this->successMethod();
    }
}

Invoices and Receipts

Listing Invoices

php
// Get all invoices
$invoices = $team->invoices();

// Include pending invoices
$invoices = $team->invoicesIncludingPending();

// Get specific invoice
$invoice = $team->findInvoice($invoiceId);

Generating Invoice PDFs

php
// Download invoice
return $team->downloadInvoice($invoiceId, [
    'vendor' => 'Your Company',
    'product' => 'SaaS Subscription',
    'street' => '123 Main St',
    'location' => 'City, ST 12345',
    'phone' => '+1 (555) 123-4567',
]);

Invoice Page

php
// Controller
public function invoices(Request $request)
{
    return view('billing.invoices', [
        'invoices' => $request->user()->currentTeam->invoices(),
    ]);
}
blade
{{-- resources/views/billing/invoices.blade.php --}}
<table>
    <thead>
        <tr>
            <th>Date</th>
            <th>Amount</th>
            <th>Status</th>
            <th>Download</th>
        </tr>
    </thead>
    <tbody>
        @foreach($invoices as $invoice)
            <tr>
                <td>{{ $invoice->date()->toFormattedDateString() }}</td>
                <td>{{ $invoice->total() }}</td>
                <td>
                    @if($invoice->paid)
                        <span class="text-green-600">Paid</span>
                    @else
                        <span class="text-red-600">Unpaid</span>
                    @endif
                </td>
                <td>
                    <a href="{{ route('billing.invoice.download', $invoice->id) }}">
                        Download
                    </a>
                </td>
            </tr>
        @endforeach
    </tbody>
</table>

Trials and Free Tiers

Trial Configuration

php
// Global trial days
// In Team model
public function trialDays(): int
{
    return 14;
}

// Per-plan trial
$team->newSubscription('default', 'price_pro_monthly')
    ->trialDays(30)
    ->create($paymentMethodId);

// Trial without payment method
$team->newSubscription('default', 'price_pro_monthly')
    ->trialDays(14)
    ->create();

Checking Trial Status

php
// On trial?
$team->onTrial('default');

// Trial ends at
$team->subscription('default')->trial_ends_at;

// Days remaining
$team->subscription('default')->trial_ends_at->diffInDays(now());

Generic Trials (No Subscription)

php
// Set trial without subscription
$team->createAsStripeCustomer([
    'trial_ends_at' => now()->addDays(14),
]);

// Check generic trial
if ($team->onGenericTrial()) {
    // Full access during trial
}

Usage-Based Billing

Metered Billing

php
// Report usage
$team->subscription('default')
    ->reportUsage(150); // 150 API calls

// Report with timestamp
$team->subscription('default')
    ->reportUsage(
        quantity: 150,
        timestamp: now()->timestamp
    );

// Report for specific price
$team->subscription('default')
    ->reportUsageFor('price_api_calls', 150);

Usage Tracking

php
// Middleware to track API usage
class TrackApiUsage
{
    public function handle($request, $next)
    {
        $response = $next($request);

        if ($request->user()->currentTeam->subscribed('default')) {
            $request->user()->currentTeam
                ->subscription('default')
                ->reportUsage(1);
        }

        return $response;
    }
}

Best Practices

1. Always Handle Webhooks

php
// Never rely solely on success URL redirects
// Webhooks are the source of truth

// Bad
public function success()
{
    auth()->user()->currentTeam->update(['plan' => 'pro']);
}

// Good
protected function handleCustomerSubscriptionCreated(array $payload)
{
    $team = $this->getUserByStripeId($payload['data']['object']['customer']);
    $team->update(['plan' => 'pro']);
}

2. Test with Stripe CLI

bash
# Install Stripe CLI
stripe listen --forward-to localhost/stripe/webhook

# Trigger test events
stripe trigger customer.subscription.created

3. Handle Edge Cases

php
// Subscription with incomplete payment
if ($team->hasIncompletePayment('default')) {
    return redirect()->route('billing.complete-payment');
}

// Past due subscriptions
if ($team->subscription('default')->pastDue()) {
    return redirect()->route('billing.update-payment');
}

Conclusion

Laravel Cashier simplifies Stripe integration, but production billing requires careful attention to webhooks, failed payments, and edge cases. Always test thoroughly with Stripe's test mode and CLI before going live.


Resources

Related
Scroll to discover more...