Open-source project
spatie/laravel-stripe-webhooks avatar
spatie/laravel-stripe-webhooks

laravel-stripe-webhooks verifies the signature and stops right there

Handle Stripe webhooks in a Laravel application

524 stars68 forksPHPMIT

At a glance

What is it?
spatie/laravel-stripe-webhooks is a Laravel package that verifies the Stripe signature on incoming webhook requests, records valid calls in a database table, suppresses duplicates and hands the event to a job or listener. It deliberately does not do anything about your payment logic.
Who is it for?
Adopt spatie/laravel-stripe-webhooks if you run Laravel, take money through Stripe, and want signature verification, payload persistence and duplicate suppression handled before your own code runs. Do not adopt it if you need the package to complete a payment, because the README states it stops once the job or event is dispatched, and every downstream business rule stays in your codebase.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 15 days ago.
What is it written in?
Mainly PHP, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 20, 2026, and from our analysis. They are not legal advice.

Editorial analysis

The package handles delivery, and you handle the money

Stripe notifies applications of events through webhooks, and this package exists to make that notification channel safe to expose from a Laravel app. Out of the box it verifies the Stripe signature of all incoming requests, logs all valid calls to the database, and lets you define jobs or events that are dispatched when specific events hit your app.

The README is explicit about where its responsibility ends: the package will not handle what should be done after the webhook request has been validated and the right job or event is called. Any work concerning payments is yours to code up. That boundary is the single most important thing to understand before adopting, because it means you still write the part that is genuinely hard: reconciling a paid invoice, granting access, shipping a product.

The audience is Laravel developers who already have the Stripe side of their business working and who want the inbound webhook endpoint to be boring, auditable and resistant to forgery. It is a plumbing package, not a billing framework.

Signature checking, the 200 response and the 500 that replaces it

Stripe signs every request that hits your webhook url, and the package verifies that signature automatically. If the signature is not valid, the request is not written to the webhook_calls table and a Spatie\StripeWebhooks\WebhookFailed exception is thrown. If the signature is valid but something goes wrong during handling, the thrown exception is stored in the exception column of the record and the controller answers with a 500 instead of a 200.

That last detail is the design decision worth internalising. Unless something goes terribly wrong, the package always responds 200, and the reason given is practical: answering 200 prevents Stripe from resending the same event over and over again. A 500 is your signal that the event was not processed, and Stripe's own retry behaviour then takes over.

One caveat sits in the configuration rather than the code. The verify_signature key can be set to false, and the README notes that this can be handy in local environments. That is exactly what it is for, and it is also the switch that turns the whole package into an open endpoint, so its value in any deployed environment deserves a second look.

Every valid request lands in the webhook_calls table

All webhook requests with a valid signature are logged in the webhook_calls table, which has a payload column where the entire payload of the incoming webhook is saved. That single fact gives you an audit trail of every event Stripe delivered, including the ones your jobs ignored or failed on, which is often the first thing you want when a customer disputes a charge.

The table is created by a migration that you publish and run, and the model behind it is configurable. The model key in the config file points at Spatie\WebhookClient\Models\WebhookCall by default, and the comment states that your own class should equal or extend it. That extension point is how you would add columns of your own, or scope queries to a tenant, without forking the package.

Duplicate suppression is handled here too. Stripe might occasionally send the same webhook request more than once, and the package makes sure that each request is only processed once. Combined with the 200-by-default behaviour, this gives you the combination you want: no infinite retry loop from Stripe, and no double processing of a single event on your side.

Installing the package and wiring the route by hand

Installation is a composer step followed by two artisan publishing commands and one migration. The package is pulled in like this:

bash
composer require spatie/laravel-stripe-webhooks

The service provider registers itself, so there is nothing to add to your providers array. You still need to publish the config file, which lands at config/stripe-webhooks.php:

bash
php artisan vendor:publish --provider="Spatie\StripeWebhooks\StripeWebhooksServiceProvider"

The published file is where the behaviour is configured, and the keys that matter most are these:

php
    'signing_secret' => env('STRIPE_WEBHOOK_SECRET'),
    'default_job' => '',
    'model' => \Spatie\WebhookClient\Models\WebhookCall::class,
    'profile' => \Spatie\StripeWebhooks\StripeWebhookProfile::class,
    'connection' => env('STRIPE_WEBHOOK_CONNECTION'),
    'queue' => env('STRIPE_WEBHOOK_QUEUE'),
    'verify_signature' => env('STRIPE_SIGNATURE_VERIFY', true),

Then publish the migration for the webhook_calls table and run it:

bash
php artisan vendor:publish --provider="Spatie\WebhookClient\WebhookClientServiceProvider" --tag="webhook-client-migrations"
bash
php artisan migrate

Finally, set the url that Stripe should call in the Stripe dashboard, and register that same url in your routes file:

php
Route::stripeWebhooks('webhook-route-configured-at-the-stripe-dashboard');

Behind the scenes this registers a POST route to a controller provided by the package. Because Stripe has no way of getting a csrf-token, that route has to be added to the except array of the VerifyCsrfToken middleware, otherwise every delivery fails before it reaches your code:

php
protected $except = [
    'webhook-route-configured-at-the-stripe-dashboard',
];

Two extension points, one for jobs and one for listeners

There are two ways to handle an incoming webhook, and the README describes both: you can opt to queue a job or listen to the events the package fires. Queuing is the better fit for anything slow, because the connection and queue keys let you point webhook processing at a dedicated queue and connection so a slow payment reconciliation does not sit in the same queue as your mail.

Job mapping happens in the config file. The key is the name of the Stripe event type with the dot replaced by the _ character, and the commented entries show the shape:

php
    'jobs' => [
        // 'source_chargeable' => \App\Jobs\StripeWebhooks\HandleChargeableSource::class,
        // 'charge_failed' => \App\Jobs\StripeWebhooks\HandleFailedCharge::class,
    ],

There is also a default_job key for every other event type that has no job defined, and the comment notes that you may leave it empty to store the job in the database without processing it. That default is worth understanding before you fill it in, because a default job turns every unrecognised event into work on your queue, including event types you have never heard of.

The Stripe documentation is linked from the README as the reference for the full list of event types, and the README itself recommends reading the entire Stripe webhook documentation before using the package.

Where this stops being the right tool

Three situations rule the package out. First, any workload outside Laravel: the service provider, the artisan publish commands and the Route::stripeWebhooks macro are all framework-specific, and the config file, the migration and the model all assume a Laravel application.

Second, if you expect the package to reconcile payments. It stores the payload and dispatches your code; it does not know what a customer is owed, when an invoice is settled, or how a subscription should behave after a failed charge. The README is direct about this.

Third, if you need more than one processing strategy per event, or per-tenant webhook secrets. The configuration exposes a single signing_secret and a single jobs map, and the profile key points at one class that determines whether the call should be stored and processed. A platform serving many accounts with per-account endpoints needs something more layered, and the README does not document how to vary the secret per request.

The alternative worth comparing is writing the endpoint yourself in a controller, using the Stripe PHP SDK to verify the signature and a queue job to process it. The difference is exactly the package's scope: it adds signature verification, database logging of the payload, duplicate suppression and the always-200 response, and in exchange it fixes your route naming and the middleware exception. If you already have a webhook endpoint that does all four, this package adds a dependency rather than removing one.

Version cadence, upgrade notes and the licence

The package sits on version 3.12.0, released on 2026-10-08, after 3.11.1 on 2026-08-07 and 3.11.0 on 2026-04-01. The last push to the repository was on 2026-10-08 as well, so the release and the source are in step, and the repository is not archived.

Because a major version exists, an upgrade has a paper trail: the README points to an UPGRADING file for details, and the repository also carries a CHANGELOG.md. Read those before bumping a dependency in a payments path, since the behaviour around responses, storage and dispatch is exactly the kind of thing that changes between minor releases.

The licence is MIT, recorded in LICENSE.md. The dependency chain is visible in the config file, where the migration is published through the WebhookClientServiceProvider and the default model comes from the webhook client package, so a Stripe-specific package sits on top of a generic webhook client rather than reinventing the storage layer. That also means your dependency tree contains both packages, and version alignment between them is part of your upgrade work.

Editorial conclusion

Adopt spatie/laravel-stripe-webhooks if you run Laravel, take money through Stripe, and want signature verification, payload persistence and duplicate suppression handled before your own code runs. Do not adopt it if you need the package to complete a payment, because the README states it stops once the job or event is dispatched, and every downstream business rule stays in your codebase. Verify three things first: that the signing secret in the Stripe dashboard matches the STRIPE_WEBHOOK_SECRET you set, that the route you pass to Route::stripeWebhooks is also listed in the except array of the VerifyCsrfToken middleware, and that STRIPE_SIGNATURE_VERIFY is left at its default true outside local development.

Frequently asked questions

Does spatie/laravel-stripe-webhooks handle the payment logic for me?

No. The README states that the package will not handle what should be done after the webhook request has been validated and the right job or event is called, so any work regarding payments still has to be coded by you.

How do I install laravel-stripe-webhooks in Laravel?

Run composer require spatie/laravel-stripe-webhooks, then publish the config file with php artisan vendor:publish --provider="Spatie\StripeWebhooks\StripeWebhooksServiceProvider", publish the migration with the tag webhook-client-migrations through Spatie\WebhookClient\WebhookClientServiceProvider, and run php artisan migrate.

Why does Stripe get a 200 response even when processing fails?

The package always responds with 200 unless something goes terribly wrong, because answering 200 prevents Stripe from resending the same event repeatedly. If an exception occurs during handling it is saved in the exception column and the controller sends a 500 instead.

What happens if the Stripe signature is invalid?

The request is not logged in the webhook_calls table and a Spatie\StripeWebhooks\WebhookFailed exception is thrown. The package can also be configured to skip signature verification through the verify_signature key, which the README describes as handy in local environments.

Does laravel-stripe-webhooks prevent duplicate webhook processing?

Yes. Stripe might occasionally send a duplicate webhook request more than once, and the package makes sure that each request is only processed once. All requests with a valid signature are logged in the webhook_calls table, with the entire payload kept in the payload column.

Why does my Stripe route need to be excluded from CSRF verification?

Because Stripe has no way of getting a csrf-token, the route registered with Route::stripeWebhooks has to be added to the except array of the VerifyCsrfToken middleware, otherwise incoming deliveries are rejected before they reach the package controller.

Official sources

  1. License: MIT
  2. Project website
  3. README
  4. Releases
  5. spatie/laravel-stripe-webhooks on GitHub
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/spatie-laravel-stripe-webhooks.svg)](https://hysenlabs.com/projects/spatie-laravel-stripe-webhooks)

Was this useful?