S
ShadhinPay Docs
Walkthrough

Laravel Integration

Complete guide to accepting payments in Laravel using ShadhinPay REST API

Accept Payments in Laravel

This comprehensive guide walks you through integrating ShadhinPay payments into your Laravel application using the REST API. By the end, you'll have a fully functional payment system with checkout, webhooks, and status polling.

Overview

We'll build a complete e-commerce checkout flow that:

  • Initiates payments via ShadhinPay API
  • Handles webhook callbacks for real-time updates
  • Polls payment status as a fallback mechanism
  • Displays payment status to customers

Prerequisites

  • Laravel 10+ application
  • PHP 8.1+
  • Composer
  • ShadhinPay merchant account with API credentials

Step 1: Install Dependencies

No additional packages are required - we'll use Laravel's built-in HTTP client.

Step 2: Configure Environment

Add your ShadhinPay credentials to your .env file:

.env
# ShadhinPay API Configuration
SHADHINPAY_BASE_URL=https://api.shadhinpay.com
SHADHINPAY_CLIENT_ID=your_client_id
SHADHINPAY_BUSINESS_ID=your_business_id
SHADHINPAY_SECRET_KEY=your_secret_key

# Payment Mode: webhook, polling, both
SHADHINPAY_PAYMENT_MODE=both

# Sandbox Mode (use MOCK vendor for testing)
SHADHINPAY_SANDBOX=true

# Polling Configuration
SHADHINPAY_POLL_INTERVAL=10
SHADHINPAY_POLL_MAX_ATTEMPTS=30

Never commit your Secret Key to version control. Always use environment variables.

Step 3: Create Configuration File

Create a configuration file for ShadhinPay settings:

config/shadhinpay.php
<?php

return [
    // API Base URL
    'base_url' => env('SHADHINPAY_BASE_URL', 'https://api.shadhinpay.com'),

    // Three-tier Authentication Credentials
    'client_id' => env('SHADHINPAY_CLIENT_ID'),
    'business_id' => env('SHADHINPAY_BUSINESS_ID'),
    'secret_key' => env('SHADHINPAY_SECRET_KEY'),

    // Payment Mode: 'webhook', 'polling', 'both'
    'payment_mode' => env('SHADHINPAY_PAYMENT_MODE', 'both'),

    // Polling Configuration
    'polling' => [
        'enabled' => env('SHADHINPAY_POLLING_ENABLED', true),
        'interval_seconds' => env('SHADHINPAY_POLL_INTERVAL', 10),
        'max_attempts' => env('SHADHINPAY_POLL_MAX_ATTEMPTS', 30),
    ],

    // Sandbox Configuration
    'sandbox' => [
        'enabled' => env('SHADHINPAY_SANDBOX', true),
        'preferred_vendor' => 'MOCK',
    ],

    // Timeout Configuration
    'timeout' => [
        'connect' => 10,
        'request' => 30,
    ],
];

Step 4: Create DTOs (Data Transfer Objects)

Create DTOs to structure your payment requests and responses.

Payment Request DTO

app/Services/ShadhinPay/DTOs/PaymentRequest.php
<?php

namespace App\Services\ShadhinPay\DTOs;

class PaymentRequest
{
    public function __construct(
        public float $amount,
        public string $currency,
        public string $transactionId,
        public string $callbackUrl,
        public ?string $customerPhone = null,
        public ?string $customerEmail = null,
        public ?string $customerName = null,
        public ?string $description = null,
        public ?string $preferredVendor = null,
        public bool $isSandbox = true,
        public ?string $valueA = null,
        public ?string $valueB = null,
        public ?string $valueC = null,
        public ?string $valueD = null,
    ) {}

