Laravel Stripe Integration: Checkout & Webhooks Guide

Laravel Stripe integration done right: create Checkout Sessions, verify webhooks, prevent double-fulfilled orders and test locally with the Stripe CLI.

A Laravel Stripe integration with Stripe Checkout is the fastest safe way to take card payments in a PHP application. Stripe hosts the payment page, handles 3-D Secure, Apple Pay, Google Pay and PCI compliance, and then redirects the customer back to your site. You never touch a card number.

The part most tutorials get wrong is not the redirect. It is fulfilment. I have inherited projects where orders were marked as paid on the success page, where customers who closed the tab were charged but never received their product, and where a retried webhook shipped the same order twice. Every one of those bugs cost real money.

In this guide I will walk through the exact flow I use in production on Laravel 11 and PHP 8.3: creating orders and Checkout Sessions, fulfilling orders from a verified webhook, making that webhook idempotent, testing everything locally with the Stripe CLI, and the errors you are most likely to hit along the way.

Prerequisites

  • A Laravel 11 application running on PHP 8.3 or newer
  • A Stripe account in test mode (no business verification needed to start)
  • The Stripe CLI installed locally for forwarding webhooks
  • A queue driver configured (database or Redis) so fulfilment runs in the background
  • Basic familiarity with Eloquent models, routes and migrations

Why Stripe Checkout instead of a custom form

Stripe offers two main paths: Checkout, where Stripe hosts the payment page, and Payment Element, where you embed fields in your own page. For most Laravel projects I start with Checkout. It supports dozens of payment methods with a single API call, handles Strong Customer Authentication automatically, and keeps your PCI scope at the simplest level (SAQ A).

If you need subscriptions with a customer portal, Laravel Cashier is a good wrapper. For one-off payments, though, I prefer the plain stripe-php SDK: there is less magic, the code maps one-to-one to Stripe's documentation, and debugging is easier.

Step 1: Install the Stripe PHP SDK and add your keys

Install the official library with Composer and put your keys in .env. Never commit secret keys to Git, and use separate keys for test and live mode.

composer require stripe/stripe-php

# .env
STRIPE_KEY=pk_test_...
STRIPE_SECRET=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...

Then expose the keys through config/services.php so the rest of the app reads config() rather than env(). This matters because env() returns null once you run config:cache in production.

// config/services.php
'stripe' => [
    'key' => env('STRIPE_KEY'),
    'secret' => env('STRIPE_SECRET'),
    'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
],

Step 2: Prepare the orders table

Create the order in your own database before talking to Stripe. Your database, not Stripe, is the source of truth for what the customer bought. Store prices in the smallest currency unit (cents) as integers to avoid floating point rounding errors.

// database/migrations/2026_10_20_000000_create_orders_table.php
Schema::create('orders', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->nullable()->constrained();
    $table->unsignedInteger('total_cents');
    $table->string('currency', 3)->default('usd');
    $table->string('status')->default('pending'); // pending, paid, failed
    $table->string('stripe_session_id')->nullable()->unique();
    $table->string('stripe_payment_intent')->nullable();
    $table->timestamp('paid_at')->nullable();
    $table->timestamps();
});

Step 3: Create a Stripe Checkout Session in Laravel

Create the session, store its id on the order, and pass the order id as metadata so the webhook can find it later. I also set client_reference_id, which shows up in the Stripe dashboard and makes support tickets much easier to trace.

Notice the {CHECKOUT_SESSION_ID} placeholder in the success URL. Stripe replaces it with the real session id, which lets the thank-you page show the right order without trusting anything else from the query string.

namespace App\Http\Controllers;

use App\Models\Order;
use Stripe\StripeClient;

class CheckoutController extends Controller
{
    public function store(Order $order)
    {
        abort_unless($order->status === 'pending', 409);

        $stripe = new StripeClient(config('services.stripe.secret'));

        $session = $stripe->checkout->sessions->create([
            'mode' => 'payment',
            'client_reference_id' => (string) $order->id,
            'customer_email' => $order->user?->email,
            'line_items' => [[
                'price_data' => [
                    'currency' => $order->currency,
                    'product_data' => ['name' => "Order #{$order->id}"],
                    'unit_amount' => $order->total_cents,
                ],
                'quantity' => 1,
            ]],
            'metadata' => ['order_id' => $order->id],
            'success_url' => route('orders.thanks', $order).'?session_id={CHECKOUT_SESSION_ID}',
            'cancel_url' => route('cart'),
        ], [
            'idempotency_key' => "order-{$order->id}-checkout",
        ]);

        $order->update(['stripe_session_id' => $session->id]);

        return redirect()->away($session->url);
    }
}

The idempotency_key option protects you from double-clicks and network retries: if the same request is sent twice within 24 hours, Stripe returns the original session instead of creating a second one. Note that the key must be paired with identical parameters, so if the order total can change, include a version or the amount in the key.

Step 4: Fulfil orders from the Stripe webhook, not the success page

The success URL is just a page the browser visits. The customer might close the tab, lose signal on a train, or have a browser extension that blocks the redirect. If fulfilment depends on that page loading, you will eventually charge someone and ship nothing.

Stripe's checkout.session.completed webhook is the reliable signal. Stripe retries failed deliveries for up to three days, so as long as your endpoint eventually returns a 2xx, the order will be fulfilled. Always verify the signature so nobody can fake a payment by posting JSON to your endpoint.

namespace App\Http\Controllers;

use App\Jobs\FulfilOrder;
use App\Models\Order;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Stripe\Exception\SignatureVerificationException;
use Stripe\Webhook;

