PHP · Laravel · queues

A Laravel UPI integration with transactional payment state

Keep credentials in configuration, create orders from trusted models, verify raw HMAC webhooks, and use database uniqueness plus a durable queue boundary to make duplicate delivery harmless.

Data model first

Enforce invariants in the database

01

payment_orders

Unique client_txn_id and gateway_order_id; amount_paise as an integer; explicit status.

02

payment_events

Unique deterministic event_key, mapped order ID, exact verified payload, received time.

03

outbox_messages

Unique fulfilment key, topic, payload, attempts, available time, processed time.

04

orders

Tenant/customer ownership, trusted catalogue total, and an explicit paid transition.

Step 1 · configuration

Map server secrets through Laravel config

Laravel’s cached configuration should receive secrets from the runtime environment. Use the example values only in local development and clear any real value from shell history and logs.

// config/services.php
return [
    // Other service configuration...
    'vyapargateway' => [
        'base_url' => env(
            'VYAPARGATEWAY_BASE_URL',
            'https://vyapargateway.com'
        ),
        'api_key' => env('VYAPARGATEWAY_API_KEY'),
        'webhook_secret' => env('VYAPARGATEWAY_WEBHOOK_SECRET'),
        'public_store_url' => env('APP_URL'),
    ],
];

// .env — local development values; never commit real credentials
VYAPARGATEWAY_BASE_URL=https://vyapargateway.com
VYAPARGATEWAY_API_KEY=replace-with-local-secret
VYAPARGATEWAY_WEBHOOK_SECRET=replace-with-local-secret

Step 2 · service

Build a narrow, timeout-bounded gateway client

Keep amounts as integer paise and format a two-decimal rupee string at the request boundary. Do not automatically retry a create call with a new client ID; a connection failure can occur after the gateway accepted the order.

<?php

namespace App\Payments;

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use RuntimeException;

final class VyaparGateway
{
    public function createOrder(
        string $clientTxnId,
        int $amountPaise,
        string $description,
        array $customer,
    ): array {
        $response = Http::baseUrl($this->baseUrl())
            ->acceptJson()
            ->asJson()
            ->withHeaders(['X-API-Key' => $this->apiKey()])
            ->connectTimeout(3)
            ->timeout(10)
            ->post('/api/v1/create_order', [
                'client_txn_id' => $clientTxnId,
                'amount' => $this->paiseToRupees($amountPaise),
                'p_info' => $description,
                'customer_name' => Arr::get($customer, 'name'),
                'customer_email' => Arr::get($customer, 'email'),
                'customer_mobile' => Arr::get($customer, 'mobile'),
                'redirect_url' => url(
                    '/orders/' . rawurlencode($clientTxnId)
                ),
                'callback_url' => url(
                    '/api/webhooks/vyapargateway'
                ),
            ]);

        $payload = $response->json();
        $data = is_array($payload) ? ($payload['data'] ?? null) : null;

        if (
            $response->failed()
            || ($payload['status'] ?? false) !== true
            || !is_array($data)
        ) {
            throw new RuntimeException(
                (string) (
                    $payload['detail']
                    ?? $payload['msg']
                    ?? 'Gateway rejected the order'
                )
            );
        }

        if (
            ($data['client_txn_id'] ?? '') !== $clientTxnId
            || ($data['currency'] ?? '') !== 'INR'
        ) {
            throw new RuntimeException(
                'Gateway response does not match the local order'
            );
        }

        return $data;
    }

    public function checkOrderStatus(
        ?string $orderId,
        ?string $clientTxnId,
    ): array {
        if (!$orderId && !$clientTxnId) {
            throw new RuntimeException('An order identifier is required');
        }

        $response = Http::baseUrl($this->baseUrl())
            ->acceptJson()
            ->asJson()
            ->withHeaders(['X-API-Key' => $this->apiKey()])
            ->connectTimeout(3)
            ->timeout(10)
            ->post('/api/v1/check_order_status', [
                'order_id' => $orderId,
                'client_txn_id' => $clientTxnId,
            ]);

        $payload = $response->json();
        if (
            $response->failed()
            || ($payload['status'] ?? false) !== true
            || !is_array($payload['data'] ?? null)
        ) {
            throw new RuntimeException(
                (string) ($payload['msg'] ?? 'Status check failed')
            );
        }

        return $payload['data'];
    }

    private function paiseToRupees(int $paise): string
    {
        if ($paise <= 0) {
            throw new RuntimeException('Amount must be positive');
        }

        return intdiv($paise, 100)
            . '.'
            . str_pad((string) ($paise % 100), 2, '0', STR_PAD_LEFT);
    }

