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, complete 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, manage webhooks, and avoid the common mistakes that cost developers hours of debugging. I've used this exact setup in StudyLab and ReplyGenius, processing thousands of transactions monthly, so you're getting battle-tested code that actually works in production.
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
- 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 installed (this works with Laravel 10 too, but I'm using 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
I always run this as my first step because Cashier's installation command sets up everything you need.
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. Cashier uses these to track customer information.
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 learned this the hard way when I accidentally pushed live credentials to GitHub once. Thankfully, 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.
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 like my StudyLab project. 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, actually creating subscriptions. 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 name "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');
}
In ReplyGenius, I use this exact pattern to handle our three subscription tiers. 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.
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.
Laravel Cashier makes one-time charges ridiculously simple:
public function charge(Request $request)
{
    $user = $request->user();
    $amount = 2999; // $29.99 in cents
    
    try {
        $payment = $user->charge($amount, $request->payment_method);
        
        // Payment successful!
        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) {
        // Payment failed
        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
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) {
            // Handle 3D Secure or similar confirmation
            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, upgrade plans, cancel, resume, and update payment methods.
Checking Subscription Status
Cashier provides several helper methods for checking subscription status:
// 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. Here's how:
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 cancellation or cancellation 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();
Updating Payment Methods
Let users update their payment method without canceling their subscription:
public function updatePaymentMethod(Request $request)
{
    $user = $request->user();
    
    $user->updateDefaultPaymentMethod($request->payment_method);
    
    return redirect()->back()
        ->with('success', 'Payment method updated!');
}
You can also use Stripe's Billing Portal (highly recommended):
public function billingPortal(Request $request)
{
    return $request->user()->redirectToBillingPortal(
        route('dashboard')
    );
}
The Billing Portal is a hosted page by Stripe where users can update payment methods, view invoices, and manage subscriptions. I use this in both StudyLab and ReplyGenius because it saves tons of development time.
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.
Creating the Webhook Endpoint
Laravel Cashier includes a webhook controller. Just add it to your routes:
// routes/web.php
use Laravel\Cashier\Http\Controllers\WebhookController;
Route::post(
    '/stripe/webhook',
    [WebhookController::class, 'handleWebhook']
);
This route should be outside your authentication middleware since Stripe calls it directly.
Configuring Stripe Webhooks
In your Stripe Dashboard:
- Go to Developers → Webhooks
- Click "Add endpoint"
- Enter your webhook URL: https://yourapp.com/stripe/webhook
- Select events to listen for (or choose "receive all events")
- Copy the webhook signing secret to your .envfile
Important events to listen for:
- customer.subscription.updated- Subscription changes
- customer.subscription.deleted- Cancellations
- invoice.payment_succeeded- Successful payments
- invoice.payment_failed- Failed payments
- payment_intent.succeeded- One-time payment success
Handling Custom Webhook Events
Want to run custom logic when webhooks arrive? Create event listeners:
<?php
namespace App\Listeners;
use Laravel\Cashier\Events\WebhookReceived;
class StripeEventListener
{
    public function handle(WebhookReceived $event)
    {
        if ($event->payload['type'] === 'invoice.payment_succeeded') {
            $invoiceId = $event->payload['data']['object']['id'];
            
            // Your custom logic here
            // Maybe send a "thank you" email?
        }
    }
}
Register the listener in your EventServiceProvider:
protected $listen = [
    \Laravel\Cashier\Events\WebhookReceived::class => [
        \App\Listeners\StripeEventListener::class,
    ],
];
I use this pattern to send custom confirmation emails and update analytics when payments succeed.
Working with Invoices
Cashier makes invoice management straightforward. Users can view past invoices, and you can generate new ones on demand.
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. The array parameter lets you customize company details.
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 to forward webhooks during development:
stripe listen --forward-to localhost:8000/stripe/webhook
This saved me hours when I was debugging StudyLab's payment flow.
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
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
    // 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.
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
- Set up live webhook endpoint in Stripe Dashboard
- Implement proper error handling and logging
- Add retry logic for failed webhooks
- Set up monitoring for payment failures
- Test the complete payment flow end-to-end
- Ensure PCI compliance (don't store card details)
- Add analytics tracking for conversion funnels
Conclusion
Integrating Stripe with Laravel doesn't have to be complicated. Laravel 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, manage webhooks, and handle common edge cases. This is the exact setup I use in production applications processing thousands of payments monthly.
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.
Need help implementing Stripe integration in your Laravel application? I've built payment systems for multiple SaaS products and can help you get it right the first time. Let's work together, reach out and we'll discuss your specific requirements.
Need Help With Your Laravel Project?
I specialize in building custom Laravel applications, process automation, and SaaS development. Whether you need to eliminate repetitive tasks or build something from scratch, let's discuss your project.
⚡ Currently available for 2-3 new projects
 
            About Hafiz Riaz
Full Stack Developer from Turin, Italy. I build web applications with Laravel and Vue.js, and automate business processes. Creator of ReplyGenius, StudyLab, and other SaaS products.
View Portfolio →Get web development tips via email
Join 50+ developers • No spam • Unsubscribe anytime
Related Articles
 
                                                        Setting Up Laravel 10 with Vue3 and Vuetify3
A complete guide to seamlessly integrating Vue3 and Vuetify3 into Laravel 10 usi...
 
                                                        Effortlessly Dockerize Your Laravel & Vue Application: A Step-by-Step Guide
Unleash the Full Potential of Laravel 10 with Vue 3 and Vuetify 3 in a Dockerize...
 
                                                        Mastering Design Patterns in Laravel
Unraveling the Secrets Behind Laravel’s Architectural Mastery