    public static function fromOrder($order, string $callbackUrl): self
    {
        $isSandbox = config('shadhinpay.sandbox.enabled', true);

        return new self(
            amount: (float) $order->total,
            currency: $order->currency ?? 'BDT',
            transactionId: $order->order_number . '_' . time(),
            callbackUrl: $callbackUrl,
            customerPhone: $order->customer_phone,
            customerEmail: $order->customer_email,
            customerName: $order->customer_name,
            description: "Payment for Order #{$order->order_number}",
            preferredVendor: $isSandbox ? config('shadhinpay.sandbox.preferred_vendor', 'MOCK') : null,
            isSandbox: $isSandbox,
            valueA: (string) $order->id,
            valueB: $order->order_number,
        );
    }

    public function toArray(): array
    {
        return array_filter([
            'amount' => $this->amount,
            'currency' => $this->currency,
            'transactionId' => $this->transactionId,
            'callbackUrl' => $this->callbackUrl,
            'customerPhone' => $this->customerPhone,
            'customerEmail' => $this->customerEmail,
            'customerName' => $this->customerName,
            'description' => $this->description,
            'preferredVendor' => $this->preferredVendor,
            'isSandbox' => $this->isSandbox,
            'valueA' => $this->valueA,
            'valueB' => $this->valueB,
            'valueC' => $this->valueC,
            'valueD' => $this->valueD,
        ], fn($value) => $value !== null);
    }
}

Payment Response DTO

app/Services/ShadhinPay/DTOs/PaymentResponse.php
<?php

namespace App\Services\ShadhinPay\DTOs;

class PaymentResponse
{
    public function __construct(
        public string $paymentId,
        public ?string $paymentUrl = null,
        public ?string $qrCodeUrl = null,
        public ?string $status = null,
        public ?string $vendorUsed = null,
        public ?string $expiresAt = null,
        public bool $isSandbox = false,
    ) {}

    public static function fromApiResponse(array $data): self
    {
        return new self(
            paymentId: $data['paymentId'] ?? '',
            paymentUrl: $data['paymentUrl'] ?? null,
            qrCodeUrl: $data['qrCodeUrl'] ?? null,
            status: $data['status'] ?? $data['paymentStatus'] ?? null,
            vendorUsed: $data['vendorUsed'] ?? $data['vendorName'] ?? null,
            expiresAt: $data['expiresAt'] ?? null,
            isSandbox: $data['isSandbox'] ?? false,
        );
    }

    public function isSuccessful(): bool
    {
        return in_array($this->status, ['SUCCESS', 'COMPLETED']);
    }

    public function isFailed(): bool
    {
        return in_array($this->status, ['FAILED', 'CANCELLED', 'EXPIRED']);
    }

    public function isPending(): bool
    {
        return in_array($this->status, ['INITIATED', 'PENDING', 'PROCESSING']);
    }
}

Step 5: Create the ShadhinPay Service

This is the core service that handles all API communication:

app/Services/ShadhinPay/ShadhinPayService.php
<?php

namespace App\Services\ShadhinPay;

