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. You must verify the Stripe webhook signature before trusting the payload — the guide covers the exact constructEvent() pattern and common gotchas in detail. 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, implementing Stripe trials for memberships and adding a Stripe Customer Portal cover the Cashier-based subscription management flow that builds on this foundation.
FAQ#
Why does the webhook return a 400 on every request despite using the right secret?
The most common cause is parsing the request body before verification. Webhook::constructEvent() needs the raw, unchanged bytes. If middleware or another layer has buffered or re-serialized the body, the signature fails. Always extract the raw body with $request->getContent() before passing it to the verification method.
Can I use the success URL to immediately fulfil the order without waiting for the webhook?
No, not reliably. The customer's browser might crash after redirect but before the webhook fires, or the webhook may arrive first. Always gate fulfilment on the checkout.session.completed webhook, not the redirect. The success page can show a provisional message like "Processing..." but don't grant access until the webhook confirms.
What if I need to send the user a receipt email after checkout?
Dispatch a queued job from your webhook handler. Don't send the email synchronously in the webhook — you need to return a 2xx response quickly. Queue the job, return 200, and let the background worker handle the email. This pattern is critical for reliable webhook processing under load.
How do I test a one-time payment flow locally without going through the full Stripe dashboard?
Use the Stripe CLI with stripe listen --forward-to localhost:8000/stripe/webhook and stripe trigger checkout.session.completed to send real signed test events to your local endpoint. Set your local STRIPE_WEBHOOK_SECRET to the ephemeral secret the CLI prints when it starts.