Skip to content
Laravel 12 / Cashier 16.6

Laravel + Stripe Integration

Accept payments, handle subscriptions, and manage payouts in your Laravel application with Stripe.

What You Can Build

One-Time Payments

Accept credit card payments for products, services, or donations with Stripe Checkout.

Subscription Billing

Recurring payments with trials, tiers, usage-based billing, and automatic renewals.

Marketplace Payments

Split payments between sellers and platform with Stripe Connect.

Webhooks & Automation

Real-time notifications for successful payments, failed charges, and subscription events.

How It Works

1

Install Stripe Package

Laravel Cashier provides a clean API on top of Stripe.

2

Configure API Keys

Add Stripe keys to your .env file and configure webhooks.

3

Create Models

Add Billable trait to your User model for subscription support.

4

Build Checkout Flow

Use Checkout Sessions or embed payment forms in your app.

Code-behind example

Complete Laravel Stripe setup: checkout, routes, webhooks, and errors

This implementation targets Laravel 12, Laravel Cashier 16.6, and the Stripe PHP 17.x dependency Cashier installs. The exact model names and price IDs change by product, but the important pieces are the same: authenticated routes, hosted Checkout, Cashier's signed webhook endpoint as the billing source of truth, idempotent application event work, and explicit handling for API failures and customer portal access.

1. Install Cashier and set environment values

Use Cashier for subscriptions, checkout, invoices, and the customer portal. Keep Stripe identifiers in config so staging and production never share price IDs by accident.

composer require laravel/cashier:^16.6
php artisan vendor:publish --tag="cashier-migrations"
php artisan migrate

STRIPE_KEY=pk_test_...
STRIPE_SECRET=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRO_PRICE_ID=price_...

Add the price ID to config/services.php as 'pro_price_id' => env('STRIPE_PRO_PRICE_ID'). Cashier reads the API keys and webhook secret from its own config/cashier.php; never call env() from controllers.

2. Add Billable to the customer model

Cashier stores the Stripe customer ID and subscription state on the billable model. Most SaaS apps use User; team-based products may bill an Account or Organization model instead.

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Laravel\Cashier\Billable;

class User extends Model
{
    use Billable;
}

3. Define app routes and secure Cashier's webhook

Checkout and portal routes should be authenticated. Cashier 16.6 automatically registers POST /stripe/webhook and applies signature verification when STRIPE_WEBHOOK_SECRET is configured, so do not register another route at the same URI.

use App\Http\Controllers\Billing\CheckoutController;
use Illuminate\Support\Facades\Route;

Route::middleware(['auth'])->group(function (): void {
    Route::post('/billing/checkout', [CheckoutController::class, 'store'])
        ->name('billing.checkout');

    Route::get('/billing/portal', [CheckoutController::class, 'portal'])
        ->name('billing.portal');

    Route::get('/billing/success', [CheckoutController::class, 'success'])
        ->name('billing.success');
});

Stripe cannot send a Laravel CSRF token. In a fresh Laravel 12 application, exclude only the webhook URI in bootstrap/app.php. If the application retained Laravel 10's structure after upgrading, put the same exact URI in app/Http/Middleware/VerifyCsrfToken.php.

// Fresh Laravel 12 structure: bootstrap/app.php
->withMiddleware(function (Middleware $middleware): void {
    $middleware->validateCsrfTokens(except: ['stripe/webhook']);
})

// Upgraded Laravel 10 structure: VerifyCsrfToken.php
protected $except = ['stripe/webhook'];

4. Create the checkout controller with API failure handling

The controller creates a hosted Stripe Checkout session and redirects the customer to it. Stripe handles payment authentication on the hosted page; your application handles API failures here and completed or expired sessions through webhooks.

namespace App\Http\Controllers\Billing;

use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Stripe\Exception\ApiErrorException;

class CheckoutController extends Controller
{
    public function store(Request $request): RedirectResponse
    {
        $user = $request->user();

        try {
            return $user
                ->newSubscription('default', config('services.stripe.pro_price_id'))
                ->allowPromotionCodes()
                ->checkout([
                    'success_url' => route('billing.success').'?session_id={CHECKOUT_SESSION_ID}',
                    'cancel_url' => route('billing.portal'),
                    'customer_update' => [
                        'address' => 'auto',
                        'name' => 'auto',
                    ],
                ])
                ->redirect();
        } catch (ApiErrorException $exception) {
            report($exception);

            return back()
                ->with('billing_error', 'Stripe could not start checkout. Please try again or contact support.');
        }
    }

    public function portal(Request $request): RedirectResponse
    {
        try {
            return $request->user()->redirectToBillingPortal(route('billing.success'));
        } catch (ApiErrorException $exception) {
            report($exception);

            return back()
                ->with('billing_error', 'Stripe could not open the billing portal. Please try again.');
        }
    }

    public function success(): RedirectResponse
    {
        return redirect()
            ->route('dashboard')
            ->with('status', 'Your billing details are being confirmed by Stripe.');
    }
}

5. Keep Cashier's webhook and queue application-specific work

Never mark a subscription active only because the browser returned from Checkout. Cashier's controller verifies the signature and synchronizes supported customer and subscription events. Listen for WebhookReceived only for application-specific work such as fulfilling an order or recording a refund; do not replace Cashier's route or duplicate its subscription synchronization.

The persistence invariant: the database, not the application code, must enforce that a Stripe event ID is stored at most once. A unique index on stripe_id is what makes the deduplication correct. Checking exists() and then calling create() is a read-then-write race: two concurrent deliveries of the same event can both pass the check before either inserts, and both will run the side effects. Without the unique index, the job below silently loses its protection.

