10 min read

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.

Laravel Stripe Payment Integration Laravel Cashier SaaS
Stripe Integration in Laravel: Complete Guide to Subscriptions & One-Time Payments

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:

  1. Go to Developers → Webhooks
  2. Click "Add endpoint"
  3. Enter your webhook URL: https://yourapp.com/stripe/webhook
  4. Select events to listen for (or choose "receive all events")
  5. Copy the webhook signing secret to your .env file

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:

  1. Successful subscription creation - Does the user get subscribed?
  2. Failed payment - Does your app handle it gracefully?
  3. 3D Secure payments - Test with card 4000002500003155
  4. Subscription cancellation - Does access end at the right time?
  5. Plan swapping - Is proration calculated correctly?
  6. 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

Hafiz Riaz

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