The simplest and safest way to take card payments in Laravel 13 is Stripe Checkout: your server creates a Checkout Session with the stripe-php library, redirects the customer to Stripe's hosted payment page, and Stripe sends the customer back to your site. You then confirm the payment with a webhook. Card numbers never touch your server. This guide builds a one-time payment in test mode.
Targets: Laravel 13 (PHP 8.3 to 8.5) and the stripe/stripe-php library. Use test keys until everything works.
Step 1: Install the Stripe library
composer require stripe/stripe-phpStep 2: Add your test API keys
In the Stripe Dashboard, switch to test mode and copy the secret key. Put it in .env:
STRIPE_SECRET=sk_test_your_secret_key
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret'stripe' => [
'secret' => env('STRIPE_SECRET'),
'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
],Add the array to config/services.php. Never commit secret keys, and never expose the secret key in JavaScript.
Step 3: Create the routes
use App\Http\Controllers\CheckoutController;
use Illuminate\Support\Facades\Route;
Route::post('/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');
Route::post('/stripe/webhook', [CheckoutController::class, 'webhook']);Step 4: Create the Checkout Session
<?php
namespace App\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Stripe\Exception\SignatureVerificationException;
use Stripe\StripeClient;
use Stripe\Webhook;
use UnexpectedValueException;
class CheckoutController extends Controller
{
public function create(): RedirectResponse
{
$stripe = new StripeClient(config('services.stripe.secret'));
$session = $stripe->checkout->sessions->create([
'mode' => 'payment',
'line_items' => [[
'price_data' => [
'currency' => 'usd',
'product_data' => ['name' => 'Laravel Course'],
'unit_amount' => 4900,
],
'quantity' => 1,
]],
'success_url' => route('checkout.success').'?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => route('checkout.cancel'),
]);
return redirect()->away($session->url);
}
public function success(Request $request)
{
return view('checkout.success');
}
public function cancel()
{
return view('checkout.cancel');
}
public function webhook(Request $request): Response
{
try {
$event = Webhook::constructEvent(
$request->getContent(),
$request->header('Stripe-Signature', ''),
config('services.stripe.webhook_secret')
);
} catch (UnexpectedValueException|SignatureVerificationException $e) {
return response('Invalid webhook.', 400);
}
if ($event->type === 'checkout.session.completed') {
$session = $event->data->object;
// Mark the order as paid. Look it up with $session->id or your own metadata.
}
return response('OK', 200);
}
}unit_amount is in the smallest currency unit, so 4900 means 49.00 US dollars. Do not take the price from the request; read it from your own database so users cannot change it.
Create resources/views/checkout/success.blade.php and cancel.blade.php with simple messages. The success page only tells the user "thank you"; it does not prove payment, because anyone can open that URL.
Step 5: Add the payment button
<form method="POST" action="{{ route('checkout.create') }}">
@csrf
<button type="submit">Buy now - $49</button>
</form>Step 6: Exclude the webhook from CSRF protection
Stripe cannot send a CSRF token, so exclude the webhook URL in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: [
'stripe/webhook',
]);
})The signature check in the controller replaces CSRF for this route.
Step 7: Test the webhook locally
Install the Stripe CLI and forward events to your local server:
stripe listen --forward-to http://localhost:8000/stripe/webhookThe CLI prints a signing secret that starts with whsec_. Put it into STRIPE_WEBHOOK_SECRET and run php artisan config:clear. In the Dashboard, you create the real endpoint later for production, with its own secret.
How do I test a payment?
- Click the Buy button. You should land on a Stripe-hosted page.
- Pay with the test card number
4242 4242 4242 4242, any future expiry date and any CVC. - Check that you return to the success page and that the Stripe CLI shows a
checkout.session.completedevent with a 200 response.
Common mistakes
- Trusting the success page: confirm payment through the webhook, not the redirect.
- Reading the price from the form: always take it from your own data.
- CSRF blocks the webhook: exclude the route as shown.
- Parsing the JSON before verifying: verify the signature using the raw body from
getContent(). - Mixing test and live keys: the webhook secret must belong to the same mode and endpoint as the key.
- Not handling duplicate events: Stripe can resend events, so make your "mark as paid" step safe to repeat.
FAQ
Should I use Laravel Cashier instead?
Cashier is a good choice for subscriptions and billing portals. For a simple one-time payment, the stripe-php flow above is small and clear.
Which payment methods are available?
Checkout shows the payment methods enabled in your Stripe settings for the customer's region. You do not hard-code them.
Do I need HTTPS?
Your live site must use HTTPS. Stripe's hosted page is already secure, and local test mode works over http://localhost.



