Let's Connect

Lines of code on a dark editor screen, representing a single Laravel controller routing payment webhooks from multiple providers

If your Laravel payment webhooks live in a separate controller per provider, you have copy-pasted the same three things into each one: signature verification, raw-payload logging, and duplicate detection. I had a StripeWebhookController, a PayPalWebhookController, and a GoCardlessWebhookController, each re-implementing the same plumbing slightly differently, which meant three places to get idempotency wrong. The fix was to collapse them into a single WebhookController behind one route, POST /webhooks/{provider}, that resolves a provider-specific handler from a map. Adding a new provider became one class, not a new endpoint, and the cross-cutting concerns now live in exactly one place.

Why a controller per provider is a trap

Every payment provider POSTs webhooks, and on the surface each looks different: Stripe signs with a Stripe-Signature header and an HMAC scheme, PayPal sends a transmission id you verify against their API, a direct-debit gateway like GoCardless signs the raw body with a webhook secret. So the instinct is one controller each. But strip away the signature step and the shape is identical for all of them: verify the request is genuine, store the raw event so you can replay it, drop duplicates because providers retry aggressively, acknowledge with a 200 fast, then do the actual work out of band. Duplicate that across three controllers and the day you add a fourth provider you re-derive the idempotency logic again, and that is exactly the logic you cannot afford to get wrong, because a double-fired webhook means you credit a wallet or fulfil an order twice.

The other failure mode is doing real work inside the request. If you reconcile an invoice, send a receipt email, and update a ledger synchronously, the provider's HTTP client times out, marks the delivery failed, and retries, and now you are processing the same event again while the first one is still running. The controller's only job is to answer fast. Everything past the signature check belongs on a queue.

The single route and thin controller

One route handles every provider. The {provider} segment is a string the controller uses to resolve a handler. Because webhooks are server-to-server, they carry no CSRF token, so the route must be exempt from CSRF verification. In Laravel 11 and 12 you do that in bootstrap/app.php, not in a Kernel, by listing the path in validateCsrfTokens.

routes/web.php
use App\Http\Controllers\WebhookController;
use Illuminate\Support\Facades\Route;

Route::post('/webhooks/{provider}', WebhookController::class)
    ->whereIn('provider', ['stripe', 'paypal', 'gocardless'])
    ->name('webhooks.handle');
bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    // Webhooks are server-to-server and carry no CSRF token.
    $middleware->validateCsrfTokens(except: [
        'webhooks/*',
    ]);
})

The controller itself is a single __invoke method. It resolves the handler for the {provider} segment, asks it to verify the signature against the raw request, and on success persists the raw event and dispatches a job. It reads the raw body with $request->getContent(), never the parsed array, because every HMAC scheme signs the exact bytes, and re-encoding the JSON will break the comparison.

app/Http/Controllers/WebhookController.php
namespace App\Http\Controllers;

use App\Jobs\ProcessWebhookEvent;
use App\Models\WebhookEvent;
use App\Webhooks\WebhookHandlerResolver;
use App\Webhooks\Exceptions\InvalidSignatureException;
use Illuminate\Http\Request;
use Illuminate\Http\Response;

class WebhookController extends Controller
{
    public function __construct(
        private readonly WebhookHandlerResolver $resolver,
    ) {}

    public function __invoke(Request $request, string $provider): Response
    {
        $handler = $this->resolver->for($provider);

        try {
            $handler->verify($request);
        } catch (InvalidSignatureException $e) {
            // Bad signature: refuse it. Note this does NOT stop a real
            // provider retrying (they retry on any non-2xx) - but a forged
            // request will fail verification again, cheaply, every time.
            return response('Invalid signature', Response::HTTP_BAD_REQUEST);
        }

        $eventId = $handler->eventId($request);

        // Idempotency: one row per (provider, event_id). Concurrent
        // retries collide on the unique index instead of double-processing.
        $event = WebhookEvent::firstOrCreate(
            ['provider' => $provider, 'event_id' => $eventId],
            ['payload' => $request->getContent(), 'status' => 'received'],
        );

        if (! $event->wasRecentlyCreated) {
            // Duplicate delivery. Ack it and move on.
            return response('Already handled', Response::HTTP_OK);
        }

        ProcessWebhookEvent::dispatch($event->id);

        // Acknowledge fast. Real work happens on the queue.
        return response('OK', Response::HTTP_OK);
    }
}

