Stripe Integration in Laravel: What the Documentation Doesn't Tell You
Stripe's documentation is genuinely one of the best in the industry. You can get a basic integration working in an afternoon. But the first time a subscription renewal fails in production, or you get a webhook out of order, or a customer disputes a charge — that's when you realize the documentation covered the happy path and real life is messier.
Here's what I've learned from implementing Stripe in five different Laravel applications.
Laravel Cashier: Use It
Don't write your own Stripe wrapper. Laravel Cashier exists and it's maintained by the Laravel team. The only reason I've ever bypassed it is for very specific one-off payment use cases where I just needed raw Stripe API access.
composer require laravel/cashier
php artisan vendor:publish --tag="cashier-migrations"
php artisan migrate
Add the Billable trait to your User model. That's it. Now you have subscriptions, invoices, payment methods, and webhook handling mostly handled.
One-Time Charges vs Subscriptions
The setup differs significantly:
// One-time charge — use Payment Intents directly
$paymentIntent = $user->pay(4999, [
'description' => 'Pro Plan Upgrade',
'receipt_email' => $user->email,
]);
// Subscription — use Cashier's subscription builder
$user->newSubscription('default', 'price_pro_monthly')
->trialDays(14)
->create($paymentMethodId);
Webhooks: More Important Than the Checkout
This is where most integrations have gaps. Your checkout might work perfectly, but if your webhook handler is broken, you'll have customers who paid but whose accounts didn't update. That's a support nightmare.
Cashier handles most common events automatically. You need to register the webhook route and configure the secret:
// routes/web.php
Route::stripeWebhooks('stripe/webhook');
In your Stripe dashboard, set your webhook endpoint to https://yourapp.com/stripe/webhook and select these events at minimum: customer.subscription.updated, customer.subscription.deleted, invoice.payment_failed, invoice.payment_succeeded.
Then set STRIPE_WEBHOOK_SECRET in your .env. Cashier verifies the signature on every incoming webhook — without this, anyone can POST fake events to your endpoint.
Handling Failed Payments Gracefully
This is the part most tutorials skip. Stripe will retry failed payments automatically (configure Smart Retries in your Stripe Dashboard). But your app needs to know what to do while retries are happening — do you lock the account? Show a banner? Send an email?
class HandleFailedInvoice implements ShouldQueue
{
public function handle(WebhookReceived $event): void
{
if ($event->payload['type'] !== 'invoice.payment_failed') {
return;
}
$stripeCustomerId = $event->payload['data']['object']['customer'];
$user = User::where('stripe_id', $stripeCustomerId)->first();
if ($user === null) {
return;
}
// Give them a grace period before restricting access
$user->update(['payment_failed_at' => now()]);
$user->notify(new PaymentFailedNotification);
}
}
Register this listener on the WebhookReceived event in your EventServiceProvider.
A Real Webhook Gotcha: Order of Delivery
Stripe does not guarantee webhook delivery order. I've had customer.subscription.deleted arrive before invoice.payment_failed. Your handlers need to be idempotent — running them twice or in a different order shouldn't break things.
Cashier handles this for most cases, but if you're writing custom handlers, always check the current subscription state before acting on it:
// Don't assume the subscription is in the state you expect
$subscription = $user->subscription('default');
if ($subscription === null || $subscription->canceled()) {
return; // Already handled
}
Testing Stripe Integration
Use Cashier's fake in tests — don't make real API calls:
Cashier::fake();
$user = User::factory()->create();
$response = $this->actingAs($user)
->postJson('/billing/subscribe', [
'payment_method' => 'pm_card_visa',
'plan' => 'monthly',
]);
$response->assertOk();
Cashier::assertSubscribed($user);
A Few Hard-Learned Rules
- Never store raw card numbers. Ever. Stripe handles tokenization — let them.
- Always validate webhook signatures. A fake webhook event could grant free access to your app.
- Log every webhook payload for at least 30 days. You'll need it when debugging edge cases.
- Test your failure flows, not just your success flows. Most production issues are in the error paths.