use App\Services\ShadhinPay\DTOs\PaymentRequest;
use App\Services\ShadhinPay\DTOs\PaymentResponse;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class ShadhinPayService
{
    protected array $config;
    protected PendingRequest $client;

    public function __construct()
    {
        $this->config = config('shadhinpay');
        $this->initializeClient();
    }

    protected function initializeClient(): void
    {
        $this->client = Http::baseUrl($this->config['base_url'])
            ->timeout($this->config['timeout']['request'])
            ->connectTimeout($this->config['timeout']['connect'])
            ->withHeaders($this->getAuthHeaders())
            ->acceptJson();
    }

    protected function getAuthHeaders(): array
    {
        return [
            'Client-Id' => $this->config['client_id'] ?? '',
            'Business-Id' => $this->config['business_id'] ?? '',
            'Secret-Key' => $this->config['secret_key'] ?? '',
            'Content-Type' => 'application/json',
        ];
    }

    /**
     * Initiate a new payment
     */
    public function initiatePayment(PaymentRequest $request): PaymentResponse
    {
        try {
            $response = $this->client->post('/api/v1/payment', $request->toArray());

            if ($response->successful()) {
                $data = $response->json();
                return PaymentResponse::fromApiResponse($data['data'] ?? $data);
            }

            $error = $response->json()['message'] ?? 'Payment initiation failed';
            throw new \Exception($error);

        } catch (\Exception $e) {
            Log::error('ShadhinPay payment initiation failed', [
                'error' => $e->getMessage(),
                'request' => $request->toArray(),
            ]);
            throw $e;
        }
    }

    /**
     * Check payment status
     */
    public function checkPaymentStatus(string $paymentId): PaymentResponse
    {
        try {
            $response = $this->client->get("/api/v1/payment/status/{$paymentId}");

            if ($response->successful()) {
                $data = $response->json();
                return PaymentResponse::fromApiResponse($data['data'] ?? $data);
            }

            throw new \Exception($response->json()['message'] ?? 'Status check failed');

        } catch (\Exception $e) {
            Log::error('ShadhinPay status check failed', [
                'error' => $e->getMessage(),
                'payment_id' => $paymentId,
            ]);
            throw $e;
        }
    }
}

Step 6: Create the Payment Model

Create a model to track payments in your database:

app/Models/Payment.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Payment extends Model
{
    protected $fillable = [
        'order_id',
        'user_id',
        'payment_id',
        'transaction_id',
        'amount',
        'currency',
        'status',
        'payment_url',
        'qr_code_url',
        'callback_url',
        'vendor_used',
        'initiated_at',
        'completed_at',
        'expires_at',
        'is_sandbox',
    ];

    protected $casts = [
        'amount' => 'decimal:2',
        'initiated_at' => 'datetime',
        'completed_at' => 'datetime',
        'expires_at' => 'datetime',
        'is_sandbox' => 'boolean',
    ];

    public function order()
    {
        return $this->belongsTo(Order::class);
    }

    public function isSuccessful(): bool
    {
        return in_array($this->status, ['SUCCESS', 'COMPLETED']);
    }

    public function isPending(): bool
    {
        return in_array($this->status, ['INITIATED', 'PENDING', 'PROCESSING']);
    }
}

Create the migration:

database/migrations/create_payments_table.php
<?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('payments', function (Blueprint $table) {
            $table->id();
            $table->foreignId('order_id')->constrained()->onDelete('cascade');
            $table->foreignId('user_id')->nullable()->constrained()->onDelete('set null');
            $table->string('payment_id')->unique();
            $table->string('transaction_id')->unique();
            $table->decimal('amount', 12, 2);
            $table->string('currency', 3)->default('BDT');
            $table->string('status', 20)->default('initiated');
            $table->string('payment_url')->nullable();
            $table->string('qr_code_url')->nullable();
            $table->string('callback_url')->nullable();
            $table->string('vendor_used', 50)->nullable();
            $table->timestamp('initiated_at')->nullable();
            $table->timestamp('completed_at')->nullable();
            $table->timestamp('expires_at')->nullable();
            $table->boolean('is_sandbox')->default(false);
            $table->timestamps();

            $table->index(['status', 'created_at']);
            $table->index('order_id');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('payments');
    }
};

Step 7: Create the Checkout Controller

Handle the checkout and payment flow:

app/Http/Controllers/CheckoutController.php
<?php

namespace App\Http\Controllers;

use App\Models\Order;
use App\Models\Payment;
use App\Services\ShadhinPay\ShadhinPayService;
use App\Services\ShadhinPay\DTOs\PaymentRequest;
use Illuminate\Http\Request;

class CheckoutController extends Controller
{
    public function __construct(
        protected ShadhinPayService $shadhinPay,
    ) {}

