transistorized-cmd / stripe-toolkit-webhooks
Bulletproof Stripe webhook handling for Laravel — the first module of The Complete Stripe Toolkit. Idempotency, store-then-process, queue dispatch, snapshot + thin events.
Package info
github.com/transistorized-cmd/stripe-toolkit-webhooks
pkg:composer/transistorized-cmd/stripe-toolkit-webhooks
Requires
- php: ^8.2
- illuminate/contracts: ^11.0 || ^12.0 || ^13.0
- illuminate/database: ^11.0 || ^12.0 || ^13.0
- illuminate/http: ^11.0 || ^12.0 || ^13.0
- illuminate/queue: ^11.0 || ^12.0 || ^13.0
- illuminate/routing: ^11.0 || ^12.0 || ^13.0
- illuminate/support: ^11.0 || ^12.0 || ^13.0
- spatie/laravel-package-tools: ^1.16
- stripe/stripe-php: ^15.0 || ^16.0 || ^17.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.0
- orchestra/testbench: ^9.0 || ^10.0 || ^11.0
- pestphp/pest: ^3.0 || ^4.0
- pestphp/pest-plugin-laravel: ^3.0 || ^4.0
Suggests
- filament/filament: Required for the Filament admin panel (Pro tier).
README
First module of The Complete Stripe Toolkit for Laravel.
The webhook reliability layer your future-self wishes you had shipped on day one: idempotent, queue-backed, observable — for both classic snapshot webhooks and the new Event Destinations (thin events) — under a single typed DTO.
Status:
v1.0.0-rc.1. Free core feature-complete, 51 Pest tests green. Distilled from 10+ years of shipping Laravel and Stripe integrations across production SaaS apps. Pro module ships separately.
A taste
use TransistorizedCmd\StripeToolkit\Webhooks\Attributes\StripeEvent; use TransistorizedCmd\StripeToolkit\Webhooks\Contracts\WebhookEventDTO; use TransistorizedCmd\StripeToolkit\Webhooks\StripeWebhookHandler; #[StripeEvent('payment_intent.succeeded')] class FulfillOrder extends StripeWebhookHandler { public int $tries = 5; public array $backoff = [60, 300, 900, 1800, 3600]; public function handle(WebhookEventDTO $event): void { /** @var \Stripe\PaymentIntent $intent */ $intent = $event->relatedObject(); Order::where('stripe_pi', $intent->id) ->firstOrFail() ->markPaid($intent->amount, $intent->currency); } }
That's the whole thing. Drop the file under app/Stripe/Handlers/, and
the kit auto-discovers it via the attribute, persists every incoming
webhook keyed by event.id, queues your handler with its own retry
schedule, and dead-letters cleanly when retries run out.
The same handler matches payment_intent.succeeded and
v1.payment_intent.succeeded — when you migrate to Event Destinations,
your business code doesn't change.
Who this is for
You'll get the most out of this package if you fit one of these:
- You run a Laravel SaaS in production and you've already lived through a webhook outage — silent failures, double-processed events, duplicate emails to customers, accounting that doesn't reconcile. You want a layer that makes the next integration boring.
- You're rolling out Stripe Connect or Event Destinations and you need both the legacy snapshot webhooks and the new V2 thin events flowing through the same handlers without forking your code.
- You're a tech lead at a payments-heavy product and you'd rather install a vetted package than re-derive idempotency, retry semantics, and dead-letter tracking from first principles for the fourth time.
This is not the easiest entry point if you're brand new to Stripe
or Laravel queues — the kit assumes you know why event.id matters
and what backoff means. If that's you, ship spatie/laravel-stripe-webhooks
first, hit a wall, then come back.
What it does
The pipeline, from POST to handler:
Stripe POST /stripe/webhook
│
▼
[Controller]
1. Verify signature (HMAC SHA256, constant-time, tolerance configurable)
2. Detect format (snapshot vs thin) by inspecting payload.object
3. Persist row in stripe_webhook_calls keyed by event.id (UNIQUE)
└─ if event.id already PROCESSED → 200 "duplicate", no dispatch
└─ if event.id already RECEIVED → 200 "in_progress" (race winner owns it)
4. Fire WebhookReceived event
5. Dispatch ProcessStripeWebhook to queue
6. Return 200 (target: <100ms)
│
▼
[Queue worker · ProcessStripeWebhook]
Resolve handler classes (config map ∪ #[StripeEvent] attributes)
For each → dispatch RunStripeHandler to queue
│
▼
[Queue worker · RunStripeHandler] (one job per handler per event)
Reconstruct typed WebhookEventDTO
Try { handler->handle($event); mark processed; }
Catch { categorize error; release with backoff; }
After $tries failures → mark dead_letter, fire WebhookDeadLettered
What you get on top of that:
- Idempotency at the event level — same
event.idtwice is a no-op for handlers, including under concurrent delivery (race-tested). - Per-handler retries — one slow handler doesn't poison the rest;
each is its own job with its own
$tries/$backoff. - Native typed Stripe objects —
relatedObject()returns\Stripe\PaymentIntent,\Stripe\Charge, etc., not arrays. - Type normalization —
payment_intent.succeededandv1.payment_intent.succeededroute to the same handler; the prefix-stripping happens automatically. - Multi-secret routing —
/stripe/webhook/{configKey}lets you carry multiple Stripe webhook endpoints (Connect, separate mode, etc.) on a single Laravel app. - Connect-aware —
accountId()on the DTO surfaces the connected account id from the payload (snapshot or thin). - Read-only debug inspector at
/stripe-webhooks-debug(dev-mode) with a built-in form trigger to send signed test events to your own endpoint without leaving the browser. - Three artisan commands:
install,prune,migrate-from-spatie.
Why this and not…
spatie/laravel-stripe-webhooks |
laravel/cashier |
This kit | |
|---|---|---|---|
| Persists the call before processing | yes | no | yes |
Idempotency on event.id |
partial | no | event-level + per-handler |
| Per-handler retries with own backoff | no | no | yes |
| Dead-letter tracking with error category | no | no | yes |
| Adapter for thin events (Event Destinations) | no | no | yes |
| Type normalization (snapshot ↔ thin) | no | n/a | yes |
relatedObject() returns native Stripe types |
no | n/a | yes |
accountId() for Connect |
no | partial | yes |
| Read-only debug UI | no | no | yes |
| Dependency on subscription model | none | required | none |
| Scope | webhook plumbing | subscription billing | webhook plumbing + observability |
spatie/laravel-stripe-webhooks is the dominant package by downloads
(3M+) — it works, it's stable, it persists. The kit is what you reach
for when you outgrow it: when you want per-handler reliability and
observability, when you need thin events, when you're tired of
forking your handler logic by event-type version. There's a
migration command to import your existing Spatie
history.
Install
composer require transistorized-cmd/stripe-toolkit-webhooks php artisan stripe-webhooks:install php artisan migrate
stripe-webhooks:install does three things:
- Publishes
config/stripe-webhooks.php - Publishes the two migrations (
stripe_webhook_callsandstripe_webhook_handler_runs) - Scaffolds a sample handler at
app/Stripe/Handlers/HandlePaymentIntentSucceeded.phpso you have something to edit immediately
Add the signing secret to .env:
STRIPE_WEBHOOK_SECRET=whsec_…
Register the route:
// routes/web.php use TransistorizedCmd\StripeToolkit\Webhooks\Facades\StripeWebhook; StripeWebhook::route('stripe/webhook');
If your route lives in routes/web.php (rather than routes/api.php),
exclude it from CSRF in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware): void { $middleware->validateCsrfTokens(except: [ 'stripe/webhook', 'stripe/webhook/*', ]); })
Run a queue worker:
php artisan queue:work --queue=stripe-webhooks
Done. Your endpoint is live.
Choosing a Stripe API version
When you register the endpoint in Stripe Dashboard → Developers →
Webhooks (or via stripe listen), Stripe asks you to pick an API
version. The choice affects the shape of the payloads Stripe sends
to this endpoint — Stripe locks the version per endpoint, so it
becomes a one-way migration switch independent of your account
default.
| Option | Use it when |
|---|---|
Latest stable (e.g. 2026-04-22.dahlia at time of writing) |
New endpoints / new accounts. Aligns the payload shape with the most recent SDK and gets you all the modern fields. |
Your account's current version (e.g. 2023-10-16) |
You're integrating into an existing app pinned to that version. Match it so your endpoint's payloads match what Stripe::PaymentIntent::retrieve() returns elsewhere in your code. |
.preview versions |
Avoid for production. They include features in evolution; the SDK may not deserialize newly-added fields cleanly. |
The kit itself is version-agnostic — it persists api_version on every
stripe_webhook_calls row for auditing but doesn't branch on it. Your
handlers receive typed \Stripe\… objects either way; field
availability follows whatever version the endpoint is locked to.
Note: Stripe's "locked per endpoint" semantics are useful for gradual migrations. You can pin one endpoint to
dahliato start receiving new fields, while the rest of your account stays on2023-10-16. When you're ready, upgrade your account default to match.
Your first handler
Two ways to register, both work, you can mix them.
Attribute discovery — recommended, autoloaded from
app/Stripe/Handlers/ (path is configurable):
#[StripeEvent('invoice.payment_failed')] #[StripeEvent('invoice.payment_action_required')] // attribute is repeatable class StartDunning extends StripeWebhookHandler { public function handle(WebhookEventDTO $event): void { /** @var \Stripe\Invoice $invoice */ $invoice = $event->relatedObject(); // … } }
Config map — explicit, useful when registering closures or deferring class loading:
// config/stripe-webhooks.php 'handlers' => [ 'invoice.payment_failed' => [ \App\Stripe\Handlers\NotifyCustomer::class, \App\Stripe\Handlers\StartDunning::class, ], ],
Whichever you pick, the DTO contract is identical:
| Method | Returns |
|---|---|
id() |
string — Stripe evt_… |
type() |
string — raw type, e.g. payment_intent.succeeded |
normalizedType() |
string — version-prefix-stripped |
createdAt() |
\DateTimeImmutable |
apiVersion() |
?string — present on snapshot, null on thin |
accountId() |
?string — Connected Account id, when applicable |
livemode() |
bool |
sourceFormat() |
EventSource::Snapshot | EventSource::Thin |
relatedObject() |
mixed — typed \Stripe\… instance (lazy on thin) |
rawPayload() |
string — what Stripe POSTed, byte-for-byte |
See examples/ for four production-shaped handlers covering
fulfillment, dunning, refund reconciliation, and access revocation.
Multi-secret routing & Stripe Connect
Two complementary mechanisms for Connect:
Per-endpoint secrets
// routes/web.php StripeWebhook::route('stripe/webhook/{configKey}');
// config/stripe-webhooks.php 'webhook_secrets' => [ 'default' => env('STRIPE_WEBHOOK_SECRET'), 'platform' => env('STRIPE_WEBHOOK_SECRET_PLATFORM'), 'connect_v2' => env('STRIPE_WEBHOOK_SECRET_CONNECT_V2'), ],
A POST to /stripe/webhook/platform verifies against the platform
secret. The configKey is recorded on every WebhookCall, so you can
filter the inspector or query history by source.
accountId() on the DTO
When a single endpoint receives events from many connected accounts (typical Connect setup), the kit extracts the account id and exposes it on the DTO:
public function handle(WebhookEventDTO $event): void { if ($accountId = $event->accountId()) { $tenant = Tenant::where('stripe_account_id', $accountId)->firstOrFail(); // proceed with tenant context } }
The two are not alternatives — use both: the route param identifies the secret for HMAC verification, the accountId identifies the tenant.
Snapshot + thin events
Stripe sends two formats:
- Snapshot (
/v1/webhookslegacy): fullevent.data.objectin the payload. - Thin (Event Destinations / V2, GA Oct 2024): minimal envelope
with
related_objectmetadata only — the actual resource is fetched on demand.
You don't have to think about this. The kit:
- Detects format from
payload.object(eventvsv2.core.event) before signature verification. - Verifies with the right code path (
Stripe\Webhook::constructEventvsStripe\WebhookSignature::verifyHeader). - Hydrates a typed wrapper either way (
SnapshotEventDTOorThinEventDTO). - Returns the same contract from
relatedObject()— typed Stripe objects, instant for snapshot, lazy-fetched + cached for thin.
Type normalization means handlers registered as payment_intent.succeeded
match v1.payment_intent.succeeded automatically. Migrate to Event
Destinations whenever Stripe forces you; your code doesn't change.
stripe/stripe-php ^15/^16 are supported, but thin event support
needs ^17. If a v2 payload arrives on an older SDK, you get a clean
500 with an explicit UnsupportedSdkVersionException rather than
silent failure.
Reconciling with Stripe when webhooks fail
Webhooks are best-effort: networks blip, deploys race, signing secrets drift, and Stripe eventually gives up retrying. When state has diverged, ask the API directly — Stripe is always the source of truth.
The kit ships a small StripeReconciler for this. Two patterns:
Pattern A — app reconcile (the demo uses this)
You have a stored Stripe id (stripe_checkout_session_id,
stripe_payment_intent_id, …) on your own model. Fetch the live state
and apply your business logic:
use TransistorizedCmd\StripeToolkit\Webhooks\Support\StripeReconciler; public function reconcile(Order $order, StripeReconciler $reconciler): RedirectResponse { /** @var \Stripe\Checkout\Session $session */ $session = $reconciler->fetchObject($order->stripe_checkout_session_id); if ($session->payment_status === 'paid') { $order->markPaid(/* … */); } return back(); }
fetchObject($id) routes by id prefix (pi_, ch_, cs_, cus_,
sub_, in_, evt_) and returns the typed \Stripe\… instance.
Implements the kit's Contracts\StripeObjectFetcher interface — bind
your own implementation if you need extra prefixes or different SDK
options (Stripe Connect's stripe_account header, etc.).
Pattern B — re-run handlers against fresh state
You have a stored WebhookCall row that's stuck — the call landed but
processing failed, or the handler was deployed broken and you've since
fixed it. Tell the kit to refetch the related object and re-run the
event's handlers:
$reconciler->reconcile($webhookCall);
The kit synthesises a snapshot DTO carrying the fresh related
object and dispatches each handler synchronously. Handlers must be
idempotent (the kit's docs already say so) — re-running on
already-reconciled state is a no-op. A WebhookReconciled event is
fired so you can record audit trails.
Pro tier — operator tooling
The free core ships the primitive. The Pro module wraps it in operator-grade tooling:
php artisan stripe-webhooks:reconcile {id|--pending|--older=10m}for batch recovery- A "Reconcile" action on the Filament
WebhookCallResource - Audit log of who ran what reconcile and when
- Throttling and back-pressure for large batches against Stripe rate limits
For local recovery and one-off operator workflows, the free primitive is enough. Reach for Pro when you need the dashboard.
Debug inspector
In local/testing environments the kit exposes a read-only Blade UI
at /stripe-webhooks-debug:
- Live-updating table of recent calls with status counters and filters
- Detail view per call with metadata, handler runs, error stack traces, and pretty-printed payload
- Embedded form trigger that signs payloads server-side and POSTs them to your own endpoint — useful for replicating Stripe's exact wire format during development
- "Duplicate this event" links from rows and detail page
Auto-disabled in production unless you explicitly opt in:
STRIPE_WEBHOOKS_DEBUG=true
STRIPE_WEBHOOKS_DEBUG_PATH=/internal/webhooks # optional: change the URL
The form trigger makes the app POST to itself. PHP's built-in dev server is single-threaded by default; run it with workers:
PHP_CLI_SERVER_WORKERS=4 php artisan serve --no-reload
Testing helpers
Sign payloads programmatically in your test suite:
use TransistorizedCmd\StripeToolkit\Webhooks\Tests\Support\SignedPayload; $body = SignedPayload::body($eventArray); $header = SignedPayload::header($body, 'whsec_test_default'); $this->call('POST', '/stripe/webhook', [], [], [], [ 'HTTP_STRIPE_SIGNATURE' => $header, 'CONTENT_TYPE' => 'application/json', ], $body)->assertOk();
Pre-built fixtures cover snapshot (with optional Connect account) and thin event payloads:
use TransistorizedCmd\StripeToolkit\Webhooks\Tests\Fixtures\Fixtures; $payload = Fixtures::snapshotPaymentIntentSucceeded(accountId: 'acct_…'); $thinPayload = Fixtures::thinV1CustomerCreated();
A richer WebhookFactory API — fluent builders for every event type, à
la stripe trigger but Laravel-native — ships in the Pro module.
Laravel events to hook
use TransistorizedCmd\StripeToolkit\Webhooks\Events\WebhookDeadLettered; use TransistorizedCmd\StripeToolkit\Webhooks\Events\WebhookHandlerFailed; use TransistorizedCmd\StripeToolkit\Webhooks\Events\WebhookProcessed; use TransistorizedCmd\StripeToolkit\Webhooks\Events\WebhookReceived; Event::listen(WebhookDeadLettered::class, function (WebhookDeadLettered $e) { Slack::send("Stripe webhook DLQ: {$e->webhookCall->stripe_event_id}"); });
| Event | Fires when |
|---|---|
WebhookReceived |
Persisted to DB, before queue dispatch |
WebhookProcessed |
All handlers for an event finished OK |
WebhookHandlerFailed |
A handler attempt failed (may still retry) |
WebhookDeadLettered |
A handler exhausted retries — DLQ entry |
Artisan commands
| Command | What it does |
|---|---|
stripe-webhooks:install |
Publishes config + migrations + sample handler stub |
stripe-webhooks:prune |
Deletes rows past the retention horizon (--dry-run, --status= filters) |
stripe-webhooks:migrate-from-spatie |
Imports history from a spatie/laravel-stripe-webhooks install (--dry-run, --source-table=, --source-name=, --batch-size=, --since=) |
Schedule the prune in routes/console.php:
Schedule::command('stripe-webhooks:prune')->dailyAt('03:00');
Retention is configurable per status (see below).
Configuration reference
config/stripe-webhooks.php after stripe-webhooks:install:
return [ 'webhook_secrets' => [ 'default' => env('STRIPE_WEBHOOK_SECRET'), ], 'route' => [ 'path' => 'stripe/webhook', 'middleware' => ['api'], ], 'queue' => [ 'connection' => env('STRIPE_QUEUE_CONNECTION', env('QUEUE_CONNECTION', 'sync')), 'name' => env('STRIPE_QUEUE_NAME', 'stripe-webhooks'), ], 'tables' => [ 'webhook_calls' => 'stripe_webhook_calls', 'handler_runs' => 'stripe_webhook_handler_runs', ], 'retention' => [ 'processed_days' => 90, // null = retain forever 'failed_days' => 365, 'dead_letter_days' => null, // keep DLQ forever by default ], 'tolerance' => 300, // signature timestamp tolerance (s) 'handlers' => [ // 'invoice.payment_failed' => [\App\Stripe\Handlers\StartDunning::class], ], 'discover_attributes' => true, 'discover_path' => null, // null → app_path('Stripe/Handlers') 'debug' => [ 'enabled' => env('STRIPE_WEBHOOKS_DEBUG', null), // null = auto (on outside production) 'path' => env('STRIPE_WEBHOOKS_DEBUG_PATH', 'stripe-webhooks-debug'), 'middleware' => ['web'], 'per_page' => 25, 'auto_refresh_seconds' => 5, ], ];
Compatibility matrix
CI runs three combinations:
| PHP | Laravel | Stripe SDK |
|---|---|---|
| 8.2 | 11.x | ^15.0 |
| 8.3 | 12.x | ^16.0 |
| 8.4 | 13.x | ^17.0 |
Plus a static-analysis job (Pint preset Laravel + PHPStan / Larastan level 5) that runs before the matrix.
Filament v4 is an optional suggest for the Pro module's admin panel.
The free core doesn't depend on it.
Documentation
This README is the executive summary. The full guide lives in
docs/:
- Installation
- Writing handlers
- Multi-secret · Connect
- Thin events
- Debug inspector
- Migrating from Spatie
- Troubleshooting
- FAQ
The site is VitePress; cd docs && npm install && npm run docs:dev to
preview locally.
Roadmap & Pro module
This module focuses on the infrastructure of Stripe webhooks. The
companion Pro module (transistorized-cmd/stripe-toolkit-webhooks-pro)
adds the operator-facing parts:
- Filament v4 admin panel: replay actions, DLQ inspector, batch operations, advanced filters
stripe-webhooks:replayCLI command- DLQ alert notifications (Slack, email, custom channels)
- Test factories — Laravel-native equivalent to
stripe trigger - Zero-downtime signing-secret rotation helper
- Pulse + Telescope cards
Pro is paid (Lemon Squeezy). The free core works fully without it.
The wider picture — refunds, disputes, Connect platform mechanics, dunning flows, reconciliation, audit trails — is the subject of an upcoming book in The Complete Stripe Toolkit for Laravel series. Drop your email to be notified when it's ready.
Contributing
Issues and pull requests welcome. Before opening a PR:
composer install vendor/bin/pint --test # code style vendor/bin/phpstan analyse # static analysis vendor/bin/pest # 51 tests, ~0.8s
Security issues: please email rather than file publicly.
Credits
- Built by Jose Luis Pellicer (transistorized-cmd) — 10+ years building Laravel applications, with deep Stripe integration work across multiple production SaaS products.
- Inspired by the gaps in
spatie/laravel-stripe-webhooks— which remains a great default for simpler use cases.
License
MIT — see LICENSE.md.