Skip to content
Laravel Integration

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 is the shape of a production Laravel Stripe implementation. The exact model names and price IDs change by product, but the important pieces are the same: authenticated routes, a controller that creates Stripe Checkout sessions, a webhook endpoint that becomes the source of truth for billing state, and explicit handling for incomplete payments, failed cards, duplicate events, 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
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_...
STRIPE_SUCCESS_URL="${APP_URL}/billing/success"
STRIPE_CANCEL_URL="${APP_URL}/billing"

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 routes and exempt the webhook from CSRF

Checkout and portal routes should be authenticated. The webhook route must remain unauthenticated, use Stripe signature verification, and be excluded from Laravel's CSRF middleware because Stripe cannot send a Laravel CSRF token.

use App\Http\Controllers\Billing\CheckoutController;
use App\Http\Controllers\Billing\StripeWebhookController;
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');
});

Route::post('/stripe/webhook', StripeWebhookController::class)
    ->name('stripe.webhook');

In a Laravel 10-style application structure, add the exact webhook URI to app/Http/Middleware/VerifyCsrfToken.php. Keep the exception as narrow as possible.

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
    {
        return $request->user()->redirectToBillingPortal(route('billing.success'));
    }

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

5. Handle webhooks as the source of truth

Never mark a subscription active only because the browser returned from Checkout. Use Stripe webhooks to process completed and expired sessions, subscription updates, failed payments, refunds, and cancellations. Stripe retries deliveries and can send the same event more than once, sometimes to two workers at the same time, so deduplication has to be atomic.

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 controller 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();
});

The controller then claims each event with a single atomic insertOrIgnore() inside a database transaction. On a duplicate the unique index rejects the row, the call affects zero rows, and the handler is skipped. The claim, handler database writes, and processed_at marker commit together. If the handler throws or the process dies before commit, the transaction rolls back and Stripe's retry can claim the event again.

namespace App\Http\Controllers\Billing;

use App\Http\Controllers\Controller;
use App\Models\StripeEvent;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Stripe\Exception\SignatureVerificationException;
use Stripe\Webhook;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
use UnexpectedValueException;

class StripeWebhookController extends Controller
{
    public function __invoke(Request $request): JsonResponse
    {
        try {
            $event = Webhook::constructEvent(
                $request->getContent(),
                $request->header('Stripe-Signature'),
                config('services.stripe.webhook_secret')
            );
        } catch (UnexpectedValueException $exception) {
            Log::warning('Malformed Stripe webhook payload.', [
                'message' => $exception->getMessage(),
            ]);

            return response()->json(['message' => 'Malformed Stripe payload'], Response::HTTP_BAD_REQUEST);
        } catch (SignatureVerificationException $exception) {
            Log::warning('Stripe webhook signature verification failed.', [
                'message' => $exception->getMessage(),
            ]);

            return response()->json(['message' => 'Invalid Stripe signature'], Response::HTTP_BAD_REQUEST);
        }

        try {
            DB::transaction(function () use ($event): void {
                $claimed = StripeEvent::query()->insertOrIgnore([
                    'stripe_id' => $event->id,
                    'type' => $event->type,
                    'payload' => json_encode($event->toArray()),
                    'created_at' => now(),
                    'updated_at' => now(),
                ]);

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

                match ($event->type) {
                    'checkout.session.completed' => $this->handleCheckoutCompleted($event->data->object),
                    'checkout.session.expired' => $this->recordExpiredCheckout($event->data->object),
                    'customer.subscription.updated' => $this->syncSubscription($event->data->object),
                    'customer.subscription.deleted' => $this->markSubscriptionCancelled($event->data->object),
                    'invoice.payment_failed' => $this->notifyFailedPayment($event->data->object),
                    'charge.refunded' => $this->recordRefund($event->data->object),
                    default => null,
                };

                StripeEvent::query()
                    ->where('stripe_id', $event->id)
                    ->update(['processed_at' => now()]);
            });
        } catch (Throwable $exception) {
            report($exception);

            return response()->json(
                ['message' => 'Stripe event handling failed'],
                Response::HTTP_INTERNAL_SERVER_ERROR
            );
        }

        return response()->json(['received' => true]);
    }
}

insertOrIgnore() passes straight through to the query builder, so Eloquent casts, mutators, and automatic timestamps do not apply. Encode the payload and set created_at and updated_at explicitly, as above. A database transaction cannot undo an email or third-party API call; dispatch that work after commit through an outbox or queued job 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 double emails or duplicate account updates. Also post malformed JSON and a bad signature and assert both return 400.

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.