Stripe Integration in Laravel: Subscriptions, Webhooks and Metered Billing
A complete guide to integrating Stripe into a Laravel SaaS — covering one-time payments, subscription plans, usage-based billing, and webhook handling that does not lose events.
Why Stripe Integration Goes Wrong
Stripe is the best payment API available. It is also one of the most complex to integrate correctly. The common mistake I see in Laravel Stripe integrations is treating Stripe as a simple "charge a card" API and ignoring the event-driven architecture Stripe is built on. The result is a payment system that works in the demo but fails in production: missed subscription renewals, double-charges, failed payment emails that never send, subscription cancellations that do not revoke access.
The correct mental model for Stripe integration is: Stripe is the source of truth for billing. Your database is a cache. Your application should react to Stripe events (webhooks) to update its state — not assume that an API call succeeded and update state immediately.
This article covers a complete Stripe integration for a Laravel SaaS — one-time payments, subscription management, usage-based billing, and a webhook handler that handles every edge case.
Installation and Setup
composer require laravel/cashier
php artisan vendor:publish --tag="cashier-migrations"
php artisan migrate
Add the Billable trait to your billable model (usually User or Tenant for SaaS):
use Laravel\Cashier\Billable;
class Tenant extends Model
{
use Billable;
}
Set your Stripe keys in .env:
STRIPE_KEY=pk_live_...
STRIPE_SECRET=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
One-Time Payments
// Charge a customer once (e.g., a setup fee)
$tenant->charge(50000, 'usd', [
'description' => 'One-time setup fee',
]);
// Create a payment intent for Stripe Elements (frontend)
$paymentIntent = $tenant->pay(50000, [
'currency' => 'usd',
'description' => 'Project deposit',
]);
return response()->json([
'clientSecret' => $paymentIntent->client_secret,
]);
Subscription Management
Creating Subscriptions
// New subscription with a 14-day trial
$tenant->newSubscription('default', 'price_starter_monthly')
->trialDays(14)
->create($request->paymentMethodId);
// Subscription with a coupon/discount
$tenant->newSubscription('default', 'price_growth_monthly')
->withCoupon('LAUNCH50')
->create($request->paymentMethodId);
Upgrading and Downgrading Plans
// Swap plan immediately (prorates automatically)
$tenant->subscription('default')->swap('price_enterprise_monthly');
// Swap at end of billing period (no proration)
$tenant->subscription('default')->noProrate()->swap('price_starter_monthly');
Cancellation and Resumption
// Cancel at end of current billing period
$tenant->subscription('default')->cancel();
// Cancel immediately
$tenant->subscription('default')->cancelNow();
// Resume a cancelled (but not yet expired) subscription
$tenant->subscription('default')->resume();
Checking Subscription Status
// In middleware or service providers
if ($tenant->subscribed()) {
// Active subscription (including on trial)
}
if ($tenant->onTrial()) {
// In trial period
}
if ($tenant->subscription('default')->onGracePeriod()) {
// Cancelled but still has access until end of period
}
// Check specific plan
if ($tenant->subscribedToPrice('price_enterprise_monthly')) {
// On enterprise plan
}
Usage-Based (Metered) Billing
Usage-based billing is increasingly common in AI-powered SaaS — charge per API call, per document processed, per AI message sent. Stripe supports this with metered prices.
// Record usage for a metered price
// Call this every time a billable event occurs (e.g., AI message processed)
$tenant->subscription('default')
->reportUsage(quantity: 1);
// Or report a specific quantity at a specific timestamp
$tenant->subscription('default')
->reportUsageFor(
price: 'price_ai_messages_metered',
quantity: 5,
timestamp: now()->subMinutes(10)
);
For high-frequency events (thousands per day), batch usage reports rather than calling Stripe on every event:
// Increment a counter in Redis on every event
Redis::incr("tenant:{$tenant->id}:ai_messages");
// Scheduled job every hour: flush to Stripe
class FlushUsageToStripe implements ShouldQueue
{
public function handle(): void
{
Tenant::subscribed()->each(function (Tenant $tenant) {
$key = "tenant:{$tenant->id}:ai_messages";
$count = (int) Redis::getdel($key);
if ($count > 0) {
$tenant->subscription('default')->reportUsage($count);
}
});
}
}
Webhook Handling: The Critical Part
Stripe webhooks are how Stripe tells your application what happened — subscription renewed, payment failed, card expiring, subscription cancelled. Missing webhook events means your application's billing state drifts from Stripe's truth.
Register the Cashier Webhook Route
// routes/web.php
Route::post(
'stripe/webhook',
\Laravel\Cashier\Http\Controllers\WebhookController::class
)->name('cashier.webhook');
Exclude this route from CSRF protection:
// bootstrap/app.php (Laravel 11+)
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: ['stripe/webhook']);
})
Handle Webhook Events
Extend Cashier's WebhookController to add your own business logic:
// app/Http/Controllers/StripeWebhookController.php
class StripeWebhookController extends WebhookController
{
// Called when a subscription successfully renews
public function handleInvoicePaymentSucceeded(array $payload): Response
{
$subscriptionId = $payload['data']['object']['subscription'];
$tenant = Tenant::where('stripe_id', $payload['data']['object']['customer'])->first();
if ($tenant) {
$tenant->update(['subscription_ends_at' => now()->addMonth()]);
Mail::to($tenant->owner)->send(new PaymentReceived($payload['data']['object']));
}
return $this->successMethod();
}
// Called when a payment fails (card declined, etc.)
public function handleInvoicePaymentFailed(array $payload): Response
{
$tenant = Tenant::where('stripe_id', $payload['data']['object']['customer'])->first();
if ($tenant) {
Mail::to($tenant->owner)->send(new PaymentFailed($payload['data']['object']));
// Optionally: restrict access after X failed attempts
}
return $this->successMethod();
}
// Called when a subscription is cancelled
public function handleCustomerSubscriptionDeleted(array $payload): Response
{
$tenant = Tenant::where('stripe_id', $payload['data']['object']['customer'])->first();
if ($tenant) {
$tenant->update(['plan' => 'cancelled']);
// Revoke access, archive data, send cancellation email
DowngradeToFreeTier::dispatch($tenant);
}
return $this->successMethod();
}
}
Making Webhooks Reliable
Stripe retries failed webhooks for up to 72 hours. Your webhook handler must be idempotent — processing the same event twice should have the same result as processing it once:
// Store processed event IDs to prevent duplicate processing
public function handleInvoicePaymentSucceeded(array $payload): Response
{
$eventId = $payload['id']; // e.g., "evt_1234..."
// Idempotency check
if (ProcessedStripeEvent::where('stripe_event_id', $eventId)->exists()) {
return $this->successMethod(); // already handled, return 200 to stop retries
}
DB::transaction(function () use ($payload, $eventId) {
ProcessedStripeEvent::create(['stripe_event_id' => $eventId]);
// ... rest of your handler logic
});
return $this->successMethod();
}
Testing Stripe Integration
Never run Stripe tests against your live account. Stripe's test mode provides test card numbers that trigger specific scenarios:
4242 4242 4242 4242— Successful payment4000 0000 0000 0002— Card declined4000 0025 0000 3155— Requires 3D Secure authentication4000 0000 0000 9995— Insufficient funds
For webhook testing locally, use the Stripe CLI:
stripe listen --forward-to localhost:8000/stripe/webhook
stripe trigger invoice.payment_succeeded
Frequently Asked Questions
Should I bill the User model or a Team/Tenant model?
For B2C products (individual users pay for their own access), add Billable to User. For B2B SaaS (one subscription covers a whole team or organisation), add Billable to your Tenant or Team model. Billing the team model is almost always the right choice for B2B — it correctly reflects the business relationship, makes seat-based pricing easier, and ensures subscription state is shared across all users in the organisation.
How do I handle SCA/3D Secure in Europe?
Strong Customer Authentication (SCA) is mandatory for payments in the EU and UK. Laravel Cashier handles SCA for subscription creation by returning an IncompletePayment exception when 3D Secure authentication is required. Catch this exception and redirect the user to Stripe's hosted payment confirmation page: redirect($exception->payment->redirectUrl()). For subscription renewals, Stripe handles SCA automatically via off-session authentication if the customer opted in during initial setup.
How do I offer a free tier alongside paid plans?
The cleanest approach: do not create a Stripe subscription for free-tier users at all. Check plan eligibility by looking at the stripe_id column — if null, the user is on the free tier. Only create a Stripe customer record when the user initiates an upgrade to a paid plan. This keeps your Stripe customer list clean and avoids Stripe fees for free users.
Senior Full Stack Developer — Laravel, Vue.js, Nuxt.js & AI. Available for freelance projects.
Hire Me for Your Project