    public function process(Request $request)
    {
        $request->validate([
            'customer_name' => 'required|string|max:255',
            'customer_email' => 'required|email|max:255',
            'customer_phone' => 'required|string|max:20',
        ]);

        $user = auth()->user();

        // Create order (implement your own order creation logic)
        $order = Order::create([
            'user_id' => $user->id,
            'order_number' => 'ORD-' . strtoupper(uniqid()),
            'total' => $request->total,
            'currency' => 'BDT',
            'customer_name' => $request->customer_name,
            'customer_email' => $request->customer_email,
            'customer_phone' => $request->customer_phone,
            'status' => 'pending',
        ]);

        // Initiate payment with ShadhinPay
        try {
            $callbackUrl = route('payment.callback');
            $paymentRequest = PaymentRequest::fromOrder($order, $callbackUrl);

            $response = $this->shadhinPay->initiatePayment($paymentRequest);

            // Create payment record
            $payment = Payment::create([
                'order_id' => $order->id,
                'user_id' => $user->id,
                'payment_id' => $response->paymentId,
                'transaction_id' => $paymentRequest->transactionId,
                'amount' => $order->total,
                'currency' => $order->currency,
                'status' => 'initiated',
                'payment_url' => $response->paymentUrl,
                'qr_code_url' => $response->qrCodeUrl,
                'callback_url' => $callbackUrl,
                'vendor_used' => $response->vendorUsed,
                'initiated_at' => now(),
                'expires_at' => $response->expiresAt,
                'is_sandbox' => $response->isSandbox,
            ]);

            // Redirect to payment page
            return redirect()->route('checkout.payment', $order);

        } catch (\Exception $e) {
            $order->update(['status' => 'failed']);

            return back()
                ->withInput()
                ->with('error', 'Payment initiation failed: ' . $e->getMessage());
        }
    }

    public function payment(Order $order)
    {
        $payment = $order->payments()->latest()->first();

        if (!$payment) {
            return redirect()->route('orders.show', $order)
                ->with('error', 'No payment found for this order');
        }

        return view('checkout.payment', [
            'order' => $order,
            'payment' => $payment,
            'pollInterval' => config('shadhinpay.polling.interval_seconds', 10) * 1000,
        ]);
    }

    public function success(Order $order)
    {
        return view('checkout.success', compact('order'));
    }

    public function failed(Order $order)
    {
        $payment = $order->payments()->latest()->first();
        return view('checkout.failed', compact('order', 'payment'));
    }
}

Step 8: Handle Webhooks

Create a controller to process webhook callbacks:

app/Http/Controllers/WebhookController.php
<?php

namespace App\Http\Controllers;

use App\Models\Order;
use App\Models\Payment;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;

class WebhookController extends Controller
{
    public function handlePaymentCallback(Request $request)
    {
        Log::info('ShadhinPay webhook received', $request->all());

        // Verify signature (recommended for production)
        if (!$this->verifySignature($request)) {
            Log::warning('Invalid webhook signature');
            return response()->json(['error' => 'Invalid signature'], 401);
        }

        $paymentId = $request->input('paymentId');
        $status = $request->input('status');

        $payment = Payment::where('payment_id', $paymentId)->first();

        if (!$payment) {
            Log::warning('Payment not found for webhook', ['payment_id' => $paymentId]);
            return response()->json(['error' => 'Payment not found'], 404);
        }

        // Update payment status
        $payment->update([
            'status' => strtolower($status),
            'completed_at' => in_array($status, ['SUCCESS', 'FAILED', 'EXPIRED']) ? now() : null,
        ]);

        // Update order status
        $order = $payment->order;
        if ($order) {
            $orderStatus = match($status) {
                'SUCCESS' => 'paid',
                'FAILED' => 'failed',
                'EXPIRED' => 'expired',
                default => $order->status,
            };
            $order->update(['status' => $orderStatus]);
        }

        Log::info('Payment status updated via webhook', [
            'payment_id' => $paymentId,
            'status' => $status,
        ]);

        return response()->json(['success' => true]);
    }

