spatie/laravel-stripe-webhooks
Handle Stripe webhooks in Laravel with automatic signature verification, database logging of valid calls, and dispatch of jobs or events for specific Stripe events. You still implement the payment-related business logic yourself.
Install and wire it up in ~10 minutes:
composer require spatie/laravel-stripe-webhooks
php artisan vendor:publish --provider="Spatie\StripeWebhooks\StripeWebhooksServiceProvider"
php artisan vendor:publish --provider="Spatie\WebhookClient\WebhookClientServiceProvider" --tag="webhook-client-migrations"
php artisan migrate
Note the second publish: this package is a thin Stripe adapter around spatie/laravel-webhook-client, so the webhook_calls table migration lives in the underlying package, not this one. Missing that publish is the single most common install failure.
Then three things:
STRIPE_WEBHOOK_SECRET=whsec_... in .env (per-endpoint secret from the Stripe dashboard, not your API key).Route::stripeWebhooks('stripe/webhook'); in routes/api.php or routes/web.php.VerifyCsrfToken::$except. Stripe can't send a CSRF token, so an unexcepted route fails with 419 before the package ever sees it.Where to look first: config/stripe-webhooks.php. It's short and every meaningful extension point is a key in it (jobs, default_job, model, profile, queue). Read it before writing any handler code.
First use case: get one event end-to-end and verify it in the database before writing business logic.
// config/stripe-webhooks.php
'jobs' => [
'payment_intent_succeeded' => \App\Jobs\StripeWebhooks\HandlePaymentIntentSucceeded::class,
],
Locally, don't hand-craft requests — use the Stripe CLI:
stripe listen --forward-to localhost:8000/stripe/webhook
stripe trigger payment_intent.succeeded
stripe listen prints a whsec_... that is different from your dashboard secret; put that in .env while developing. Then confirm a row appeared in webhook_calls and your job ran (check failed_jobs and the exception column if not).
Job-per-event is the primary pattern. The job receives the WebhookCall model, not the raw payload, which means the full payload is re-readable after a failure and the call is replayable:
class HandlePaymentIntentSucceeded implements ShouldQueue
{
use InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public WebhookCall $webhookCall) {}
public function handle(): void
{
$intent = $this->webhookCall->payload['data']['object'];
// fulfil the order for $intent['id'], etc.
}
}
Always ShouldQueue. The HTTP response to Stripe should be near-instant; Stripe times out fast and a slow controller means duplicate deliveries.
Event listeners instead of jobs when you want Laravel's event system (multiple listeners, Event::fake() in tests, wildcard listeners):
protected $listen = [
'stripe-webhooks::payment_intent.succeeded' => [ChargeSucceeded::class],
];
Mind the two naming conventions. This is the sharpest edge in the package:
| Thing | Format | Example |
|---|---|---|
config('stripe-webhooks.jobs') key |
dots → underscores | payment_intent_succeeded |
| Event name fired | dots preserved, prefixed | stripe-webhooks::payment_intent.succeeded |
Stripe payload type |
dotted | payment_intent.succeeded |
Catch-all with default_job. Useful for audit logging, metrics, or a "we saw an event we don't handle" Slack alert. Leave it as '' and unknown events are stored but silently never processed — safe default, but it hides gaps.
Typed payloads. The payload attribute is an array. For real work, hydrate the Stripe object so you get IDE support and correct types:
use Stripe\Event;
$event = Event::constructFrom($this->webhookCall->payload);
$intent = $event->data->object; // \Stripe\PaymentIntent
Idempotency guard. Stripe guarantees at-least-once delivery. Even though the package logs each request, it doesn't dedupe business effects for you. Either guard in the handler (unique index on stripe_event_id, or Cache::add($payload['id'], true, 86400)) or swap in a profile:
class StripeWebhookProfile implements WebhookProfile
{
public function shouldProcess(Request $request): bool
{
return ! WebhookCall::where('payload->id', $request->get('id'))->exists();
}
}
Replay after an incident. When a job failed at 2am, fix the bug and re-dispatch — no need to ask Stripe to resend:
dispatch(new ProcessStripeWebhookJob(WebhookCall::find($id)));
Multiple secrets (Stripe Connect). Use the {configKey} route parameter and pair it with suffixed config keys:
Route::stripeWebhooks('stripe/webhook/{configKey}');
// config: 'signing_secret_account' => 'whsec_abc', 'signing_secret_connect' => 'whsec_123'
Queue separation. Set STRIPE_WEBHOOK_QUEUE / STRIPE_WEBHOOK_CONNECTION so payment processing doesn't sit behind email jobs. Give those workers a longer --timeout and --tries=5.
Testing. Don't fight the signature. Either test the job directly with a factory-made WebhookCall, or Event::fake() and assert webhook_calls rows + event dispatch. If you need the HTTP path, disable verification in the test env or generate a real signature with \Stripe\WebhookSignature.
200 is sent for almost everything — that's intentional. The package returns 200 to stop Stripe retrying, except when your handler throws: then the exception column is populated and a 500 goes back so Stripe retries. Don't "fix" a failing handler by swallowing exceptions; you'll break the retry contract.
Invalid signatures are not logged. WebhookFailed is thrown before the webhook_calls row is written, so there's no DB trace. If signatures fail, the secret is wrong (wrong endpoint's secret, stripe listen secret left in .env, trailing slash mismatch, or a proxy/middleware rewriting the request body and breaking the HMAC).
Jobs and events fire. The package both dispatches the configured job and fires stripe-webhooks::<type>. If you register both for one event, you'll process it twice. Pick one mechanism per event type and be consistent.
The README's "Performing custom logic" section is misleading. It shows extending ProcessStripeWebhookJob and putting that class in the model config key. The model key must be a WebhookCall subclass (per the config file's own comment and the underlying webhook-client contract). To customise the processing job, subclass ProcessStripeWebhookJob and bind it separately — don't put a job class in model.
Empty default_job = silent no-op. Unconfigured event types are persisted and then dropped. During rollout, log $webhookCall->payload['type'] for a week to discover which events actually hit your endpoint before locking down the jobs array.
Events arrive out of order and more than once. customer.subscription.updated can land before customer.created. Never assume the referenced object exists locally — re-fetch from the Stripe API using the ID in the payload rather than trusting only the embedded snapshot, and make handlers idempotent and order-tolerant.
webhook_calls grows forever. Add a scheduled prune (WebhookCall::where('created_at', '<', now()->subDays(30))->delete()). Querying it is fine — where('payload->id', ...) works on MySQL 5.7+/Postgres/SQLite 3.38+.
Queue workers, not just the web server. If queue:work isn't running, requests return 200, rows sit in webhook_calls with NULL exception, and nothing happens — indistinguishable from success in logs. Alert on webhook_calls where created_at is old and exception IS NULL and no downstream side effect.
Local verify_signature => false is a footgun that ships to prod. Use it only via a local env binding; the Stripe CLI gives you a real signature anyway, so you rarely need it.
Pin the version and read UPGRADING.md before minor bumps. This package delegates to spatie/laravel-webhook-client, so a major bump there can change the WebhookProfile/WebhookCall contract underneath you. Constrain both in composer.json and check the release notes — the release cadence here is lumpy rather than continuous.
The package stops at the boundary. It validates, persists, and routes. Everything about money — amounts, currency, refunds, reconciliation against your own ledger — is yours. Treat the webhook as a notification, not a source of truth, and reconcile with a periodic pull from the Stripe API.
How can I help you explore Laravel packages today?