That is the whole controller. There is no Stripe code, no PayPal code, no SQL beyond the idempotency insert. Adding GoCardless touched zero lines here.

How the resolver maps a provider string to a handler

The {provider} segment is untrusted input, so I never new up a class name built from it. Instead I keep an explicit map. The resolver throws on anything not in the map, which means an unknown provider is a clean 404 rather than a class-not-found crash.

app/Webhooks/WebhookHandlerResolver.php
namespace App\Webhooks;

use App\Webhooks\Contracts\WebhookHandler;
use Illuminate\Contracts\Container\Container;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

class WebhookHandlerResolver
{
    /** @var array<string, class-string<WebhookHandler>> */
    private array $map = [
        'stripe'     => StripeHandler::class,
        'paypal'     => PayPalHandler::class,
        'gocardless' => GoCardlessHandler::class,
    ];

    public function __construct(private readonly Container $container) {}

    public function for(string $provider): WebhookHandler
    {
        $class = $this->map[$provider]
            ?? throw new NotFoundHttpException("No webhook handler for [{$provider}].");

        return $this->container->make($class);
    }
}

Each handler implements one small contract. It knows three things and nothing else: how to verify this provider's signature, how to pull this provider's unique event id, and how to normalise this provider's payload into my internal event shape. Keeping the contract tiny is what makes a new provider a single-file change, and it makes each handler trivial to unit test in isolation. If you write service classes the same way, my notes on testable service classes in Laravel cover the dependency-injection patterns I lean on here.

app/Webhooks/Contracts/WebhookHandler.php
namespace App\Webhooks\Contracts;

use Illuminate\Http\Request;

interface WebhookHandler
{
    /** Throw InvalidSignatureException if the request is not genuine. */
    public function verify(Request $request): void;

    /** The provider's unique id for this event (used for idempotency). */
    public function eventId(Request $request): string;

    /** Map the provider payload to the internal event shape. */
    public function normalize(array $payload): array;
}

The Stripe handler is a good example because its verification is non-trivial but self-contained. It uses the official Stripe PHP SDK's Webhook::constructEvent, which checks the HMAC and rejects events whose timestamp falls outside the default tolerance. Critically, it is handed the raw body, not the decoded array.

app/Webhooks/StripeHandler.php
namespace App\Webhooks;

use App\Webhooks\Contracts\WebhookHandler;
use App\Webhooks\Exceptions\InvalidSignatureException;
use Illuminate\Http\Request;
use Stripe\Exception\SignatureVerificationException;
use Stripe\Webhook;
use UnexpectedValueException;

class StripeHandler implements WebhookHandler
{
    public function verify(Request $request): void
    {
        try {
            Webhook::constructEvent(
                $request->getContent(),
                $request->header('Stripe-Signature', ''),
                config('services.stripe.webhook_secret'),
            );
        } catch (SignatureVerificationException|UnexpectedValueException $e) {
            // SignatureVerificationException: bad/missing signature.
            // UnexpectedValueException: malformed JSON payload.
            throw new InvalidSignatureException(previous: $e);
        }
    }

    public function eventId(Request $request): string
    {
        return $request->json('id'); // e.g. "evt_1P..."
    }

    public function normalize(array $payload): array
    {
        return [
            'type'      => $payload['type'],           // "payment_intent.succeeded"
            'amount'    => $payload['data']['object']['amount'] ?? null,
            'currency'  => $payload['data']['object']['currency'] ?? null,
            'reference' => $payload['data']['object']['id'] ?? null,
        ];
    }
}
Source code on a screen showing handler classes, illustrating one small class per payment provider behind a single webhook controller
One handler per provider, one shared controller. Adding a gateway is a single new class on the resolver map.

