DEV Community

Ruhani Soft
Ruhani Soft

Posted on

Laravel Payment Gateway Integration in 2026: A Production Guide to Webhooks, Idempotency, and Local Gateways

Integrating a payment gateway in Laravel looks easy in the tutorial: redirect the user, get a callback, mark the order as paid. Then production happens. Customers close the tab before the redirect. Webhooks arrive twice, or out of order, or never. A refund comes in while the order is still "pending". Your finance team asks why the ledger and the gateway dashboard disagree.

This guide covers how to build payment integrations that survive all of that. It is based on patterns that hold up across very different gateways, from Stripe and PayPal to mobile financial services like bKash and Nagad.

What you will learn

  • A gateway-agnostic architecture for Laravel
  • A payment state machine that prevents illegal transitions
  • Secure webhook handling with signature verification
  • Idempotency, so double-delivery never double-charges or double-fulfills
  • Reconciliation jobs for the webhooks you miss
  • Testing payment flows without hitting real sandboxes
  • Notes on local gateways such as bKash, Nagad, and SSLCommerz

1. The mistakes that cost real money

Before the code, here are the failure modes behind most payment incidents:

  1. Trusting the browser redirect. The success URL is not proof of payment. Anyone can visit it.
  2. Fulfilling an order inside the callback request. If it times out halfway, you get a paid order that was never fulfilled.
  3. Not handling duplicate webhooks. Gateways use at-least-once delivery. Duplicates are normal.
  4. Storing money as floats. Use integers in minor units (cents, poisha).
  5. No reconciliation. Webhooks fail. Without a safety net, you will have silent mismatches.
  6. Gateway logic scattered across controllers. Adding the second gateway becomes a rewrite.

Everything below is designed to remove these.

2. Architecture: one interface, many gateways

If you ever expect to support more than one gateway, define a contract first.

<?php

namespace App\Payments\Contracts;

use App\Models\Payment;
use Illuminate\Http\Request;

interface PaymentGateway
{
    public function name(): string;

    /** Create a session/payment on the gateway and return where to send the customer. */
    public function initiate(Payment $payment): InitiatedPayment;

    /** Verify the authenticity of an incoming webhook/IPN. */
    public function verifyWebhook(Request $request): bool;

    /** Extract a unique, stable event id from the webhook (used for idempotency). */
    public function eventId(Request $request): string;

    /** Translate the gateway payload into our internal result. */
    public function parseWebhook(Request $request): WebhookResult;

    /** Ask the gateway for the truth. Used by reconciliation and after redirects. */
    public function fetchStatus(Payment $payment): GatewayStatus;

    public function refund(Payment $payment, int $amountMinor, ?string $reason = null): RefundResult;
}
Enter fullscreen mode Exit fullscreen mode

Keep DTOs small and immutable:

<?php

namespace App\Payments\Contracts;

final class InitiatedPayment
{
    public function __construct(
        public readonly string $gatewayReference,
        public readonly ?string $redirectUrl = null,
        public readonly array $clientPayload = [],
    ) {}
}

