Laravel + Stripe Integration
Accept payments, handle subscriptions, and manage payouts in your Laravel application with Stripe.
What You Can Build
One-Time Payments
Accept credit card payments for products, services, or donations with Stripe Checkout.
Subscription Billing
Recurring payments with trials, tiers, usage-based billing, and automatic renewals.
Marketplace Payments
Split payments between sellers and platform with Stripe Connect.
Webhooks & Automation
Real-time notifications for successful payments, failed charges, and subscription events.
How It Works
Install Stripe Package
Laravel Cashier provides a clean API on top of Stripe.
Configure API Keys
Add Stripe keys to your .env file and configure webhooks.
Create Models
Add Billable trait to your User model for subscription support.
Build Checkout Flow
Use Checkout Sessions or embed payment forms in your app.
Code-behind example
Complete Laravel Stripe setup: checkout, routes, webhooks, and errors
This is the shape of a production Laravel Stripe implementation. The exact model names and price IDs change by product, but the important pieces are the same: authenticated routes, a controller that creates Stripe Checkout sessions, a webhook endpoint that becomes the source of truth for billing state, and explicit handling for incomplete payments, failed cards, duplicate events, and customer portal access.
1. Install Cashier and set environment values
Use Cashier for subscriptions, checkout, invoices, and the customer portal. Keep Stripe identifiers in config so staging and production never share price IDs by accident.
composer require laravel/cashier
php artisan vendor:publish --tag="cashier-migrations"
php artisan migrate
STRIPE_KEY=pk_test_...
STRIPE_SECRET=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRO_PRICE_ID=price_...
STRIPE_SUCCESS_URL="${APP_URL}/billing/success"
STRIPE_CANCEL_URL="${APP_URL}/billing"
2. Add Billable to the customer model
Cashier stores the Stripe customer ID and subscription state on the billable model. Most SaaS apps use User; team-based products may bill an Account or Organization model instead.
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Laravel\Cashier\Billable;
class User extends Model
{
use Billable;
}
3. Define routes and exempt the webhook from CSRF
Checkout and portal routes should be authenticated. The webhook route must remain unauthenticated, use Stripe signature verification, and be excluded from Laravel's CSRF middleware because Stripe cannot send a Laravel CSRF token.
use App\Http\Controllers\Billing\CheckoutController;
use App\Http\Controllers\Billing\StripeWebhookController;
use Illuminate\Support\Facades\Route;
Route::middleware(['auth'])->group(function (): void {
Route::post('/billing/checkout', [CheckoutController::class, 'store'])
->name('billing.checkout');
Route::get('/billing/portal', [CheckoutController::class, 'portal'])
->name('billing.portal');
Route::get('/billing/success', [CheckoutController::class, 'success'])
->name('billing.success');
});
Route::post('/stripe/webhook', StripeWebhookController::class)
->name('stripe.webhook');
In a Laravel 10-style application structure, add the exact webhook URI to app/Http/Middleware/VerifyCsrfToken.php. Keep the exception as narrow as possible.
protected $except = [
'stripe/webhook',
];
4. Create the checkout controller with API failure handling
The controller creates a hosted Stripe Checkout session and redirects the customer to it. Stripe handles payment authentication on the hosted page; your application handles API failures here and completed or expired sessions through webhooks.
namespace App\Http\Controllers\Billing;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Stripe\Exception\ApiErrorException;
class CheckoutController extends Controller
{
public function store(Request $request): RedirectResponse
{
$user = $request->user();
try {
return $user
->newSubscription('default', config('services.stripe.pro_price_id'))
->allowPromotionCodes()
->checkout([
'success_url' => route('billing.success').'?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => route('billing.portal'),
'customer_update' => [
'address' => 'auto',
'name' => 'auto',
],
])
->redirect();
} catch (ApiErrorException $exception) {
report($exception);
return back()
->with('billing_error', 'Stripe could not start checkout. Please try again or contact support.');
}
}
public function portal(Request $request): RedirectResponse
{
return $request->user()->redirectToBillingPortal(route('billing.success'));
}
public function success(): RedirectResponse
{
return redirect()
->route('dashboard')
->with('status', 'Your billing details are being confirmed by Stripe.');
}
}
5. Handle webhooks as the source of truth
Never mark a subscription active only because the browser returned from Checkout. Use Stripe webhooks to process completed and expired sessions, subscription updates, failed payments, refunds, and cancellations. Stripe retries deliveries and can send the same event more than once, sometimes to two workers at the same time, so deduplication has to be atomic.
The persistence invariant: the database, not the application code, must enforce that a Stripe event ID is stored at most once. A unique index on stripe_id is what makes the deduplication correct. Checking exists() and then calling create() is a read-then-write race: two concurrent deliveries of the same event can both pass the check before either inserts, and both will run the side effects. Without the unique index, the controller below silently loses its protection.
Schema::create('stripe_events', function (Blueprint $table): void {
$table->id();
$table->string('stripe_id')->unique();
$table->string('type')->index();
$table->json('payload');
$table->timestamp('processed_at')->nullable();
$table->timestamps();
});
The controller then claims each event with a single atomic insertOrIgnore() inside a database transaction. On a duplicate the unique index rejects the row, the call affects zero rows, and the handler is skipped. The claim, handler database writes, and processed_at marker commit together. If the handler throws or the process dies before commit, the transaction rolls back and Stripe's retry can claim the event again.
namespace App\Http\Controllers\Billing;
use App\Http\Controllers\Controller;
use App\Models\StripeEvent;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Stripe\Exception\SignatureVerificationException;
use Stripe\Webhook;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
use UnexpectedValueException;
class StripeWebhookController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
try {
$event = Webhook::constructEvent(
$request->getContent(),
$request->header('Stripe-Signature'),
config('services.stripe.webhook_secret')
);
} catch (UnexpectedValueException $exception) {
Log::warning('Malformed Stripe webhook payload.', [
'message' => $exception->getMessage(),
]);
return response()->json(['message' => 'Malformed Stripe payload'], Response::HTTP_BAD_REQUEST);
} catch (SignatureVerificationException $exception) {
Log::warning('Stripe webhook signature verification failed.', [
'message' => $exception->getMessage(),
]);
return response()->json(['message' => 'Invalid Stripe signature'], Response::HTTP_BAD_REQUEST);
}
try {
DB::transaction(function () use ($event): void {
$claimed = StripeEvent::query()->insertOrIgnore([
'stripe_id' => $event->id,
'type' => $event->type,
'payload' => json_encode($event->toArray()),
'created_at' => now(),
'updated_at' => now(),
]);
if ($claimed === 0) {
return;
}
match ($event->type) {
'checkout.session.completed' => $this->handleCheckoutCompleted($event->data->object),
'checkout.session.expired' => $this->recordExpiredCheckout($event->data->object),
'customer.subscription.updated' => $this->syncSubscription($event->data->object),
'customer.subscription.deleted' => $this->markSubscriptionCancelled($event->data->object),
'invoice.payment_failed' => $this->notifyFailedPayment($event->data->object),
'charge.refunded' => $this->recordRefund($event->data->object),
default => null,
};
StripeEvent::query()
->where('stripe_id', $event->id)
->update(['processed_at' => now()]);
});
} catch (Throwable $exception) {
report($exception);
return response()->json(
['message' => 'Stripe event handling failed'],
Response::HTTP_INTERNAL_SERVER_ERROR
);
}
return response()->json(['received' => true]);
}
}
insertOrIgnore() passes straight through to the query builder, so Eloquent casts, mutators, and automatic timestamps do not apply. Encode the payload and set created_at and updated_at explicitly, as above. A database transaction cannot undo an email or third-party API call; dispatch that work after commit through an outbox or queued job keyed by stripe_id.
Webhook tests
Use stripe listen --forward-to https://your-app.test/stripe/webhook, replay duplicate events concurrently, and verify that the unique index on stripe_id prevents double emails or duplicate account updates. Also post malformed JSON and a bad signature and assert both return 400.
Failure tests
Run Stripe test cards for declined cards, insufficient funds, 3D Secure, expired cards, refunds, chargebacks, cancelled subscriptions, and expired Checkout sessions.
Internal links
For product scope, pair this billing flow with the SaaS MVP guide, e-commerce development, and the webhooks glossary.
Integration planning
Plan the Laravel Stripe integration before coding.
Direct answer
A Laravel Stripe integration is production-ready when products, prices, customers, subscriptions, invoices, refunds, failed payments, and webhooks all map cleanly to Laravel records and support workflows. The goal is not just checkout success; it is trustworthy billing state after renewals, cancellations, disputes, and plan changes.
A production Stripe integration is more than adding a checkout button. The application needs a reliable payment model, subscription state, webhook processing, refund logic, customer records, and a support path when a card fails or a plan changes. Somnio scopes the billing rules first so payment behavior matches how the business actually sells.
Data and events model
We map products, prices, subscriptions, invoices, customers, and webhook events before implementation. That keeps Laravel models, Stripe records, and admin screens aligned when payments succeed, fail, refund, or renew.
Failure states
Plan for duplicate webhooks, incomplete checkouts, failed cards, expired trials, disputed charges, refunds, tax changes, and customers who upgrade, downgrade, or cancel mid-cycle.
Admin and support visibility
Support teams need customer IDs, subscription status, invoice links, failed-payment history, refund notes, and safe role access without opening Stripe for every billing question.
Provider setup
Setup includes products and prices, test and live keys, webhook signing secrets, Customer Portal decisions, test cards, environment variables, and production webhook URLs.
Operations
Billing systems need observability. We plan event logs, retry behavior, dashboard views, failed-payment notices, and role access so the team can support customers without opening Stripe for every question.
Example production scope
Subscription billing with Stripe Checkout, Laravel Cashier, customer portal access, invoice history, refund workflow, failed-payment emails, and webhook retries.
Handoff
The handoff includes source code, environment variable notes, webhook endpoint setup, test card flows, deployment steps, and documentation for future plan or pricing changes.