    private function baseUrl(): string
    {
        $value = (string) config('services.vyapargateway.base_url');
        if ($value === '') {
            throw new RuntimeException('Gateway base URL is missing');
        }
        return rtrim($value, '/');
    }

    private function apiKey(): string
    {
        $value = (string) config('services.vyapargateway.api_key');
        if ($value === '') {
            throw new RuntimeException('Gateway API key is missing');
        }
        return $value;
    }
}
Laravel HTTP client reference →

Step 3 · checkout

Load, price, and persist before calling the gateway

The authenticated relationship query enforces ownership. CartPricing is the application boundary that reads trusted catalogue prices; the browser supplies only the cart ID. Database unique indexes make concurrent retries converge.

<?php

namespace App\Http\Controllers;

use App\Models\PaymentOrder;
use App\Payments\VyaparGateway;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Throwable;

final class CheckoutController
{
    public function __invoke(
        Request $request,
        VyaparGateway $gateway,
    ): JsonResponse {
        $input = $request->validate([
            'cart_id' => ['required', 'uuid'],
        ]);
        $customer = $request->user();

        $cart = $customer->carts()
            ->whereKey($input['cart_id'])
            ->firstOrFail();
        $amountPaise = app(CartPricing::class)
            ->totalPaiseFromCatalogue($cart);

        $order = PaymentOrder::query()->firstOrCreate(
            [
                'customer_id' => $customer->getKey(),
                'cart_id' => $cart->getKey(),
            ],
            [
                'client_txn_id' => 'ORD-' . Str::uuid(),
                'amount_paise' => $amountPaise,
                'currency' => 'INR',
                'status' => 'pending',
            ],
        );

        abort_unless(
            $order->amount_paise === $amountPaise
                && $order->status === 'pending',
            409,
            'Cart already has a different payment state',
        );

        try {
            $remote = $gateway->createOrder(
                clientTxnId: $order->client_txn_id,
                amountPaise: $order->amount_paise,
                description: 'Order ' . $order->public_number,
                customer: [
                    'name' => $customer->name,
                    'email' => $customer->email,
                    'mobile' => $customer->mobile,
                ],
            );

            DB::transaction(function () use ($order, $remote): void {
                $locked = PaymentOrder::query()
                    ->lockForUpdate()
                    ->findOrFail($order->getKey());
                $locked->gateway_order_id = $remote['order_id'];
                $locked->payment_url = $remote['payment_url'];
                $locked->expires_at = $remote['expires_at'];
                $locked->save();
            });
        } catch (Throwable $exception) {
            report($exception);
            abort(502, 'Payment checkout is temporarily unavailable');
        }

        return response()->json([
            'order_id' => $order->public_number,
            'payment_url' => $remote['payment_url'],
            'expires_at' => $remote['expires_at'],
        ], 201);
    }
}
Keep the stable local payment row after a timeout. A queue can call checkOrderStatus() using that row’s IDs before the customer is offered a genuine new attempt.

Step 4 · route

Give the webhook one exact public route

Register the callback under API routes. Do not disable CSRF globally, and do not protect the webhook with browser-session authentication: its timestamped signature is its authentication.

// routes/api.php
use App\Http\Controllers\VyaparGatewayWebhookController;
use Illuminate\Support\Facades\Route;

Route::post(
    '/webhooks/vyapargateway',
    VyaparGatewayWebhookController::class,
)->name('webhooks.vyapargateway');

// API routes are outside the web CSRF middleware group by default.
// If your application registers this URI in routes/web.php, add only this
// exact URI to Laravel's documented CSRF exclusion configuration.

Step 5 · webhook transaction

Authenticate bytes, lock the row, enqueue after truth

The expected digest signs timestamp + "." + exact raw JSON body. Verification happens before decoding. The unique event, order transition, and outbox write occur in one transaction so a process crash cannot separate payment truth from future fulfilment.

<?php

namespace App\Http\Controllers;

use App\Models\OutboxMessage;
use App\Models\PaymentEvent;
use App\Models\PaymentOrder;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
use JsonException;

