php 107 lines · 3 tabs

Idempotent Stripe Webhook Processing With a Processed-Events Table in Laravel

Shared by codesnips Sep 2026
3 tabs
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('processed_webhook_events', function (Blueprint $table) {
            $table->id();
            $table->string('provider', 40)->default('stripe');
            $table->string('event_id')->unique();
            $table->string('type', 80)->nullable();
            $table->timestamp('processed_at')->useCurrent();
            $table->timestamps();

            $table->index(['provider', 'processed_at']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('processed_webhook_events');
    }
};
3 files · php Explain with highlit

Payment providers like Stripe deliver webhooks with at-least-once semantics: the same event can arrive twice because of network retries, timeouts, or the provider re-sending after a slow response. Without protection, a duplicate charge.succeeded could credit an account twice or send two receipts. This snippet implements the standard defense — an idempotency ledger keyed by the provider's own event id — so each event is applied exactly once even under concurrent redelivery.

The create_processed_webhook_events_table migration defines the ledger. Its most important line is $table->string('event_id')->unique(): the unique index is what actually enforces deduplication at the database level, turning a race into a caught constraint violation rather than a double-apply. Columns like provider, type, and processed_at exist for observability and to make the table safe to prune later.

ProcessedWebhookEvent is a thin Eloquent model over that table. The markProcessed helper wraps firstOrCreate on event_id, and its return value (wasRecentlyCreated) tells the caller whether this process actually claimed the event or merely lost the race to another worker. This is the crux of the pattern: the claim and the check are a single atomic operation, not a read-then-write that two requests could both pass.

StripeWebhookController ties it together. It first verifies the signature with Stripe's Webhook::constructEvent, because an idempotency table is meaningless if attackers can forge event ids. It then runs the claim and business logic inside a DB::transaction, so if the handler throws, the ledger row rolls back and the event can be safely retried. When markProcessed reports the event was already recorded, the controller short-circuits and returns 200, signalling Stripe to stop retrying.

A subtle trade-off worth noting: the handler runs synchronously here for clarity, but if handle is slow it should enqueue a job after the ledger claim commits. Returning non-2xx on genuine failures is intentional — it lets Stripe's retry schedule act as a durable backstop, while the unique index guarantees those retries never double-process. This combination of signature verification, a unique event id, and a transactional claim is the reachable-for pattern whenever any at-least-once source drives money-moving side effects.


Related snips

Share this code

Here's the card — post it anywhere.

Idempotent Stripe Webhook Processing With a Processed-Events Table in Laravel — share card
Link copied