    protected function verifySignature(Request $request): bool
    {
        $signature = $request->header('X-ShadhinPay-Signature');
        if (!$signature) {
            return true; // Skip verification if no signature (dev mode)
        }

        $payload = $request->getContent();
        $secret = config('shadhinpay.secret_key');
        $expected = hash_hmac('sha256', $payload, $secret);

        return hash_equals("sha256={$expected}", $signature);
    }
}

Step 9: Implement Status Polling

Create an API endpoint for client-side polling:

app/Http/Controllers/PaymentStatusController.php
<?php

namespace App\Http\Controllers;

use App\Models\Payment;
use App\Services\ShadhinPay\ShadhinPayService;
use Illuminate\Http\Request;

class PaymentStatusController extends Controller
{
    public function __construct(
        protected ShadhinPayService $shadhinPay,
    ) {}

    public function check(Request $request, string $paymentId)
    {
        $payment = Payment::where('payment_id', $paymentId)->first();

        if (!$payment) {
            return response()->json(['error' => 'Payment not found'], 404);
        }

        // If already in terminal state, return cached status
        if (!$payment->isPending()) {
            return response()->json([
                'status' => $payment->status,
                'is_complete' => true,
                'redirect_url' => $payment->isSuccessful()
                    ? route('checkout.success', $payment->order_id)
                    : route('checkout.failed', $payment->order_id),
            ]);
        }

        // Poll the API for current status
        try {
            $response = $this->shadhinPay->checkPaymentStatus($paymentId);

            // Update local status if changed
            if ($response->status && strtolower($response->status) !== $payment->status) {
                $payment->update([
                    'status' => strtolower($response->status),
                    'completed_at' => $response->isSuccessful() || $response->isFailed() ? now() : null,
                ]);

                // Update order status too
                if ($payment->order) {
                    $orderStatus = match(strtoupper($response->status)) {
                        'SUCCESS' => 'paid',
                        'FAILED' => 'failed',
                        'EXPIRED' => 'expired',
                        default => $payment->order->status,
                    };
                    $payment->order->update(['status' => $orderStatus]);
                }
            }

            $isComplete = $response->isSuccessful() || $response->isFailed();

            return response()->json([
                'status' => $response->status,
                'is_complete' => $isComplete,
                'redirect_url' => $isComplete
                    ? ($response->isSuccessful()
                        ? route('checkout.success', $payment->order_id)
                        : route('checkout.failed', $payment->order_id))
                    : null,
            ]);

        } catch (\Exception $e) {
            return response()->json([
                'status' => $payment->status,
                'is_complete' => false,
                'error' => 'Failed to check status',
            ]);
        }
    }
}

Step 10: Set Up Routes

Add the necessary routes:

routes/web.php
<?php

use App\Http\Controllers\CheckoutController;
use App\Http\Controllers\PaymentStatusController;
use App\Http\Controllers\WebhookController;

// Checkout routes (authenticated)
Route::middleware(['auth'])->group(function () {
    Route::get('/checkout', [CheckoutController::class, 'index'])->name('checkout.index');
    Route::post('/checkout', [CheckoutController::class, 'process'])->name('checkout.process');
    Route::get('/checkout/{order}/payment', [CheckoutController::class, 'payment'])->name('checkout.payment');
    Route::get('/checkout/{order}/success', [CheckoutController::class, 'success'])->name('checkout.success');
    Route::get('/checkout/{order}/failed', [CheckoutController::class, 'failed'])->name('checkout.failed');
});

// Payment status polling (authenticated)
Route::middleware(['auth'])->group(function () {
    Route::get('/api/payment/status/{paymentId}', [PaymentStatusController::class, 'check'])
        ->name('payment.status');
});

// Webhook route (no auth - called by ShadhinPay)
Route::post('/api/payment/callback', [WebhookController::class, 'handlePaymentCallback'])
    ->name('payment.callback')
    ->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);

Step 11: Create Payment Page View

Create the payment page with status polling:

