Stripe Checkout sessions in Laravel — without Cashier

5 min read

Cashier is a solid package, but it brings a subscriptions table, a Billable trait, and migrations that make no sense when you're only taking one-time payments. If you need a Stripe-hosted Checkout page — take the money, redirect back, fulfil the order — you can skip Cashier entirely and talk directly to the Stripe PHP SDK in a handful of files.

When to use raw Stripe SDK instead of Cashier for Laravel

Cashier is the right call for recurring billing: subscriptions, proration, trial periods, invoices. For anything else, the SDK is all you need:

Use case Right tool
One-time payment Raw SDK
SaaS subscription Cashier
Marketplace charge Raw SDK
Per-seat billing Cashier
Physical goods checkout Raw SDK

The rest of this guide assumes you're in the left column.

Setting up stripe/stripe-php in Laravel

Install the SDK:

composer require stripe/stripe-php

Add your keys to .env:

STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_CURRENCY=gbp

Create config/stripe.php so every file pulls from one place:

// config/stripe.php
return [
    'secret'         => env('STRIPE_SECRET_KEY'),
    'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
    'currency'       => env('STRIPE_CURRENCY', 'gbp'),
];

Run php artisan config:clear after adding new env vars in production.

Building the Checkout controller

The modern Stripe PHP SDK uses StripeClient rather than static methods. It's easier to mock in tests and makes the API key explicit:

// app/Http/Controllers/CheckoutController.php
namespace App\Http\Controllers;

use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Stripe\StripeClient;

class CheckoutController extends Controller
{
    public function create(Request $request): RedirectResponse
    {
        $stripe = new StripeClient(config('stripe.secret'));

        $session = $stripe->checkout->sessions->create([
            'mode'       => 'payment',
            'line_items' => [[
                'price_data' => [
                    'currency'     => config('stripe.currency'),
                    'unit_amount'  => 2999, // pence/cents — £29.99 in GBP
                    'product_data' => [
                        'name' => 'Pro Licence — 1 Year',
                    ],
                ],
                'quantity' => 1,
            ]],
            // {CHECKOUT_SESSION_ID} is a Stripe template variable — do not URL-encode it
            'success_url'           => route('checkout.success') . '?session_id={CHECKOUT_SESSION_ID}',
            'cancel_url'            => route('checkout.cancel'),
            'customer_email'        => $request->user()?->email,
            'allow_promotion_codes' => true,
            'metadata'              => [
                'user_id' => $request->user()?->id,
            ],
        ]);

        return redirect($session->url);
    }

    public function success(Request $request): View
    {
        $stripe  = new StripeClient(config('stripe.secret'));
        $session = $stripe->checkout->sessions->retrieve(
            $request->query('session_id')
        );

        return view('checkout.success', compact('session'));
    }

    public function cancel(): View
    {
        return view('checkout.cancel');
    }
}

Register the routes in routes/web.php:

Route::get('/checkout', [CheckoutController::class, 'create'])->name('checkout.create');
Route::get('/checkout/success', [CheckoutController::class, 'success'])->name('checkout.success');
Route::get('/checkout/cancel', [CheckoutController::class, 'cancel'])->name('checkout.cancel');

// Webhook — registered here for clarity, but see CSRF note below
Route::post('/stripe/webhook', [WebhookController::class, 'handle'])->name('stripe.webhook');

Handling the checkout.session.completed webhook

Stripe sends checkout.session.completed when a payment succeeds. Verify the signature, then fulfil the order:

// app/Http/Controllers/WebhookController.php
namespace App\Http\Controllers;

use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Stripe\Exception\SignatureVerificationException;
use Stripe\Webhook;

class WebhookController extends Controller
{
    public function handle(Request $request): Response
    {
        $payload   = $request->getContent();
        $sigHeader = $request->header('Stripe-Signature');

        try {
            $event = Webhook::constructEvent(
                $payload,
                $sigHeader,
                config('stripe.webhook_secret')
            );
        } catch (SignatureVerificationException) {
            return response('Invalid signature', 400);
        } catch (\UnexpectedValueException) {
            return response('Invalid payload', 400);
        }

        match ($event->type) {
            'checkout.session.completed' => $this->fulfil($event->data->object),
            default                      => null,
        };

        return response('OK', 200);
    }

    private function fulfil(\Stripe\Checkout\Session $session): void
    {
        $userId = $session->metadata->user_id ?? null;

        if (! $userId) {
            return;
        }

        $user = User::find($userId);

        // Idempotency check — Stripe can deliver the same event more than once
        if ($user?->hasProAccess()) {
            return;
        }

        $user?->grantProAccess();
    }
}

Excluding the webhook from Laravel's CSRF protection

Stripe doesn't send a CSRF token. In Laravel 11 and 12, exclude the route in bootstrap/app.php:

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

The older VerifyCsrfToken.php with $except still works in Laravel 12 if you're upgrading an existing app, but the bootstrap/app.php approach is the current convention for new projects.

Testing locally with the Stripe CLI

# Forward events to your local server
stripe listen --forward-to localhost:8000/stripe/webhook

# Trigger a checkout.session.completed event
stripe trigger checkout.session.completed

stripe listen outputs a webhook secret specific to that session — use it as STRIPE_WEBHOOK_SECRET in your local .env. It is not the same value as your production secret.

Use 4242 4242 4242 4242 (any future expiry, any CVC) for a successful test payment. 4000 0000 0000 9995 triggers a card decline.

Gotchas and Edge Cases

Webhooks fire more than once. Stripe retries on non-2xx responses and network failures. Always check the current state before fulfilling — the idempotency guard in the example above is not optional.

Raw body is required for signature verification. Webhook::constructEvent() needs the unmodified request body. $request->getContent() is safe in a standard Laravel app. If you're seeing SignatureVerificationException in production despite a correct secret, check whether any middleware or CDN layer is buffering or transforming the body before it reaches Laravel.

The {CHECKOUT_SESSION_ID} template variable must not be URL-encoded. Build the success URL with string concatenation, not through a URL builder:

// Correct — Stripe replaces {CHECKOUT_SESSION_ID} before redirecting
'success_url' => route('checkout.success') . '?session_id={CHECKOUT_SESSION_ID}',

// Wrong — route() will encode the braces and Stripe won't recognise the template
'success_url' => route('checkout.success', ['session_id' => '{CHECKOUT_SESSION_ID}']),

Your production webhook secret is not the same as your local one. The secret from stripe listen is ephemeral. The production secret lives in the Stripe Dashboard under Webhooks → your endpoint → Signing secret.

unit_amount is always in the smallest currency unit. For GBP and USD that's pence/cents. £29.99 = 2999. For zero-decimal currencies like JPY, unit_amount is the face value.

Wrapping Up

For one-time payments in Laravel, this is the full picture: install stripe/stripe-php, create a Checkout Session, redirect, verify the webhook signature, and guard against duplicate fulfilment. No Cashier required, no extra migrations, no Billable trait. If you later need subscriptions or trials, the Laravel Cashier docs pick up from here — and you can migrate to Cashier without touching anything you built above.

stripe
payments
laravel
webhooks
Steven Richardson

Steven is a software engineer with a passion for building scalable web applications. He enjoys sharing his knowledge through articles and tutorials.