Schema::create('stripe_events', function (Blueprint $table): void {
    $table->id();
    $table->string('stripe_id')->unique();
    $table->string('type')->index();
    $table->json('payload');
    $table->timestamp('processed_at')->nullable();
    $table->timestamps();
});

Register a listener that dispatches a queued job only for the application-specific events below. The job claims each event with one atomic insertOrIgnore() inside a transaction. On a duplicate the unique index rejects the row, the call affects zero rows, and application handlers are skipped.

use App\Jobs\ProcessStripeEvent;
use App\Jobs\ProvisionSynchronizedSubscription;
use Illuminate\Support\Facades\Event;
use Laravel\Cashier\Events\WebhookHandled;
use Laravel\Cashier\Events\WebhookReceived;

Event::listen(WebhookReceived::class, function (WebhookReceived $event): void {
    if (! in_array($event->payload['type'], [
        'checkout.session.completed',
        'checkout.session.expired',
        'invoice.payment_failed',
        'charge.refunded',
    ], true)) {
        return;
    }

    ProcessStripeEvent::dispatch($event->payload);
});

Event::listen(WebhookHandled::class, function (WebhookHandled $event): void {
    if ($event->payload['type'] !== 'customer.subscription.created') {
        return;
    }

    ProvisionSynchronizedSubscription::dispatch($event->payload);
});
namespace App\Jobs;

use App\Models\StripeEvent;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;

class ProcessStripeEvent implements ShouldQueue
{
    use Queueable;

    /**
     * @param array $payload
     */
    public function __construct(public array $payload) {}

    public function handle(): void
    {
        DB::transaction(function (): void {
            $claimed = StripeEvent::query()->insertOrIgnore([
                'stripe_id' => $this->payload['id'],
                'type' => $this->payload['type'],
                'payload' => json_encode($this->payload, JSON_THROW_ON_ERROR),
                'created_at' => now(),
                'updated_at' => now(),
            ]);

            if ($claimed === 0) {
                return;
            }

            match ($this->payload['type']) {
                'checkout.session.completed' => $this->handleCheckoutCompleted($this->payload['data']['object']),
                'checkout.session.expired' => $this->recordExpiredCheckout($this->payload['data']['object']),
                'invoice.payment_failed' => $this->recordFailedPayment($this->payload['data']['object']),
                'charge.refunded' => $this->recordRefund($this->payload['data']['object']),
                default => null,
            };

            StripeEvent::query()
                ->where('stripe_id', $this->payload['id'])
                ->update(['processed_at' => now()]);
        });
    }
}

Implement the four private handler methods with your own database-only domain changes and use a real asynchronous queue in production. In Cashier 16.6, WebhookHandled is dispatched only when Cashier has a matching controller method; it is not dispatched for the four events handled by this job. A subscription Checkout also produces a separately delivered customer.subscription.created event, and Stripe does not guarantee that it arrives before checkout.session.completed. Keep the Checkout handler scoped to session data. Put work that requires Cashier's local subscription in the filtered WebhookHandled listener shown above, after Cashier synchronizes customer.subscription.created, and deduplicate that job by its own Stripe event ID. Let exceptions escape so Laravel's queue retry policy can retry the job; the transaction rolls back both the claim and database writes. insertOrIgnore() bypasses Eloquent casts and automatic timestamps, so encode the payload and set timestamps explicitly. A transaction cannot undo email or another API call, so dispatch external side effects after commit through an outbox keyed by stripe_id.

Webhook tests

Use stripe listen --forward-to https://your-app.test/stripe/webhook, replay duplicate events concurrently, and verify that the unique index on stripe_id prevents duplicate account updates. Assert unsigned requests are rejected and confirm there is only one registered POST /stripe/webhook route.

Failure tests

Run Stripe test cards for declined cards, insufficient funds, 3D Secure, expired cards, refunds, chargebacks, cancelled subscriptions, and expired Checkout sessions.

Internal links

For product scope, pair this billing flow with the SaaS MVP guide, e-commerce development, and the webhooks glossary.

Integration planning

Plan the Laravel Stripe integration before coding.

Direct answer

A Laravel Stripe integration is production-ready when products, prices, customers, subscriptions, invoices, refunds, failed payments, and webhooks all map cleanly to Laravel records and support workflows. The goal is not just checkout success; it is trustworthy billing state after renewals, cancellations, disputes, and plan changes.

A production Stripe integration is more than adding a checkout button. The application needs a reliable payment model, subscription state, webhook processing, refund logic, customer records, and a support path when a card fails or a plan changes. Somnio scopes the billing rules first so payment behavior matches how the business actually sells.

Data and events model

We map products, prices, subscriptions, invoices, customers, and webhook events before implementation. That keeps Laravel models, Stripe records, and admin screens aligned when payments succeed, fail, refund, or renew.

Failure states

Plan for duplicate webhooks, incomplete checkouts, failed cards, expired trials, disputed charges, refunds, tax changes, and customers who upgrade, downgrade, or cancel mid-cycle.

Admin and support visibility

Support teams need customer IDs, subscription status, invoice links, failed-payment history, refund notes, and safe role access without opening Stripe for every billing question.

Provider setup

Setup includes products and prices, test and live keys, webhook signing secrets, Customer Portal decisions, test cards, environment variables, and production webhook URLs.

Operations

Billing systems need observability. We plan event logs, retry behavior, dashboard views, failed-payment notices, and role access so the team can support customers without opening Stripe for every question.

Example production scope

Subscription billing with Stripe Checkout, Laravel Cashier, customer portal access, invoice history, refund workflow, failed-payment emails, and webhook retries.

Handoff

The handoff includes source code, environment variable notes, webhook endpoint setup, test card flows, deployment steps, and documentation for future plan or pricing changes.