How to Integrate Stripe Payments in Laravel: A Step-by-Step Guide
Accept payments in Laravel with Stripe, step by step: from API keys to the Checkout session and verified webhooks. Code updated for Laravel 12 and 13.
How to Integrate Stripe Payments in Laravel: A Step-by-Step Guide
Accepting online payments is one of the most common requirements in a Laravel project, and Stripe is the de facto standard today: excellent documentation, an official SDK and a reliable webhook system to confirm transactions. In this guide we'll walk step by step through how to integrate a payment into a Laravel app using Stripe Checkout, Stripe's hosted payment page.
Why Checkout instead of a home-made payment form? Because card details never pass through your server: Stripe handles them directly on its secure page. This drastically reduces your security (PCI) requirements and saves you a lot of work.
Prerequisites: a working Laravel 12 or 13 app, PHP 8.2+ (8.3+ on Laravel 13), Composer and a Stripe account (the free one is fine).
1. Get your API keys in test mode
Log in to the Stripe dashboard, make sure the toggle at the top is set to Test mode, then go to Developers → API keys. You need two values:
Publishable key (
pk_test_...) — public, used on the client side.Secret key (
sk_test_...) — secret, used only on the server. Never share it.
In test mode you can simulate payments without moving any real money.
2. Install the official Stripe SDK
From your project root:
composer require stripe/stripe-php
3. Configure the keys in your application
Add the keys to your .env file (never hardcode them):
STRIPE_KEY=pk_test_xxxxxxxxxxxxxxxxxxxxxxxx
STRIPE_SECRET=sk_test_xxxxxxxxxxxxxxxxxxxxxxxx
STRIPE_WEBHOOK_SECRET=Then expose them in config/services.php, so you can reference them cleanly via config():
'stripe' => [
'key' => env('STRIPE_KEY'),
'secret' => env('STRIPE_SECRET'),
'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
],
4. Create the Checkout session
The heart of the integration: a controller that creates a payment session and redirects the user to Stripe's page.
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Stripe\Stripe;
use Stripe\Checkout\Session;
class CheckoutController extends Controller
{
public function create(Request $request)
{
Stripe::setApiKey(config('services.stripe.secret'));
$session = Session::create([
'mode' => 'payment',
'line_items' => [[
'price_data' => [
'currency' => 'eur',
'product_data' => ['name' => 'Pro Plan'],
'unit_amount' => 4900, // €49.00 — always in cents
],
'quantity' => 1,
]],
'success_url' => route('checkout.success') . '?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => route('checkout.cancel'),
]);
return redirect($session->url, 303);
}
}Two important details: amounts must always be expressed in cents (4900 = €49.00) and must be defined on the server, never taken from a field sent by the browser — otherwise the user could tamper with them.
5. Define the routes and the payment button
In routes/web.php:
use App\Http\Controllers\CheckoutController;
use App\Http\Controllers\StripeWebhookController;
Route::post('/checkout', [CheckoutController::class, 'create'])->name('checkout.create');
Route::view('/checkout/success', 'checkout.success')->name('checkout.success');
Route::view('/checkout/cancel', 'checkout.cancel')->name('checkout.cancel');
Route::post('/stripe/webhook', [StripeWebhookController::class, 'handle'])->name('stripe.webhook');In your Blade view, a small form pointing to the checkout route is all you need:
<form method="POST" action="{{ route('checkout.create') }}">
@csrf
<button type="submit">Buy — €49</button>
</form>The checkout.success and checkout.cancel pages are two simple views that show the outcome to the user.
6. Confirm the payment with webhooks
This is where the most common mistake happens: don't treat a payment as completed just because the user landed on the success page. The user might close the browser before the redirect, or the network might drop. The only reliable source of truth is the webhook Stripe sends to your server when the payment succeeds.
Create the controller that receives and verifies the events:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Stripe\Webhook;
use Stripe\Exception\SignatureVerificationException;
class StripeWebhookController extends Controller
{
public function handle(Request $request)
{
$payload = $request->getContent();
$signature = $request->header('Stripe-Signature');
$secret = config('services.stripe.webhook_secret');
try {
$event = Webhook::constructEvent($payload, $signature, $secret);
} catch (\UnexpectedValueException $e) {
return response('Invalid payload', 400);
} catch (SignatureVerificationException $e) {
return response('Invalid signature', 400);
}
if ($event->type === 'checkout.session.completed') {
$session = $event->data->object;
// This is where you fulfil the order: mark it as paid,
// send the confirmation email, grant access, etc.
// Useful data: $session->id, $session->customer_email, $session->amount_total
}
return response('OK', 200);
}
}Verifying the signature with constructEvent is essential: it guarantees the request really comes from Stripe and not from a bad actor faking a payment.
Exclude the webhook from CSRF protection
Stripe can't send a CSRF token, so the webhook route must be excluded from that check, in bootstrap/app.php. The syntax changes slightly depending on your Laravel version.
Laravel 13 (the middleware was renamed to PreventRequestForgery):
->withMiddleware(function (Middleware $middleware): void {
$middleware->preventRequestForgery(except: [
'stripe/*',
]);
})Laravel 12 and earlier with the new skeleton:
->withMiddleware(function (Middleware $middleware): void {
$middleware->validateCsrfTokens(except: [
'stripe/*',
]);
})Finally, register the endpoint in the Stripe dashboard (Developers → Webhooks), pointing it to https://yourdomain.com/stripe/webhook and selecting the checkout.session.completed event. Stripe will give you a signing secret (whsec_...) to paste into the STRIPE_WEBHOOK_SECRET variable.
7. Test everything locally with the Stripe CLI
In development your localhost isn't reachable by Stripe. The Stripe CLI solves this by forwarding events to your machine:
stripe login
stripe listen --forward-to localhost:8000/stripe/webhookThe listen command prints a temporary signing secret to the terminal: use it as your STRIPE_WEBHOOK_SECRET while testing. To simulate a payment, use the test card 4242 4242 4242 4242, with any future expiry date and any CVC.
8. Going live
When you're ready to go live, remember to: switch to your live keys (pk_live_... / sk_live_...), serve the app strictly over HTTPS, recreate the webhook endpoint in live mode (it will have a new signing secret) and make a first small real payment to verify the whole flow end to end.
Security and best practices
A few rules not to forget: the secret key stays server-side only, never in your JavaScript; always verify webhook signatures; calculate amounts on the server and never trust the client; handle the fact that Stripe may send the same event more than once (make the operation idempotent by checking whether a session ID has already been processed); and for heavy work after payment — sending emails, generating invoices — use Laravel queues so you can respond to Stripe immediately with a 200.
What about subscriptions?
If your project involves recurring payments, it's worth using Laravel Cashier, the official package that handles subscriptions, trial periods, plan changes and Stripe billing in just a few lines of code. For one-off payments, though, the Checkout approach shown here is a perfect fit.
In short
With Stripe Checkout you can integrate payments into Laravel securely and quickly: API keys, a Checkout session, and a verified webhook to confirm transactions. The most important part isn't making the "Pay" button appear — it's handling the webhook properly: that's what makes the payment truly reliable.
Do you have a project that needs to handle payments, subscriptions or a custom checkout? Tell us what you need: we'll help you integrate it the right way.