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:
# 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=30Never commit your Secret Key to version control. Always use environment variables.
Step 3: Create Configuration File
Create a configuration file for ShadhinPay settings:
<?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
<?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
<?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:
<?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:
<?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:
<?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:
<?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:
<?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:
<?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:
<?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:
<!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 Pattern | Result |
|---|---|
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 amounts | SUCCESS (default) |
Use different amounts to test various payment scenarios during development.
Complete Flow Summary
- Customer initiates checkout →
CheckoutController@process - Order is created → Saved to database
- Payment is initiated → ShadhinPay API call
- Customer is redirected → Payment page with QR/link
- Customer completes payment → On vendor's payment page
- Webhook is received →
WebhookController@handlePaymentCallback - Status is updated → Payment and Order models
- Customer is redirected → Success or failure page
Production Checklist
Before going live:
- Set
SHADHINPAY_SANDBOX=false - Update
SHADHINPAY_BASE_URLto 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