Making duplicate deliveries safe

Providers retry. Stripe retries for up to three days, PayPal and direct-debit gateways have their own schedules, and a slow response or a 500 on your side guarantees you will see the same event id more than once. The whole defence is a unique index on (provider, event_id) plus firstOrCreate. Two retries arriving at the same instant both attempt the insert; the database lets one win and rejects the other on the constraint, so at most one job is ever dispatched per event.

database/migrations/2026_06_13_000000_create_webhook_events_table.php
Schema::create('webhook_events', function (Blueprint $table) {
    $table->id();
    $table->string('provider');
    $table->string('event_id');
    $table->longText('payload');       // raw body, kept for replay/debug
    $table->string('status')->default('received');
    $table->timestamp('processed_at')->nullable();
    $table->timestamps();

    // The line that makes idempotency real.
    $table->unique(['provider', 'event_id']);
});

Storing the raw payload is not optional housekeeping. It is your replay and audit trail:

  • Replay: when a downstream bug mangles an event, you re-dispatch ProcessWebhookEvent for the stored row instead of asking the provider to resend.
  • Debugging: when finance asks why a payment shows twice, the raw rows are the source of truth, timestamped, in order.
  • Audit: a webhook that moves money is an event worth keeping, raw and immutable, alongside the rest of your financial trail.
  • Idempotency reach: because the unique index is enforced by the database, it holds even under concurrent retries; an application-level if check does not.
A webhook endpoint that does real work before returning 200 is not an endpoint. It is a retry storm waiting for a slow query.Md Raihan Hasan

Where the real processing happens

The dispatched ProcessWebhookEvent job is where the actual logic lives, off the request path. It loads the stored event, normalises the payload through the same handler, applies your domain logic, and marks the row processed. Because the controller already returned 200, the provider is satisfied and the job can take as long as it needs, retry on transient failure, and land in the failed_jobs table if it keeps blowing up, without ever affecting webhook delivery.

app/Jobs/ProcessWebhookEvent.php
namespace App\Jobs;

use App\Models\WebhookEvent;
use App\Webhooks\WebhookHandlerResolver;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

class ProcessWebhookEvent implements ShouldQueue
{
    use Queueable;

    public int $tries = 5;

    /** @var array<int, int> */
    public array $backoff = [10, 30, 60, 300, 900];

    public function __construct(public int $eventId) {}

    public function handle(WebhookHandlerResolver $resolver): void
    {
        $event = WebhookEvent::findOrFail($this->eventId);

        if ($event->status === 'processed') {
            return; // belt-and-braces against a re-dispatched job
        }

        $handler = $resolver->for($event->provider);
        $normalized = $handler->normalize(json_decode($event->payload, true));

        // ... apply domain logic against $normalized ...

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

This design assumes you actually have queue workers running and supervised in production. If no worker is consuming the queue, your webhooks get persisted and acknowledged but never processed, which is its own silent failure. I cover keeping those workers alive across deploys and reboots in my write-up on Laravel queue workers and Supervisor. And because /webhooks/{provider} is a public, unauthenticated endpoint, it is worth a throttle so a flood of forged requests cannot exhaust your workers, which I walk through in my post on Laravel rate limiting and brute-force protection.

Centralising the webhook flow turned a recurring source of double-charges and copy-paste drift into one auditable path: one route, one thin controller, one resolver, one small handler per provider, one idempotency index, one queued job. The payoff shows up the next time a provider is added, when you write a single class and register it on the map, and the day a payment looks wrong, because every event is sitting in webhook_events, raw and timestamped, ready to replay. Verify, persist, return 200, process on the queue. Get that order right and your payment integrations stop being the part of the system you are afraid to touch.