Skip to content
ExponaBLOG
PHP & LARAVEL DEVELOPMENT · DAY 052 / 100

Subscriptions with Laravel Cashier: a step-by-step guide

Stripe billing without reinventing it.

Expona Admin6 min read

Why Cashier Matters for Recurring Revenue

Billing is one of those features that looks simple until you ship it to production. Handling subscription lifecycle, payment retries, proration, and compliance across different regions takes engineering time that could go toward features customers actually see. Laravel Cashier abstracts Stripe's subscription complexity into a clean, Laravel-native interface—this step-by-step guide on subscriptions with Laravel Cashier walks you through building a reliable billing system without reinventing it.

If you're running a SaaS or any product with recurring revenue, Cashier saves weeks of integration work. We've deployed this pattern across multiple platforms, from early-stage startups to platforms processing six figures monthly. The same principles apply whether you're charging €9 or €9,000 per month.

Cashier Wraps Stripe Subscriptions

Cashier is Laravel's official package for handling Stripe billing. It sits between your application and Stripe's API, providing a fluent interface for subscriptions, one-time charges, and webhook handling. Instead of orchestrating Stripe API calls manually, you work with Eloquent models and simple method chains.

First, install Cashier and publish its migrations:

php
composer require laravel/cashier

php artisan vendor:publish --tag=cashier-migrations
php artisan migrate

This creates the subscriptions, subscription_items, and subscription_schedule tables. Make sure your User model uses the Billable trait:

php
namespace App\Models;

use Laravel\Cashier\Billable;
use Illuminate\Foundation\Auth\User as Authenticatable;

class User extends Authenticatable
{
    use Billable;
}

Set your Stripe keys in .env:

php
STRIPE_PUBLIC_KEY=pk_live_...
STRIPE_SECRET_KEY=sk_live_...

Now you can create a subscription with a single call. In your controller, after the user selects a plan:

php
$user->newSubscription('default', 'price_monthly_pro')
    ->create($stripeToken);

Cashier handles creating the Stripe customer, attaching the payment method, and storing the subscription record. The subscription name ('default') lets you manage multiple subscriptions per user—say, one for API access and one for premium support.

Trials, Swaps, and Cancellations

Real subscription flows are messier than one-off charges. Users want trials, the ability to upgrade mid-cycle, and the option to cancel without friction. Cashier gives you all three as readable methods.

Offering Trial Periods

Start a subscription with a free trial:

php
$user->newSubscription('default', 'price_monthly_pro')
    ->trialDays(14)
    ->create($stripeToken);

The user won't be charged until the trial ends. Check if they're in trial before showing upgrade prompts:

php
if ($user->onTrial('default')) {
    // Show "Trial ends in X days"
}

if ($user->trialEndsAt('default')?->isPast()) {
    // Trial has ended, user is now billed
}

Swapping Plans

When a user upgrades or downgrades, don't cancel and recreate. Use swap(), which handles proration automatically:

php
$user->subscription('default')->swap('price_monthly_enterprise');

If they're mid-billing cycle, Stripe calculates a credit or charge the difference on the next invoice. Stripe applies this as a line item on their next bill—no refunds, cleaner accounting.

Graceful Cancellation

Cancel at the end of the billing period (immediate to the customer, but they keep access until expiration):

php
$user->subscription('default')->cancel();

Cancel immediately (common for abuse or payment disputes):

php
$user->subscription('default')->cancelNow();

Check subscription status before granting access:

php
if ($user->subscription('default')?->active()) {
    // Grant access to features
} elseif ($user->subscription('default')?->canceled()) {
    // Subscription was canceled
}

Handle Webhooks for Payment Failures

Subscriptions fail. Cards expire, accounts get closed, rate limits kick in. Stripe retries automatically, but you need to respond when retries are exhausted. Webhooks are your safety net.

Cashier listens for Stripe webhooks at /stripe/webhook by default. Publish the config to customize:

php
php artisan vendor:publish --tag=cashier-config

