Stripe Integration in Laravel: Complete Guide to Subscriptions & One-Time Payments
Learn how to integrate Stripe with Laravel for subscriptions and one-time payments using Laravel Cashier, with webhooks and invoice handling.
Payment integration used to terrify me. Back in 2021, when I was building my first SaaS, I spent three days wrestling with Stripe's API documentation, wondering if I'd ever get subscriptions working properly. Spoiler alert: I did, but I learned the hard way that Laravel Cashier exists for a reason.
If you're building a SaaS product, e-commerce platform, or any application that needs to accept payments, you'll eventually face the Stripe integration challenge. The good news? Laravel makes this incredibly straightforward with Cashier, its first-party payment library. The better news? I'm going to show you exactly how to implement both subscription billing and one-time payments without the headaches I experienced.
In this guide, you'll learn how to set up Stripe in Laravel from scratch, implement recurring subscriptions, handle one-time payments with Stripe Checkout, manage webhooks, and avoid the common mistakes that cost developers hours of debugging.
Why Laravel Cashier for Stripe Integration?
Let's be honest, you could use Stripe's PHP SDK directly. I've done it. But here's the thing: Cashier abstracts away about 80% of the boilerplate code you'd otherwise write.
Laravel Cashier provides a fluent interface for Stripe's subscription billing services. It handles subscription creation, swapping plans, cancellation grace periods, invoice generation, and webhook verification out of the box. Plus, it includes database migrations for storing billing information, so you don't have to design that schema yourself.
I'm not saying raw Stripe API integration doesn't have its place. If you need super custom billing logic that Cashier doesn't support, go for it. But for 95% of SaaS applications? Cashier is the smart choice.
What You'll Build
By the end of this tutorial, you'll have:
- Complete subscription management (create, update, cancel)
- One-time payment processing via Stripe Checkout
- Webhook handling for payment events
- Invoice generation and retrieval
- Customer portal integration
- Production-ready error handling
Prerequisites and Setup
Before we dive in, make sure you have:
- Laravel 11 or 12 installed (Cashier 15+ requires Laravel 11, Cashier 16 works with Laravel 12)
- A Stripe account (test mode is fine)
- Basic understanding of Laravel models and controllers
- Composer installed
First, install Laravel Cashier:
composer require laravel/cashier
Next, publish Cashier's migrations and configuration:
php artisan vendor:publish --tag="cashier-migrations"
php artisan vendor:publish --tag="cashier-config"
Run the migrations to add billing columns to your users table:
php artisan migrate
These migrations add columns like stripe_id, pm_type, pm_last_four, and trial_ends_at to your users table. They also create subscriptions and subscription_items tables. Cashier uses these to track customer information locally.
Configuring Stripe Credentials
Add your Stripe API keys to your .env file:
STRIPE_KEY=pk_test_your_publishable_key
STRIPE_SECRET=sk_test_your_secret_key
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
You'll find these in your Stripe Dashboard under Developers > API keys. Don't worry about the webhook secret yet, we'll generate that when we set up webhooks.
Important: Never commit your .env file to version control. I once accidentally pushed live credentials to GitHub. Stripe's security team caught it and immediately rotated my keys, but it was embarrassing.
Preparing Your User Model
Update your User model to use Cashier's Billable trait:
<?php
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Laravel\Cashier\Billable;
class User extends Authenticatable
{
use Billable;
// Your existing code...
}
The Billable trait adds all of Cashier's payment methods to your User model. This means you can now call methods like $user->newSubscription(), $user->charge(), and $user->invoices() directly on any user instance.
Quick tip: If you're building a multi-tenant SaaS, you might want to make your Tenant or Team model Billable instead of User. That way billing is tied to the organization, not individual users. I covered the tenancy architecture side of this in my multi-tenancy strategies guide.
Setting Up Stripe Products and Prices
Before you can create subscriptions in code, you need to set up products and pricing plans in your Stripe Dashboard.
Navigate to Products in your Stripe Dashboard and create a new product. Let's say you're building a SaaS tool. You might create a "Pro Plan" product with two pricing options: $9/month and $99/year.
Here's what most developers miss: Stripe uses Price IDs, not Product IDs, when creating subscriptions. Each pricing option (monthly, yearly, etc.) gets its own Price ID that looks like price_1A2B3C4D5E6F7G8H.
Copy these Price IDs, you'll need them in your code.
I typically store these in my config file for easy access:
// config/services.php
return [
// Other services...
'stripe' => [
'key' => env('STRIPE_KEY'),
'secret' => env('STRIPE_SECRET'),
'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
'plans' => [
'monthly' => env('STRIPE_PRICE_MONTHLY', 'price_1234'),
'yearly' => env('STRIPE_PRICE_YEARLY', 'price_5678'),
],
],
];
This approach keeps your price IDs in environment variables, making it easy to use different prices for testing and production.
Creating Subscriptions with Laravel Cashier
Now for the good stuff. This is where Cashier really shines.
Basic Subscription Creation
Here's the simplest way to create a subscription:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class SubscriptionController extends Controller
{
public function create(Request $request)
{
$user = $request->user();
// Create a subscription
$user->newSubscription('default', config('services.stripe.plans.monthly'))
->create($request->payment_method);
return redirect()->route('dashboard')
->with('success', 'Subscription created successfully!');
}
}
Let me break down what's happening here:
newSubscription('default', $priceId)initializes a new subscription with the type "default" (you can use any name to track multiple subscriptions per user)- The second parameter is your Stripe Price ID
create($paymentMethod)actually creates the subscription in Stripe using the provided payment method
The $request->payment_method comes from Stripe Elements on your frontend. We'll get to that in a moment.
Adding Trial Periods
Want to offer a 14-day free trial? Add one line:
$user->newSubscription('default', config('services.stripe.plans.monthly'))
->trialDays(14)
->create($request->payment_method);
Cashier handles all the trial logic automatically. The user won't be charged until the trial ends, and $user->onTrial() will return true during the trial period.
Handling Multiple Plans
If you offer different tiers (Basic, Pro, Enterprise), create separate price IDs for each and let users choose:
public function create(Request $request)
{
$user = $request->user();
$plan = $request->input('plan'); // 'basic', 'pro', or 'enterprise'
$priceId = config("services.stripe.plans.{$plan}");
$user->newSubscription('default', $priceId)
->create($request->payment_method);
return redirect()->route('dashboard');
}
I use this exact pattern to handle multiple subscription tiers across my SaaS projects. Works like a charm.
Collecting Payment Information (Frontend)
You'll need to collect payment information before creating the subscription. Here's a Vue 3 component using Stripe Elements:
<template>
<div>
<div id="card-element"></div>
<button @click="subscribe" :disabled="processing">
Subscribe
</button>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { loadStripe } from '@stripe/stripe-js';
const stripe = ref(null);
const cardElement = ref(null);
const processing = ref(false);
onMounted(async () => {
stripe.value = await loadStripe(import.meta.env.VITE_STRIPE_KEY);
const elements = stripe.value.elements();
cardElement.value = elements.create('card');
cardElement.value.mount('#card-element');
});
const subscribe = async () => {
processing.value = true;
const { paymentMethod, error } = await stripe.value.createPaymentMethod({
type: 'card',
card: cardElement.value,
});
if (error) {
console.error(error);
processing.value = false;
return;
}
// Send paymentMethod.id to your backend
await axios.post('/api/subscriptions', {
payment_method: paymentMethod.id,
plan: 'monthly'
});
processing.value = false;
};
</script>
This creates a secure card input field and generates a payment method token that you send to your Laravel backend. If you're building a full-stack SPA with this approach, my Laravel + Vue 3 guide covers the Inertia/Composition API setup in detail.
Note: Stripe also offers the newer Payment Element (elements.create('payment')) which supports multiple payment methods (cards, Apple Pay, Google Pay, bank transfers) in a single component. For new projects, I'd recommend Payment Element over the Card Element shown above. The Cashier backend code stays the same either way.
Processing One-Time Payments
Not everything needs a subscription. Sometimes you just want to charge a customer once, like for a premium feature, consultation booking, or digital download.
Option 1: Stripe Checkout (Recommended)
The simplest approach for one-time payments is Stripe Checkout. Cashier wraps this nicely:
use Illuminate\Http\Request;
Route::get('/buy/{product}', function (Request $request) {
return $request->user()->checkout([
'price_your_product_price_id' => 1,
], [
'success_url' => route('checkout.success') . '?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => route('checkout.cancel'),
]);
});
Stripe Checkout redirects the customer to a Stripe-hosted payment page. No need to build your own card form. Stripe handles PCI compliance, 3D Secure, Apple Pay, Google Pay, and dozens of local payment methods automatically.
Option 2: Direct Charges
If you need more control over the payment UI, use Cashier's charge() method:
public function charge(Request $request)
{
$user = $request->user();
$amount = 2999; // $29.99 in cents
try {
$payment = $user->charge($amount, $request->payment_method);
return response()->json([
'success' => true,
'payment_id' => $payment->id
]);
} catch (\Laravel\Cashier\Exceptions\IncompletePayment $e) {
// Payment requires additional confirmation (3D Secure, etc.)
return response()->json([
'requires_action' => true,
'payment_intent_client_secret' => $e->payment->client_secret
]);
} catch (\Exception $e) {
return response()->json([
'error' => $e->getMessage()
], 400);
}
}
Important: Stripe amounts are always in cents (or the smallest currency unit). So $29.99 becomes 2999. I've debugged issues where developers charged 29.99 instead of 2999, which results in a $0.29 charge. Not fun.
Handling Payment Confirmation (SCA)
Modern payment regulations (like SCA in Europe) sometimes require additional customer confirmation. When this happens, Cashier throws an IncompletePayment exception.
Here's how to handle this on the frontend:
const handlePayment = async () => {
try {
const response = await axios.post('/api/charge', {
payment_method: paymentMethodId,
amount: 2999
});
if (response.data.requires_action) {
const { error } = await stripe.confirmCardPayment(
response.data.payment_intent_client_secret
);
if (!error) {
// Payment confirmed!
}
} else {
// Payment successful immediately
}
} catch (error) {
console.error('Payment failed:', error);
}
};
This flow handles both immediate successful payments and payments requiring additional confirmation.
Managing Subscriptions
Creating subscriptions is just the beginning. You need to let users manage them.
Checking Subscription Status
Cashier provides several helper methods:
// Check if user has any active subscription
if ($user->subscribed('default')) {
// User is subscribed
}
// Check if user is on a specific plan
if ($user->subscribedToPrice('price_monthly', 'default')) {
// User is on monthly plan
}
// Check if user is on trial
if ($user->onTrial('default')) {
// User is on trial
}
// Check if subscription has ended
if ($user->subscription('default')->ended()) {
// Subscription is over
}
I use these constantly in middleware and view composers to control access to premium features.
Swapping Plans
Users want to upgrade or downgrade. Simple:
public function swap(Request $request)
{
$user = $request->user();
$newPriceId = config('services.stripe.plans.yearly');
$user->subscription('default')->swap($newPriceId);
return redirect()->back()
->with('success', 'Plan updated successfully!');
}
By default, Cashier prorates the charge automatically. If a user upgrades mid-cycle, they're only charged the difference. If they downgrade, the credit is applied to the next invoice.
Want to avoid proration? Add ->noProrate():
$user->subscription('default')
->noProrate()
->swap($newPriceId);
Canceling Subscriptions
There are two ways to cancel: immediate or at period end.
For immediate cancellation:
$user->subscription('default')->cancelNow();
For cancellation at period end (my preferred method):
$user->subscription('default')->cancel();
This second approach gives users access until their billing period ends. Better UX and fewer angry support emails.
Users can also resume canceled subscriptions before the period ends:
$user->subscription('default')->resume();
Using Stripe's Billing Portal
Instead of building all this UI yourself, you can redirect users to Stripe's hosted Billing Portal:
public function billingPortal(Request $request)
{
return $request->user()->redirectToBillingPortal(
route('dashboard')
);
}
The Billing Portal lets users update payment methods, view invoices, and manage subscriptions. I use this in most projects because it saves weeks of development time and Stripe keeps it updated with new payment methods and compliance requirements automatically.
Setting Up Webhooks
Webhooks are critical. They notify your application when events happen in Stripe: successful payments, failed charges, subscription cancellations initiated from the Stripe dashboard, and more.
Without webhooks, your application won't know about these events, leading to out-of-sync data.
Cashier's Built-In Webhook Handler
Cashier automatically registers a webhook route at /stripe/webhook. You don't need to add it manually. But you do need to make sure this path is excluded from CSRF verification. In Laravel 11+, Cashier handles this for you.
The easiest way to register your webhook endpoint with Stripe is using the artisan command:
php artisan cashier:webhook
This creates the webhook in your Stripe Dashboard pointing to your APP_URL/stripe/webhook. It also selects the right events automatically. Copy the signing secret it gives you into your .env as STRIPE_WEBHOOK_SECRET.
For local development, use Stripe CLI to forward webhooks:
stripe listen --forward-to localhost:8000/stripe/webhook
Important Events to Listen For
Cashier handles these automatically, but it helps to know what's happening:
customer.subscription.updated- subscription changescustomer.subscription.deleted- cancellationsinvoice.payment_succeeded- successful paymentsinvoice.payment_failed- failed paymentspayment_intent.succeeded- one-time payment success
Handling Custom Webhook Events
Want to run custom logic when specific events arrive? Listen for Cashier's events:
<?php
namespace App\Listeners;
use Laravel\Cashier\Events\WebhookReceived;
class StripeEventListener
{
public function handle(WebhookReceived $event): void
{
if ($event->payload['type'] === 'invoice.payment_succeeded') {
$customerId = $event->payload['data']['object']['customer'];
// Send a "thank you" email, update analytics, etc.
}
}
}
In Laravel 11+, this listener is discovered automatically thanks to event auto-discovery. No need to register it anywhere. Just create the class and Laravel will find it.
If you're processing high volumes of webhook events, you'll want to dispatch jobs from your listener instead of doing heavy work inline. I covered queue architecture for this kind of thing in my guide to processing 10,000 tasks.
Working with Invoices
Cashier makes invoice management straightforward.
Retrieving User Invoices
public function invoices(Request $request)
{
$invoices = $request->user()->invoices();
return view('billing.invoices', compact('invoices'));
}
Each invoice object includes helpful methods:
@foreach($invoices as $invoice)
<div>
<p>Date: {{ $invoice->date()->toFormattedDateString() }}</p>
<p>Total: {{ $invoice->total() }}</p>
<a href="{{ route('invoice.download', $invoice->id) }}">
Download PDF
</a>
</div>
@endforeach
Downloading Invoice PDFs
public function download(Request $request, $invoiceId)
{
return $request->user()->downloadInvoice($invoiceId, [
'vendor' => 'Your Company Name',
'product' => 'Your Product',
]);
}
This generates a PDF invoice automatically. Cashier 16 uses spatie/laravel-pdf under the hood for this (older versions used dompdf). Make sure it's installed if you need PDF invoice downloads.
Automatic Tax Calculation
Cashier supports Stripe Tax for automatic tax calculation. Enable it in your AppServiceProvider:
use Laravel\Cashier\Cashier;
public function boot(): void
{
Cashier::calculateTaxes();
}
Once enabled, any new subscriptions and one-off invoices will have taxes calculated automatically based on your customer's location. You do need to set up Stripe Tax in your Dashboard first, but it saves you from integrating a third-party tax service.
Common Mistakes and How to Avoid Them
After building payment systems for multiple SaaS projects, I've seen (and made) these mistakes repeatedly:
1. Not Testing Webhooks Locally
Stripe won't send webhooks to localhost. Use Stripe CLI:
stripe listen --forward-to localhost:8000/stripe/webhook
This saved me hours debugging payment flows early on.
2. Forgetting to Verify Webhook Signatures
Never trust webhook data without verification. Cashier handles this automatically, but if you're using raw Stripe webhooks, always verify the signature:
$payload = $request->getContent();
$sig_header = $request->header('Stripe-Signature');
try {
$event = \Stripe\Webhook::constructEvent(
$payload,
$sig_header,
config('services.stripe.webhook_secret')
);
} catch(\UnexpectedValueException $e) {
return response('Invalid payload', 400);
}
3. Storing Amounts Incorrectly
Always store monetary amounts as integers in cents. Never use floats. Floating-point arithmetic causes rounding errors.
// Bad
$amount = 29.99; // Don't do this!
// Good
$amount = 2999; // Correct: $29.99 in cents
I usually validate amounts with a JSON formatter when debugging Stripe API responses to make sure the numbers look right.
4. Not Handling Failed Payments
Subscription payments can fail for various reasons: expired cards, insufficient funds, fraud detection. Always implement failed payment handling:
// In your webhook listener
if ($event->payload['type'] === 'invoice.payment_failed') {
$customerId = $event->payload['data']['object']['customer'];
$user = User::where('stripe_id', $customerId)->first();
// Send email notification
// Downgrade account after grace period
// Whatever your business logic requires
}
5. Exposing Stripe Keys in JavaScript
Never put your secret key in frontend code. Only use the publishable key (pk_test_... or pk_live_...) in JavaScript. The secret key stays in your .env on the server.
6. Not Implementing Idempotency
Stripe requests can be retried if they timeout. Use idempotency keys to prevent duplicate charges:
$user->charge(2999, $paymentMethod, [
'idempotency_key' => 'order_' . $orderId,
]);
Testing Your Integration
Before going live, test these scenarios:
- Successful subscription creation - Does the user get subscribed?
- Failed payment - Does your app handle it gracefully?
- 3D Secure payments - Test with card
4000002500003155 - Subscription cancellation - Does access end at the right time?
- Plan swapping - Is proration calculated correctly?
- Webhook delivery - Are all webhooks processed?
Stripe provides test card numbers for various scenarios. Use 4242424242424242 for successful payments and 4000000000000002 for declined payments.
Production Checklist
Before launching:
- Switch from test keys to live keys
- Run
php artisan cashier:webhookagainst your production URL - Implement proper error handling and logging
- Set up monitoring for payment failures
- Test the complete payment flow end-to-end
- Ensure PCI compliance (don't store card details)
- Enable Stripe's Billing Portal for self-service management
- Set
stripe_idcolumn collation toutf8_binif using MySQL
Frequently Asked Questions
Should I use Stripe Checkout or build my own payment form?
For most projects, start with Stripe Checkout. It handles PCI compliance, supports dozens of payment methods, and Stripe keeps it updated. Build your own form only if you need a deeply integrated payment experience that Checkout can't provide. I'd say 80% of projects I've worked on use Checkout.
How do I handle subscription billing for teams instead of individual users?
Make your Team or Organization model Billable instead of User. Each team gets its own Stripe customer, and you manage subscriptions at the team level. This keeps billing tied to the organization, which is what most B2B SaaS apps need. If you're building a multi-tenant app, check my guide on building SaaS with Laravel and Filament where I cover this pattern.
What's the difference between Cashier 15 and 16?
Cashier 15 works with Laravel 11 and Cashier 16 with Laravel 12. The biggest change in 16 is support for Stripe's newer "Basil" API version (2025-06-30.basil). Invoice PDF generation now uses spatie/laravel-pdf instead of the old dompdf renderer. The public API is mostly the same, so upgrading is straightforward.
How do I test Stripe webhooks in a staging environment?
Use php artisan cashier:webhook --url="https://staging.yourapp.com/stripe/webhook" to register a webhook for your staging domain. Stripe also has a "Test" mode toggle in the Dashboard that sends test events. For local dev, stripe listen --forward-to localhost:8000/stripe/webhook is your best friend.
Can I use Cashier with Paddle instead of Stripe?
Yes. Laravel also ships with laravel/cashier-paddle for Paddle billing. The API is similar but not identical. Paddle handles tax compliance and is a merchant of record, meaning they handle VAT/sales tax collection for you. I'd pick Stripe when you want maximum control and Paddle when you want less tax headaches.
What's Next?
Integrating Stripe with Laravel doesn't have to be complicated. Cashier abstracts away most of the complexity, letting you focus on building features that matter to your users.
You now know how to create subscriptions, process one-time payments with Stripe Checkout, manage webhooks, and handle common edge cases. The key is starting simple. Get basic subscriptions working first, then add complexity as your business requirements grow. Don't over-engineer early. I've seen too many developers spend weeks building elaborate payment systems before they have a single paying customer.
Start with a single subscription plan. Add your Stripe keys. Create the subscription flow. Test it. Then iterate.
If you're building a SaaS and need help getting Stripe integration right the first time, let's talk. I've built payment systems across multiple products and can help you avoid the expensive mistakes.
About Hafiz
Senior Full-Stack Developer with 9+ years building web apps and SaaS platforms. I specialize in Laravel and Vue.js, and I write about the real decisions behind shipping production software.
View My Work →Get web development tips via email
Join 50+ developers • No spam • Unsubscribe anytime
Related Articles
Building Admin Dashboards with Filament: A Complete Guide for Laravel Developers
Learn how to build admin dashboards with Filament in Laravel. Complete guide wit...
Laravel Multi-Tenancy: Database vs Subdomain vs Path Routing Strategies
Compare Laravel multi-tenancy approaches: database isolation, subdomain routing,...
Building a SaaS with Laravel and Filament: The Complete Guide
Build a production-ready SaaS with Laravel and Filament. Multi-tenancy, Stripe p...