final class WebhookResult
{
    public function __construct(
        public readonly string $gatewayReference,
        public readonly \App\Enums\PaymentStatus $status,
        public readonly ?int $amountMinor = null,
        public readonly ?string $currency = null,
        public readonly array $raw = [],
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

Then a manager that resolves drivers, using Laravel's Manager class or a simple map:

<?php

namespace App\Payments;

use App\Payments\Contracts\PaymentGateway;
use InvalidArgumentException;

class GatewayManager
{
    public function __construct(private array $drivers) {}

    public function driver(string $name): PaymentGateway
    {
        $class = $this->drivers[$name]
            ?? throw new InvalidArgumentException("Unknown gateway [$name]");

        return app($class);
    }
}
Enter fullscreen mode Exit fullscreen mode

Register it in a service provider, driven by config:

// config/payments.php
return [
    'drivers' => [
        'stripe'     => \App\Payments\Gateways\StripeGateway::class,
        'sslcommerz' => \App\Payments\Gateways\SslCommerzGateway::class,
        'bkash'      => \App\Payments\Gateways\BkashGateway::class,
    ],
];

// AppServiceProvider::register()
$this->app->singleton(GatewayManager::class, fn () => new GatewayManager(config('payments.drivers')));
Enter fullscreen mode Exit fullscreen mode

Your controllers now only know about GatewayManager and PaymentGateway. Adding the next gateway means writing one class and one config line.

3. Data model

Two tables matter most: payments and webhook_events.

Schema::create('payments', function (Blueprint $table) {
    $table->id();
    $table->foreignId('order_id')->constrained()->cascadeOnDelete();
    $table->string('gateway');
    $table->string('gateway_reference')->nullable()->index();
    $table->unsignedBigInteger('amount_minor');   // 1050 = 10.50
    $table->unsignedBigInteger('refunded_minor')->default(0);
    $table->char('currency', 3);
    $table->string('status')->default('pending')->index();
    $table->json('meta')->nullable();
    $table->timestamp('paid_at')->nullable();
    $table->timestamps();

    $table->unique(['gateway', 'gateway_reference']);
});

Schema::create('webhook_events', function (Blueprint $table) {
    $table->id();
    $table->string('gateway');
    $table->string('event_id');
    $table->json('payload');
    $table->timestamp('processed_at')->nullable();
    $table->text('error')->nullable();
    $table->timestamps();

    $table->unique(['gateway', 'event_id']);   // the idempotency guard
});
Enter fullscreen mode Exit fullscreen mode

The unique index on webhook_events is what makes duplicate delivery harmless. Do not rely on an if (exists) check alone, because two requests can race past it.

4. A payment state machine

Use a backed enum and explicitly define which transitions are legal.

<?php

namespace App\Enums;

enum PaymentStatus: string
{
    case Pending = 'pending';
    case Processing = 'processing';
    case Paid = 'paid';
    case Failed = 'failed';
    case Refunded = 'refunded';
    case PartiallyRefunded = 'partially_refunded';

    /** @return array<self> */
    public function allowedNext(): array
    {
        return match ($this) {
            self::Pending => [self::Processing, self::Paid, self::Failed],
            self::Processing => [self::Paid, self::Failed],
            self::Paid => [self::PartiallyRefunded, self::Refunded],
            self::PartiallyRefunded => [self::PartiallyRefunded, self::Refunded],
            self::Failed, self::Refunded => [],
        };
    }

    public function canTransitionTo(self $next): bool
    {
        return in_array($next, $this->allowedNext(), true);
    }
}
Enter fullscreen mode Exit fullscreen mode

On the model, centralize transitions so nothing mutates status directly:

public function transitionTo(PaymentStatus $next): bool
{
    if ($this->status === $next) {
        return false; // already there, treat as a no-op
    }

    if (! $this->status->canTransitionTo($next)) {
        Log::warning('Illegal payment transition', [
            'payment' => $this->id, 'from' => $this->status, 'to' => $next,
        ]);
        return false;
    }

    $this->status = $next;
    if ($next === PaymentStatus::Paid) {
        $this->paid_at = now();
    }
    $this->save();

    return true;
}
Enter fullscreen mode Exit fullscreen mode

This matters because webhooks arrive out of order. A late pending event must never overwrite paid.

5. Starting a payment

Create the local record first, then talk to the gateway. If the gateway call fails, you still have a record to inspect.

<?php

namespace App\Actions;

use App\Models\Order;
use App\Models\Payment;
use App\Payments\GatewayManager;

class StartPayment
{
    public function __construct(private GatewayManager $gateways) {}

    public function __invoke(Order $order, string $gateway): array
    {
        $payment = Payment::create([
            'order_id'     => $order->id,
            'gateway'      => $gateway,
            'amount_minor' => $order->total_minor,
            'currency'     => $order->currency,
        ]);

        $initiated = $this->gateways->driver($gateway)->initiate($payment);

        $payment->update(['gateway_reference' => $initiated->gatewayReference]);

        return [
            'payment_id' => $payment->id,
            'redirect'   => $initiated->redirectUrl,
            'payload'    => $initiated->clientPayload,
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

Always pass your own payment->id (or a UUID) to the gateway as a merchant reference where supported. It makes debugging and reconciliation far easier.

6. Webhooks done right

The webhook endpoint should do four things and nothing else: verify, deduplicate, persist, dispatch. Heavy work goes to a queue.

Route

Route::post('/webhooks/payments/{gateway}', PaymentWebhookController::class)
    ->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class])
    ->name('webhooks.payments');
Enter fullscreen mode Exit fullscreen mode

(In Laravel 11 and newer, exclude the path through validateCsrfTokens(except: [...]) in bootstrap/app.php.)

Controller

<?php

namespace App\Http\Controllers;

use App\Jobs\ProcessPaymentWebhook;
use App\Payments\GatewayManager;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

class PaymentWebhookController
{
    public function __construct(private GatewayManager $gateways) {}

    public function __invoke(Request $request, string $gateway)
    {
        $driver = $this->gateways->driver($gateway);

        if (! $driver->verifyWebhook($request)) {
            return response('Invalid signature', 400);
        }

        $inserted = DB::table('webhook_events')->insertOrIgnore([
            'gateway'    => $gateway,
            'event_id'   => $driver->eventId($request),
            'payload'    => $request->getContent(),
            'created_at' => now(),
            'updated_at' => now(),
        ]);

        // Duplicate delivery: acknowledge and stop.
        if ($inserted === 0) {
            return response()->noContent();
        }

        $eventDbId = DB::table('webhook_events')
            ->where('gateway', $gateway)
            ->where('event_id', $driver->eventId($request))
            ->value('id');

        ProcessPaymentWebhook::dispatch($eventDbId);

        return response()->noContent();
    }
}
Enter fullscreen mode Exit fullscreen mode

Return a 2xx quickly. Most gateways retry on anything else, which creates the duplicates you just protected against.

Signature verification

Every serious gateway signs its webhooks, usually with HMAC. The pattern, using a Stripe-style scheme as an example:

public function verifyWebhook(Request $request): bool
{
    $header = $request->header('Stripe-Signature', '');
    parse_str(str_replace(',', '&', $header), $parts);

    $timestamp = $parts['t'] ?? null;
    $signature = $parts['v1'] ?? null;

    if (! $timestamp || ! $signature) {
        return false;
    }

    // Reject old requests to limit replay attacks.
    if (abs(time() - (int) $timestamp) > 300) {
        return false;
    }

    $expected = hash_hmac(
        'sha256',
        $timestamp.'.'.$request->getContent(), // RAW body, not re-encoded JSON
        config('services.stripe.webhook_secret')
    );

    return hash_equals($expected, $signature);
}
Enter fullscreen mode Exit fullscreen mode

Three details people get wrong:

  • Use the raw body ($request->getContent()), never json_encode($request->all()).
  • Compare with hash_equals() to avoid timing attacks.
  • Check a timestamp tolerance where the scheme supports it.

For gateways that do not sign webhooks at all (some regional ones only send an IPN with a transaction ID), treat the webhook as a hint, then call the gateway's verification API server-to-server before changing any state. More on that in section 9.

The processing job

<?php

namespace App\Jobs;

use App\Enums\PaymentStatus;
use App\Models\Payment;
use App\Payments\GatewayManager;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\DB;

class ProcessPaymentWebhook implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable;

    public int $tries = 5;
    public array $backoff = [10, 60, 300, 900];

    public function __construct(public int $eventId) {}

    public function handle(GatewayManager $gateways): void
    {
        $event = DB::table('webhook_events')->find($this->eventId);

        if (! $event || $event->processed_at) {
            return;
        }

        $request = \Illuminate\Http\Request::create('/', 'POST', [], [], [], [], $event->payload);
        $result = $gateways->driver($event->gateway)->parseWebhook($request);

        DB::transaction(function () use ($event, $result) {
            $payment = Payment::where('gateway', $event->gateway)
                ->where('gateway_reference', $result->gatewayReference)
                ->lockForUpdate()
                ->first();

            if (! $payment) {
                // Webhook beat our own DB write. Let the queue retry later.
                $this->release(30);
                return;
            }

            if ($result->amountMinor !== null && $result->amountMinor !== $payment->amount_minor) {
                report(new \RuntimeException("Amount mismatch on payment {$payment->id}"));
                return;
            }

            if ($payment->transitionTo($result->status) && $result->status === PaymentStatus::Paid) {
                \App\Events\PaymentSucceeded::dispatch($payment);
            }

            DB::table('webhook_events')
                ->where('id', $event->id)
                ->update(['processed_at' => now()]);
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Key points:

  • lockForUpdate() inside a transaction stops two workers from processing the same payment at once.
  • The amount check defends against tampering and mismatched currency handling.
  • Fulfillment hangs off a PaymentSucceeded event, so the webhook job stays focused on payment state.

7. Idempotency beyond webhooks

Webhooks are one place duplicates appear. Others:

  • User double-clicks "Pay". Disable the button client-side, and server-side reuse an existing pending payment for the same order instead of creating a new one.
  • API retries to the gateway. Stripe and several others support an Idempotency-Key header. Use your payment ID as the key.
  • Fulfillment. Make PaymentSucceeded listeners idempotent. For example, issue a license key only if the order does not already have one.
Http::withHeaders(['Idempotency-Key' => "payment-{$payment->id}"])
    ->post($endpoint, $body);
Enter fullscreen mode Exit fullscreen mode

A good rule: any operation that has a side effect in the outside world (email, license, shipment) should be safe to run twice.

8. Reconciliation: your safety net

Even with perfect code, webhooks get lost: your server was down, a firewall blocked the gateway, the queue backed up. Run a scheduled job that asks the gateway for the truth about stale payments.

// routes/console.php (Laravel 11+) or Console\Kernel::schedule()
Schedule::command('payments:reconcile')->everyFiveMinutes();
Enter fullscreen mode Exit fullscreen mode
<?php

namespace App\Console\Commands;

use App\Models\Payment;
use App\Payments\GatewayManager;
use Illuminate\Console\Command;

class ReconcilePayments extends Command
{
    protected $signature = 'payments:reconcile';
    protected $description = 'Sync stale pending payments with their gateway';

    public function handle(GatewayManager $gateways): int
    {
        Payment::query()
            ->whereIn('status', ['pending', 'processing'])
            ->where('created_at', '<', now()->subMinutes(10))
            ->where('created_at', '>', now()->subDays(2))
            ->whereNotNull('gateway_reference')
            ->chunkById(100, function ($payments) use ($gateways) {
                foreach ($payments as $payment) {
                    try {
                        $status = $gateways->driver($payment->gateway)->fetchStatus($payment);
                        $payment->transitionTo($status->status);
                    } catch (\Throwable $e) {
                        report($e);
                    }
                }
            });

        return self::SUCCESS;
    }
}
Enter fullscreen mode Exit fullscreen mode

Also build an admin view that lists payments stuck in pending for more than an hour. Support teams will thank you.

9. Working with local and regional gateways

Stripe-quality documentation is the exception, not the rule. Many regional gateways have inconsistent sandboxes, unusual auth flows, or webhooks that are only partially signed. The architecture above still works, but expect these differences.

Token-based, multi-step flows. Mobile wallets like bKash typically follow a sequence: obtain an auth token, create a payment, redirect the customer to approve it, then execute the payment after the callback. The execute step is what actually captures the money, so your callback handler must call it and then verify the final status. Tokens expire, so cache them with a TTL slightly below their lifetime and refresh on failure.

Encrypted payload signing. Some gateways, Nagad being a well-known example, require you to encrypt and sign request payloads with RSA keys. Keep keys in secure storage, never in the repo, and write the crypto in one small, well-tested class so the rest of the driver stays readable.

Redirect plus server validation. Gateways like SSLCommerz redirect the customer back with a transaction identifier, and also send an IPN. The safe approach is to ignore what the redirect claims and call the gateway's validation endpoint with the transaction ID, then compare the amount and currency in the validation response against your own record.

public function fetchStatus(Payment $payment): GatewayStatus
{
    $response = Http::retry(3, 200)
        ->timeout(15)
        ->get(config('services.sslcommerz.validation_url'), [
            'val_id'     => $payment->meta['val_id'] ?? null,
            'store_id'   => config('services.sslcommerz.store_id'),
            'store_passwd' => config('services.sslcommerz.store_password'),
            'format'     => 'json',
        ])->throw()->json();

    $valid = in_array($response['status'] ?? '', ['VALID', 'VALIDATED'], true);
    $amountOk = (int) round(((float) ($response['amount'] ?? 0)) * 100) === $payment->amount_minor;

    return new GatewayStatus(
        $valid && $amountOk ? PaymentStatus::Paid : PaymentStatus::Failed
    );
}
Enter fullscreen mode Exit fullscreen mode

General rules for any gateway with weak webhooks:

  • Treat inbound notifications as triggers, not as truth.
  • Always verify server-to-server before marking a payment paid.
  • Validate amount, currency, and your own merchant reference.
  • Log the full request and response for every gateway call (with secrets masked). You will need this during disputes.
  • Put timeouts and retries on every outbound HTTP call: Http::timeout(15)->retry(3, 200).

If you are building for emerging markets and do not want to write each integration from scratch, it can be worth reusing battle-tested code. Studios such as Ruhanisoft, which maintain a library covering dozens of regional and international gateways, exist precisely because the per-gateway quirks are where most of the project time goes.

10. Testing without hitting real sandboxes

Sandboxes are slow, flaky, and sometimes down. Fake the HTTP layer and test your own logic.

use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;

it('marks a payment as paid once, even if the webhook is delivered twice', function () {
    Queue::fake();

    $payment = Payment::factory()->create([
        'gateway' => 'stripe',
        'gateway_reference' => 'pi_123',
        'amount_minor' => 5000,
        'status' => 'pending',
    ]);

    $payload = json_encode(['id' => 'evt_1', 'reference' => 'pi_123', 'status' => 'succeeded']);
    $headers = signedStripeHeaders($payload); // helper that builds a valid Stripe-Signature

    $this->call('POST', '/webhooks/payments/stripe', [], [], [], $headers, $payload)->assertNoContent();
    $this->call('POST', '/webhooks/payments/stripe', [], [], [], $headers, $payload)->assertNoContent();

    Queue::assertPushed(ProcessPaymentWebhook::class, 1);
});

it('rejects a webhook with a bad signature', function () {
    $this->postJson('/webhooks/payments/stripe', ['id' => 'evt_2'], ['Stripe-Signature' => 't=1,v1=bad'])
        ->assertStatus(400);
});

it('never moves a paid payment back to pending', function () {
    $payment = Payment::factory()->create(['status' => 'paid']);

    expect($payment->transitionTo(PaymentStatus::Pending))->toBeFalse()
        ->and($payment->fresh()->status)->toBe(PaymentStatus::Paid);
});
Enter fullscreen mode Exit fullscreen mode

Also fake outbound gateway calls:

Http::fake([
    'api.gateway.test/*' => Http::response(['status' => 'VALID', 'amount' => '50.00'], 200),
]);
Enter fullscreen mode Exit fullscreen mode

The tests worth writing first:

  1. Duplicate webhook delivery
  2. Invalid signature
  3. Out-of-order events
  4. Amount mismatch
  5. Gateway timeout during initiate
  6. Reconciliation picks up a missed webhook

11. Security checklist

  • Webhook secrets and API keys live in .env or a secrets manager, never in Git
  • Webhook route excluded from CSRF, but protected by signature verification
  • Raw-body HMAC with hash_equals
  • Amounts stored as integers, verified against your own record
  • Card data never touches your server if you can use hosted fields or redirects (stay out of PCI scope)
  • Webhook endpoint rate limited and allowlisted by IP where the gateway publishes ranges
  • Sensitive fields masked in logs
  • Refund and admin actions behind policies and audit logging

12. Going live checklist

  • Sandbox and live credentials separated per environment
  • Webhook URL registered in the live dashboard, not just sandbox
  • Queue workers supervised (Horizon or Supervisor) and restarting on deploy
  • Reconciliation scheduled and the scheduler actually running
  • Alerts on: webhook failures, payments stuck in pending, amount mismatches
  • A runbook for "customer paid but order not fulfilled"
  • One real low-value transaction tested end to end, including a refund

Wrapping up

Reliable payments in Laravel come down to a few habits: never trust the redirect, verify every webhook, make every step idempotent, model payment status as a state machine, and reconcile against the gateway on a schedule. Get those right once behind a clean gateway interface and each new provider becomes a small, predictable piece of work instead of a risky rewrite.

If you would rather hand off the integration work, agencies and founders often bring in a specialist Laravel payment gateway integration team for the tricky regional gateways, then keep the rest of the product in-house. Whichever route you pick, use the checklists above to hold the result to a high standard.

And if you are starting a new Laravel product from scratch, a solid base saves weeks. Browse some ready-made Laravel scripts and starter kits to see what a production-oriented foundation looks like.

What is the strangest gateway behavior you have run into? Share it in the comments.

Top comments (0)