Point Stripe to your webhook URL in the Stripe dashboard (https://yourapp.com/stripe/webhook). Stripe signs every webhook with your signing secret; Cashier verifies this automatically.

Handle key events in your EventServiceProvider or via Cashier's built-in event system. When a subscription payment fails after all retries, Stripe sends invoice.payment_action_required or invoice.payment_failed. Override the webhook handler:

php
namespace App\Services;

use Laravel\Cashier\Events\WebhookReceived;
use Stripe\Event;

class StripeWebhookHandler
{
    public function handle(WebhookReceived $event): void
    {
        $data = $event->payload['data']['object'];

        if ($event->payload['type'] === 'invoice.payment_failed') {
            $user = User::whereStripeId($data['customer'])->first();
            
            if ($user) {
                // Send reminder email
                Mail::to($user)->send(new PaymentFailedMail($user));
                
                // Log for support team
                Log::warning('Payment failed', [
                    'user_id' => $user->id,
                    'invoice_id' => $data['id'],
                ]);
            }
        }
    }
}

Register it in config/cashier.php:

php
'webhook_handler' => App\Services\StripeWebhookHandler::class,

Send users a summary of what went wrong and a payment recovery link. Many teams ignore this and lose 10-20% of failed subscriptions—a simple email cuts that in half.

Store Plan Features in Config

Don't hardcode plan names and feature access across your app. Create a single source of truth in config/billing.php:

php
return [
    'plans' => [
        'starter' => [
            'stripe_id' => 'price_monthly_starter',
            'name' => 'Starter',
            'price' => 29,
            'currency' => 'usd',
            'features' => [
                'api_calls' => 10000,
                'team_members' => 2,
                'webhooks' => true,
                'priority_support' => false,
            ],
        ],
        'pro' => [
            'stripe_id' => 'price_monthly_pro',
            'name' => 'Pro',
            'price' => 99,
            'currency' => 'usd',
            'features' => [
                'api_calls' => 100000,
                'team_members' => 10,
                'webhooks' => true,
                'priority_support' => true,
            ],
        ],
    ],
];

Add a method to your User model to check features:

php
public function planFeature(string $key, mixed $default = null): mixed
{
    $subscription = $this->subscription('default');
    if (!$subscription?->active()) {
        return $default;
    }

    $plan = config('billing.plans');
    foreach ($plan as $planConfig) {
        if ($planConfig['stripe_id'] === $subscription->stripe_price) {
            return $planConfig['features'][$key] ?? $default;
        }
    }

    return $default;
}

Now check access anywhere in your codebase:

php
$apiQuota = $user->planFeature('api_calls', 1000);
if ($apiQuota < $requestCount) {
    abort(429, 'API quota exceeded');
}

This keeps billing logic out of your business logic. When you add a new plan, you update config once; all features cascade automatically.

Common Mistakes

  • Not handling failed webhooks: If your webhook handler crashes, Stripe retries for 3 days. Log everything and set up Slack alerts for webhook errors. Test with Stripe's CLI: stripe listen --forward-to localhost:8000/stripe/webhook.
  • Blocking UI on Stripe calls: Creating subscriptions or checking billing status makes external API calls. Use queues for non-critical paths. Dispatch a job to sync subscription details rather than calling Stripe synchronously in a request.
  • Forgetting to handle expired cards before charging: Stripe retries failed charges, but your app should also prompt users to update their card before the retry. Check subscription status in middleware if you need to enforce hard limits.
  • Mixing free and paid tiers: Store a plan column on users or use a separate table. Don't rely solely on Stripe subscription presence—your app needs to know locally whether someone is free or paid, fast and without API calls.
  • Testing with real Stripe keys: Always use test keys in development. Stripe provides test card numbers; use 4242 4242 4242 4242 for success and 4000 0000 0000 0002 for declines.

Key Takeaways

  • Cashier eliminates weeks of billing integration work by wrapping Stripe subscriptions into a Laravel-native interface.
  • Use trials, swaps, and graceful cancellations to build a subscription flow that feels polished to users.
  • Webhook handling is non-negotiable; most failed subscriptions result from poor communication after payment failures.
  • Keep all plan configuration in a single config file; check features locally rather than querying Stripe on every request.
  • Test webhook handling locally with Stripe CLI before deploying to production.
  • Log webhook events extensively and monitor for errors; failing silently loses revenue.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles