Skip to content
article

Stripe webhooks in Laravel: verify, queue, return 200, and stop double-fulfilling orders

right, they're down, where was I. a webhook setup that survived Black Friday

Imran Hussain@imran_builds

July 6, 2026 · 3 min read

Kids are asleep, so here's the webhook setup we've been running on an ecommerce client for two years. It came out of a Black Friday where we shipped some lucky customers two of everything. Not Cashier, plain stripe-php, because the client has a custom checkout.

Why webhooks go wrong

Stripe retries a webhook if you don't answer with a 2xx quickly, and it keeps retrying for up to three days. If your handler does the actual work (emails, stock, calling the warehouse API) inside the request, it gets slow, Stripe times out, retries, and your work runs again. Also Stripe does not promise to deliver events once, or in order. Plan for duplicates and plan for weird order.

1. The route, outside CSRF

// bootstrap/app.php (Laravel 11)
->withMiddleware(function (Middleware $middleware) {
    $middleware->validateCsrfTokens(except: ['stripe/*']);
})

2. Verify, store, dispatch, return

use Stripe\Webhook;
use Stripe\Exception\SignatureVerificationException;

class StripeWebhookController
{
    public function __invoke(Request $request)
    {
        try {
            $event = Webhook::constructEvent(
                $request->getContent(),
                $request->header('Stripe-Signature'),
                config('services.stripe.webhook_secret'),
            );
        } catch (\UnexpectedValueException|SignatureVerificationException $e) {
            return response('invalid', 400);
        }

        $stored = StripeEvent::createOrFirst(
            ['stripe_id' => $event->id],
            ['type' => $event->type, 'payload' => $request->getContent()],
        );

        if ($stored->wasRecentlyCreated) {
            HandleStripeEvent::dispatch($stored->id);
        }

        return response('ok', 200);
    }
}

Two things here. Use the raw body (getContent()) for the signature check, not the parsed JSON, or the signature won't match. And stripe_id has a unique index:

Schema::create('stripe_events', function (Blueprint $table) {
    $table->id();
    $table->string('stripe_id')->unique();
    $table->string('type');
    $table->longText('payload');
    $table->timestamp('processed_at')->nullable();
    $table->timestamps();
});

createOrFirst tries the insert and falls back to a select when the unique index says no, so two copies of the same event arriving at the same moment can't both get dispatched. firstOrCreate does it the other way round and has a small race window. That unique index is doing most of the work in this whole post.

3. The job does the work, idempotently

class HandleStripeEvent implements ShouldQueue
{
    use Queueable;

    public function __construct(public int $eventId) {}

    public function handle(): void
    {
        $event = StripeEvent::findOrFail($this->eventId);
        if ($event->processed_at) {
            return;
        }

        if ($event->type === 'checkout.session.completed') {
            $session = json_decode($event->payload)->data->object;

            DB::transaction(function () use ($session, $event) {
                $order = Order::whereKey($session->metadata->order_id)
                    ->lockForUpdate()
                    ->firstOrFail();

                if ($order->paid_at === null) {
                    $order->update(['paid_at' => now(), 'stripe_session_id' => $session->id]);
                    FulfilOrder::dispatch($order->id)->afterCommit();
                }

                $event->update(['processed_at' => now()]);
            });
        }
    }
}

Even if the same event somehow gets through twice, the order row lock plus the paid_at check means fulfilment is dispatched once. afterCommit() stops the fulfilment job starting before the transaction is actually committed (that one bit us too, the job ran, couldn't see paid_at yet, did nothing).

4. Testing locally

stripe listen --forward-to localhost:8000/stripe/webhook
stripe trigger checkout.session.completed

The CLI prints a webhook secret for local use, put it in .env. Different secret per environment, obviously.

Bits I'd tell past me

  • Keep the raw payload. The day something weird happens you'll want exactly what Stripe sent.
  • Don't trust event order. If you need the current state of a thing, fetch it from the API in the job.
  • If you are on Cashier, it already has a webhook controller, extend that instead of writing this.

Black Friday since: zero double shipments. The warehouse lads were almost disappointed.

Written by

Imran Hussain

Backend dev in Leeds. Laravel and Node, mostly ecommerce. Dad of two, sleep of none. Leeds United, unfortunately.

Get the best of #php_still_pays weekly

One email each Monday with the most useful posts. No spam; unsubscribe in one click.

Comments

7

Sign in to join the conversation.

Sign in to comment
Ayesha Siddiqui@laravel_ayesha#1 in PHP Still Pays
the createOrFirst + unique index combo is exactly right, and almost every tutorial skips it. keeping the raw payload saved us once when a junior "tidied up" the json casting
1 net votes
Imran Hussain@imran_builds#3 in PHP Still Pays
aye, exactly why we keep it. the tidy-up junior is a universal constant
0 net votes
Oluwatobi Adeyemi@tobi_freelance#6 in PHP Still Pays
Saving this. The 'return 200 fast, do the work in a job' part is what most tutorials skip. They do everything in the controller then wonder why Stripe retried 6 times
2 net votes
Emeka Nwosu@emeka_golang3 rep in PHP Still Pays
Same pattern in Go at my place, unique constraint on the provider event id is the real hero. Everything else is decoration. Nice one boss
2 net votes
Fatima Al-Sayed@fatima_appsec2 rep in PHP Still Pays
Good that you verify the signature. Please also make sure the webhook secret is different for staging and production. I see the same secret everywhere in reviews, and rotate it when someone with access leaves.
4 net votes
Aoife Brennan@aoifeb_dev1 rep in PHP Still Pays
not a laravel person but this is the clearest explanation of webhook idempotency i've read, sending it to the data team who keep getting duplicate events in the pipelines (when i had a data team, sure look)
1 net votes
Imran Hussain@imran_builds#3 in PHP Still Pays
you'll have a team again before Leeds get promoted, and that's a promise I can actually keep. sent you two more leads btw
0 net votes