class StripeWebhookController extends Controller
{
    public function __invoke(Request $request)
    {
        try {
            $event = Webhook::constructEvent(
                $request->getContent(),
                $request->header('Stripe-Signature'),
                config('services.stripe.webhook_secret')
            );
        } catch (\UnexpectedValueException | SignatureVerificationException $e) {
            return response('Invalid payload', 400);
        }

        if ($event->type === 'checkout.session.completed') {
            $this->handleCompleted($event->data->object);
        }

        return response('ok');
    }

    private function handleCompleted(object $session): void
    {
        if ($session->payment_status !== 'paid') {
            return; // async methods (e.g. bank debits) complete later
        }

        DB::transaction(function () use ($session) {
            $order = Order::whereKey($session->metadata->order_id)
                ->lockForUpdate()
                ->first();

            // Idempotent: webhooks can be delivered more than once
            if (! $order || $order->status === 'paid') {
                return;
            }

            $order->update([
                'status' => 'paid',
                'paid_at' => now(),
                'stripe_payment_intent' => $session->payment_intent,
            ]);

            FulfilOrder::dispatch($order)->afterCommit();
        });
    }
}

Three details in this handler matter. First, the raw request body is passed to constructEvent; if any middleware modifies the body, the signature check fails. Second, lockForUpdate inside a transaction stops two simultaneous deliveries of the same event from both passing the status check. Third, the fulfilment job is dispatched after commit, so the worker never sees an order that is not yet saved as paid.

If you enable delayed payment methods such as SEPA Direct Debit or ACH, also listen for checkout.session.async_payment_succeeded and checkout.session.async_payment_failed, because payment_status will be unpaid when the session first completes.

Step 5: Register the route and exclude it from CSRF

Stripe cannot send your CSRF token, so the webhook route must be excluded. In Laravel 11 this moved out of the VerifyCsrfToken middleware class and into bootstrap/app.php. I keep the webhook in web.php so it is excluded explicitly, rather than relying on the api group.

// routes/web.php
use App\Http\Controllers\CheckoutController;
use App\Http\Controllers\StripeWebhookController;

Route::post('/orders/{order}/checkout', [CheckoutController::class, 'store'])
    ->middleware('auth')
    ->name('checkout');

Route::post('/stripe/webhook', StripeWebhookController::class);

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

Step 6: Test the Laravel Stripe webhook locally

Use the Stripe CLI to forward real events to your local server. The listen command prints a temporary webhook signing secret; put that in STRIPE_WEBHOOK_SECRET while developing. Then use test card 4242 4242 4242 4242 with any future date and any CVC, or 4000 0025 0000 3155 to trigger a 3-D Secure challenge.

stripe login
stripe listen --forward-to localhost:8000/stripe/webhook

# in another terminal, fire a sample event
stripe trigger checkout.session.completed

# keep a worker running so FulfilOrder is processed
php artisan queue:work

Common errors and fixes

  • No signatures found matching the expected signature: you are using the dashboard webhook secret locally (use the one printed by stripe listen), or a middleware is altering the raw body.
  • 419 Page Expired on the webhook: the route is not excluded from CSRF, or the path in validateCsrfTokens does not match exactly.
  • Invalid integer for unit_amount: you passed a decimal like 19.99. Stripe expects integer cents, so send 1999.
  • Orders stay pending in production: the webhook endpoint is not registered in the live dashboard, or it is registered with test mode keys.
  • Fulfilment runs twice: the status check is not inside a locked transaction, or you dispatch the job before checking whether the order is already paid.
  • env() returns null after deploy: you read env() outside config files and then ran config:cache.

Security and production best practices

  • Compute prices on the server from your own products table; never accept an amount from the browser.
  • Subscribe only to the events you handle, and return 200 quickly. Do heavy work in queued jobs.
  • Log the Stripe event id and ignore events you have already processed if you handle many event types.
  • Use restricted API keys where possible, and rotate secrets if they ever appear in logs or Git history.
  • Reconcile nightly: compare paid orders in your database with successful payments in Stripe to catch anything a bug missed.
  • Show the success page optimistically, but treat it as information only. The webhook decides whether an order is paid.

Conclusion

A reliable Laravel Stripe integration comes down to a few rules: create your order before the Checkout Session, fulfil only from a verified webhook, make fulfilment idempotent with a locked transaction, and test with the Stripe CLI before you go live. Get those right and Stripe Checkout becomes one of the least stressful parts of your stack.

Once this flow is in place, adding refunds, coupons or subscriptions is mostly a matter of handling a few more webhook events in the same pattern.

Frequently asked questions

Should I use Laravel Cashier or the Stripe PHP SDK?

Use Cashier if you are building subscriptions and want invoices, trials and a billing portal handled for you. For one-off payments with Stripe Checkout, the plain stripe-php SDK is simpler and maps directly to Stripe's documentation.

Why are my Stripe webhooks failing signature verification in Laravel?

Usually the webhook secret is wrong for the environment, since the Stripe CLI and each dashboard endpoint have different secrets. It can also happen when middleware changes the raw request body, so always pass $request->getContent() to constructEvent.

Is it safe to mark an order as paid on the Stripe success URL?

No. The success URL can be skipped if the customer closes the browser, and it can be visited by anyone who knows the address. Fulfil orders from the checkout.session.completed webhook after verifying its signature.

How do I stop a Stripe webhook from processing the same order twice?

Stripe may deliver an event more than once, so your handler must be idempotent. Lock the order row inside a transaction, check whether it is already paid, and only then update it and dispatch fulfilment.

How do I test Stripe payments in Laravel without real cards?

Use test mode keys and Stripe's test cards, such as 4242 4242 4242 4242. Run stripe listen to forward webhooks to your local app and stripe trigger to send sample events.