resources/views/checkout/payment.blade.php
<!DOCTYPE html>
<html>
<head>
    <title>Complete Payment - {{ $order->order_number }}</title>
    <script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-100 min-h-screen flex items-center justify-center">
    <div class="bg-white rounded-lg shadow-xl p-8 max-w-md w-full">
        <div class="text-center">
            <h1 class="text-2xl font-bold mb-2">Complete Your Payment</h1>
            <p class="text-gray-600 mb-6">Order #{{ $order->order_number }}</p>

            <div class="bg-gray-50 rounded-lg p-4 mb-6">
                <p class="text-3xl font-bold text-green-600">
                    {{ number_format($payment->amount, 2) }} {{ $payment->currency }}
                </p>
            </div>

            <!-- Payment Options -->
            <div class="space-y-4 mb-6">
                @if($payment->payment_url)
                <a href="{{ $payment->payment_url }}"
                   class="block w-full bg-blue-600 text-white py-3 px-4 rounded-lg hover:bg-blue-700 transition"
                   target="_blank">
                    Pay Now
                </a>
                @endif

                @if($payment->qr_code_url)
                <div class="border rounded-lg p-4">
                    <p class="text-sm text-gray-600 mb-2">Or scan QR code:</p>
                    <img src="{{ $payment->qr_code_url }}" alt="Payment QR Code" class="mx-auto">
                </div>
                @endif
            </div>

            <!-- Status -->
            <div id="payment-status" class="text-center">
                <div class="animate-spin h-8 w-8 border-4 border-blue-600 border-t-transparent rounded-full mx-auto mb-2"></div>
                <p class="text-gray-600">Waiting for payment confirmation...</p>
            </div>
        </div>
    </div>

    <script>
        const paymentId = '{{ $payment->payment_id }}';
        const pollInterval = {{ $pollInterval }};
        let attempts = 0;
        const maxAttempts = 30;

        function checkStatus() {
            attempts++;
            if (attempts > maxAttempts) {
                document.getElementById('payment-status').innerHTML = `
                    <p class="text-yellow-600">Payment verification timed out.</p>
                    <p class="text-sm text-gray-500">Please check your order status.</p>
                `;
                return;
            }

            fetch(`/api/payment/status/${paymentId}`)
                .then(response => response.json())
                .then(data => {
                    if (data.is_complete && data.redirect_url) {
                        window.location.href = data.redirect_url;
                    } else {
                        setTimeout(checkStatus, pollInterval);
                    }
                })
                .catch(error => {
                    console.error('Status check failed:', error);
                    setTimeout(checkStatus, pollInterval);
                });
        }

        // Start polling after a short delay
        setTimeout(checkStatus, 3000);
    </script>
</body>
</html>

Step 12: Testing with Sandbox Mode

When sandbox mode is enabled, the Mock vendor simulates payment outcomes based on the amount:

Amount PatternResult
Ends in 1 (e.g., 101, 1001)SUCCESS
Ends in 2 (e.g., 102, 1002)FAILED
Ends in 3 (e.g., 103, 1003)EXPIRED
Other amountsSUCCESS (default)

Use different amounts to test various payment scenarios during development.

Complete Flow Summary

  1. Customer initiates checkoutCheckoutController@process
  2. Order is created → Saved to database
  3. Payment is initiated → ShadhinPay API call
  4. Customer is redirected → Payment page with QR/link
  5. Customer completes payment → On vendor's payment page
  6. Webhook is receivedWebhookController@handlePaymentCallback
  7. Status is updated → Payment and Order models
  8. Customer is redirected → Success or failure page

Production Checklist

Before going live:

  • Set SHADHINPAY_SANDBOX=false
  • Update SHADHINPAY_BASE_URL to production URL
  • Verify webhook signature validation is enabled
  • Set up HTTPS for your callback URL
  • Test the complete flow with real payments
  • Implement proper error logging and monitoring
  • Set up retry logic for failed webhooks

Next Steps

On this page