All articles
LaravelStripePayment GatewayPHPSaaS

Stripe Integration in Laravel: What the Documentation Doesn't Tell You

PKParth Kanasagra
9 min read

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.

Related Articles

PK

Parth Kanasagra

Certified Senior Laravel & PHP Developer · 9+ years experience · Available for hire