final class VyaparGatewayWebhookController
{
    public function __invoke(Request $request): Response
    {
        $rawBody = $request->getContent();
        $timestamp = (string) $request->header(
            'X-VyaparGateway-Timestamp',
            ''
        );
        $signature = strtolower((string) $request->header(
            'X-VyaparGateway-Signature',
            ''
        ));

        if (
            !preg_match('/^\d{10}$/', $timestamp)
            || abs(time() - (int) $timestamp) > 300
        ) {
            abort(401, 'Invalid webhook timestamp');
        }

        $secret = (string) config(
            'services.vyapargateway.webhook_secret'
        );
        abort_if($secret === '', 500, 'Webhook secret is not configured');

        $expected = hash_hmac(
            'sha256',
            $timestamp . '.' . $rawBody,
            $secret,
        );
        if (
            !preg_match('/^[a-f0-9]{64}$/', $signature)
            || !hash_equals($expected, $signature)
        ) {
            abort(401, 'Invalid webhook signature');
        }

        try {
            $event = json_decode(
                $rawBody,
                true,
                32,
                JSON_THROW_ON_ERROR,
            );
        } catch (JsonException) {
            abort(400, 'Invalid JSON');
        }

        foreach (
            ['event', 'order_id', 'client_txn_id', 'status', 'amount', 'currency']
            as $required
        ) {
            abort_unless(array_key_exists($required, $event), 400);
        }

        DB::transaction(function () use ($event, $rawBody): void {
            $order = PaymentOrder::query()
                ->where('client_txn_id', $event['client_txn_id'])
                ->lockForUpdate()
                ->firstOrFail();

            $eventKey = implode(':', [
                $event['event'],
                $event['order_id'],
                $event['upi_txn_id'] ?? $event['status'],
            ]);
            $paymentEvent = PaymentEvent::query()->firstOrCreate(
                ['event_key' => $eventKey],
                [
                    'payment_order_id' => $order->getKey(),
                    'payload' => $rawBody,
                ],
            );
            if (!$paymentEvent->wasRecentlyCreated) {
                return;
            }

            abort_unless(
                hash_equals(
                    (string) $order->gateway_order_id,
                    (string) $event['order_id'],
                )
                && $event['currency'] === 'INR'
                && $this->rupeesToPaise((string) $event['amount'])
                    === $order->amount_paise,
                409,
                'Payment does not match order',
            );

            if ($event['status'] === 'success') {
                if ($order->status === 'pending') {
                    $order->status = 'paid';
                    $order->upi_txn_id = $event['upi_txn_id'] ?? null;
                    $order->paid_at = now();
                    $order->save();
                }

                OutboxMessage::query()->firstOrCreate(
                    ['key' => 'fulfil-order:' . $order->getKey()],
                    [
                        'topic' => 'order.paid',
                        'payload' => ['order_id' => $order->getKey()],
                    ],
                );
            } elseif (in_array(
                $event['status'],
                ['failed', 'expired'],
                true,
            )) {
                $order->gateway_status = $event['status'];
                $order->save();
            } else {
                abort(400, 'Unsupported payment status');
            }
        });

        return response()->noContent();
    }

    private function rupeesToPaise(string $amount): int
    {
        if (!preg_match('/^\d+(?:\.\d{1,2})?$/', $amount)) {
            abort(400, 'Invalid payment amount');
        }

        [$whole, $fraction] = array_pad(explode('.', $amount, 2), 2, '');
        $fraction = str_pad($fraction, 2, '0');
        return ((int) $whole * 100) + (int) $fraction;
    }
}
PHP hash_equals reference →

Operational controls

Complete the flow around the controller

Unique indexes

Add database uniqueness for client transaction, gateway order, payment event, and outbox keys.

After-commit worker

Claim outbox rows safely, dispatch a unique queued job, and mark processed only after success.

Status command

Schedule bounded reconciliation for ambiguous and overdue pending orders with backoff.

Secret hygiene

Disable request-body logging on sensitive routes and redact API and signature headers.

State tests

Test duplicate, concurrent, delayed, stale, malformed, amount-mismatch, and wrong-order events.

Settlement review

Reconcile successful transactions with provider or bank reports; payment success is not settlement.

Questions

Laravel integration FAQ

Where should the VyaparGateway API key live in Laravel?
Store it in the deployment secret store and expose it through environment-backed config. Read it through config(), never directly in Blade, JavaScript, a public VITE_ variable, logs, or source control.
Should the webhook route use Laravel's parsed request data?
Use $request->getContent() for signature verification because the HMAC covers exact raw bytes. Decode JSON only after the timestamp and signature pass.
Does returning from the browser redirect confirm payment?
No. The return route can display pending status, but only a verified webhook or authenticated server status response should transition the stored order to paid.
How should fulfilment be queued?
Write an outbox record in the same transaction as the paid transition, or dispatch a unique job after the database commit. The job must also be idempotent.
How do I handle duplicate Laravel webhook requests?
Derive a deterministic event key, enforce a unique database index, lock the mapped order, and return a successful response when the event was already durably processed.

Make duplicate delivery uneventful.

Unique keys, row locks, and an outbox keep payment state consistent through retries.

View all integrations