Handling Stripe Subscription Lifecycle Events in Laravel: Beyond Webhook Verification
You've verified the webhook signature. Stripe events are flowing into /stripe/webhook. Now you're staring at fifteen event types in the dashboard wondering which Stripe subscription webhooks your Laravel app actually needs to handle — and what to do when invoice.payment_failed fires at 2am.
The Six Stripe Subscription Webhooks That Actually Matter
Not every event in Stripe's catalogue needs a handler. For a subscription lifecycle, these six cover everything your application cares about:
| Event | When it fires | What to do |
|---|---|---|
customer.subscription.created |
New subscription starts | Provision access, record locally |
customer.subscription.updated |
Plan change, trial ends, status changes | Sync subscription state |
customer.subscription.deleted |
Fully cancelled (not grace period) | Revoke access |
invoice.payment_succeeded |
Successful charge | Renew access, store receipt |
invoice.payment_failed |
Payment declined | Flag account, trigger dunning |
customer.subscription.trial_will_end |
3 days before trial expires | Send trial-ending notification |
Before writing any handlers, make sure your webhook endpoint is configured and signatures verified. Verifying Stripe webhook signatures in Laravel covers the full setup if you haven't done it yet.
What Cashier Handles For You
Laravel Cashier's built-in webhook controller automatically handles the three most critical events:
customer.subscription.updated— syncs the subscription status, current period end, and trial end to your localsubscriptionstablecustomer.subscription.deleted— marks the subscription as cancelled in the databaseinvoice.payment_succeeded— renews the subscription'sends_atvalue
You don't need to write handlers for these from scratch. What Cashier doesn't handle is the business logic: sending the cancellation email, unlocking or revoking feature flags, storing payment receipts, or triggering your dunning flow. That's your job.
Adding Custom Stripe Subscription Webhook Handlers in Laravel
Cashier dispatches a WebhookReceived event for every incoming payload before it processes anything itself. Register a listener for it:
// app/Providers/AppServiceProvider.php
use Laravel\Cashier\Events\WebhookReceived;
use App\Listeners\StripeEventListener;
public function boot(): void
{
Event::listen(WebhookReceived::class, StripeEventListener::class);
}
Then write the listener:
// app/Listeners/StripeEventListener.php
namespace App\Listeners;
use Carbon\Carbon;
use App\Models\User;
use App\Jobs\SendPaymentFailedEmail;
use App\Notifications\TrialEndingNotification;
use Laravel\Cashier\Events\WebhookReceived;
class StripeEventListener
{
public function handle(WebhookReceived $event): void
{
match ($event->payload['type']) {
'invoice.payment_failed' => $this->handlePaymentFailed($event->payload),
'customer.subscription.trial_will_end' => $this->handleTrialEnding($event->payload),
'customer.subscription.deleted' => $this->handleSubscriptionDeleted($event->payload),
default => null,
};
}
private function handlePaymentFailed(array $payload): void
{
$user = $this->getUserByStripeId($payload['data']['object']['customer']);
if (! $user) {
return;
}
// Flag the account so your UI can prompt for a card update
$user->update(['payment_failed_at' => now()]);
// Dispatch to a queue — don't do heavy work synchronously in a webhook
dispatch(new SendPaymentFailedEmail($user));
}
private function handleTrialEnding(array $payload): void
{
$trialEnd = Carbon::createFromTimestamp($payload['data']['object']['trial_end']);
$user = $this->getUserByStripeId($payload['data']['object']['customer']);
$user?->notify(new TrialEndingNotification($trialEnd));
}
private function handleSubscriptionDeleted(array $payload): void
{
$user = $this->getUserByStripeId($payload['data']['object']['customer']);
if (! $user) {
return;
}
// Cashier already updated the local subscription record.
// Your job is the business logic.
$user->revokeFeatureAccess();
}
private function getUserByStripeId(string $stripeCustomerId): ?User
{
return User::where('stripe_id', $stripeCustomerId)->first();
}
}
Handling Payment Failures and the Dunning Flow
invoice.payment_failed fires on the first decline. Stripe's smart retries will attempt the card again over the following days, but your application needs to act immediately — without revoking access yet.
A practical dunning flow:
- Set
payment_failed_aton the user so your UI can show a "please update your card" banner - Queue a payment-failed email (don't send synchronously — the webhook needs to return quickly)
- Don't revoke access — Stripe retries and fires
invoice.payment_succeededif it works - If Stripe exhausts all retries, it fires
customer.subscription.deleted. That's when you revoke access.
The webhook handler must return a 2xx response within a few seconds. Anything longer and Stripe treats the delivery as failed and retries. Dispatching jobs for anything expensive is the right pattern — scaling Laravel queues in production covers the Horizon setup you'll want backing this.
Handling Trial Endings
customer.subscription.trial_will_end fires three days before a trial expires. It's your chance to send a "your trial ends soon" email and prompt the user to add a payment method.
The payload's trial_end is a Unix timestamp:
private function handleTrialEnding(array $payload): void
{
$subscription = $payload['data']['object'];
$stripeCustomerId = $subscription['customer'];
$trialEnd = Carbon::createFromTimestamp($subscription['trial_end']);
$user = User::where('stripe_id', $stripeCustomerId)->first();
$user?->notify(new TrialEndingNotification($trialEnd));
}
For the full trial setup — how to configure trial lengths, grace periods, and what happens at conversion — implementing Stripe trials for memberships covers the Cashier patterns in detail.
Idempotency: Process Each Event Exactly Once
Stripe retries webhook delivery for up to 72 hours if your endpoint returns a non-2xx response. That means your handler can fire multiple times for the same event. Without idempotency, you'll send duplicate emails or apply credits twice.
Store the Stripe event ID and check before processing:
// Migration
Schema::create('stripe_webhook_events', function (Blueprint $table) {
$table->id();
$table->string('stripe_event_id')->unique(); // unique index = safe under concurrency
$table->timestamps();
});
// At the top of your listener's handle() method
use App\Models\StripeWebhookEvent;
public function handle(WebhookReceived $event): void
{
$eventId = $event->payload['id'];
// Bail early if already processed
if (StripeWebhookEvent::where('stripe_event_id', $eventId)->exists()) {
return;
}
// Record before processing — a second concurrent request will hit the unique constraint
StripeWebhookEvent::create(['stripe_event_id' => $eventId]);
match ($event->payload['type']) {
'invoice.payment_failed' => $this->handlePaymentFailed($event->payload),
'customer.subscription.trial_will_end' => $this->handleTrialEnding($event->payload),
'customer.subscription.deleted' => $this->handleSubscriptionDeleted($event->payload),
default => null,
};
}
The unique index on stripe_event_id handles race conditions: one insert wins, the other throws a QueryException for a duplicate. I'd wrap the insert in a try/catch and return early on the exception rather than checking-then-inserting to eliminate the window between the check and the write.
Testing Stripe Subscription Webhooks Locally
# Forward Stripe events to your local app
stripe listen --forward-to http://localhost:8000/stripe/webhook
# In a second terminal, trigger specific events
stripe trigger invoice.payment_failed
stripe trigger customer.subscription.trial_will_end
stripe trigger customer.subscription.deleted
# Inspect recent event payloads
stripe events list --limit 5
The Stripe CLI sends real test-mode payloads with a valid signature, so your STRIPE_WEBHOOK_SECRET verification won't reject them. Set STRIPE_WEBHOOK_SECRET in .env to the signing secret the CLI prints when it starts (whsec_...).
Gotchas and Edge Cases
customer.subscription.updated fires constantly. Every retry, every metadata update, every invoice creation triggers it. Cashier's automatic sync handles what you need — don't add heavy logic here.
Delivery order isn't guaranteed. You may receive invoice.payment_succeeded before customer.subscription.created for a new subscription. Design handlers to be safe regardless of order — check for the user's existence before acting, never assume prior state.
invoice.payment_failed vs charge.failed. For subscription billing, handle invoice.payment_failed. charge.failed is more granular and fires at the payment attempt level — useful for one-time charges but noisy for subscription dunning.
CSRF exclusion. Cashier's webhook route is already excluded from CSRF verification by the package. If you're routing webhooks manually, make sure the /stripe/webhook path is in your CSRF exception list.
Local dev: webhooks don't fire automatically. When you call $user->newSubscription()->create() locally, Stripe sends no webhooks unless you have the CLI listener running. Keep it running during subscription feature development.
Wrapping Up
The pattern is straightforward: let Cashier handle subscription status sync automatically, then write WebhookReceived listeners for the business logic it doesn't touch. Idempotency on every handler, jobs for anything expensive, and the Stripe CLI for local testing.
Once lifecycle events are solid, the natural next step is letting customers manage their own subscriptions. Adding a Stripe Customer Portal to your Laravel app covers the Cashier integration that handles plan changes, cancellations, and payment method updates without you building any UI. If you're extending into usage-based billing on top of subscriptions, usage-based billing with Stripe Meters and Laravel Cashier is the follow-on read.
Steven is a software engineer with a passion for building scalable web applications. He enjoys sharing his knowledge through articles and tutorials.