Razorpay Payment Gateway Integration in Laravel 11
Razorpay is the most widely used payment gateway in India, and integrating it correctly in Laravel requires more than just calling the API. You need proper order creation, signature verification, and idempotent webhook handling. This guide walks through the complete production-ready setup.
Prerequisites
- Laravel 10 or 11
- A Razorpay account (test mode works fine for development)
- Composer installed
- HTTPS endpoint for webhooks (use ngrok locally)
Step 1: Install the Razorpay SDK
composer require razorpay/razorpayAdd your credentials to .env. You get these from the Razorpay Dashboard under Settings → API Keys.
RAZORPAY_KEY_ID=rzp_test_XXXXXXXXXXXXXXX
RAZORPAY_KEY_SECRET=XXXXXXXXXXXXXXXXXXXXXXXX
RAZORPAY_WEBHOOK_SECRET=your_webhook_secretStep 2: Create a PaymentService
Rather than scattering Razorpay calls across controllers, encapsulate all payment logic in a dedicated service class.
<?php
namespace App\Services;
use Razorpay\Api\Api;
use Razorpay\Api\Errors\SignatureVerificationError;
class PaymentService
{
private Api $api;
public function __construct()
{
$this->api = new Api(
config('services.razorpay.key_id'),
config('services.razorpay.key_secret')
);
}
public function createOrder(int $amountInPaise, string $currency = 'INR', array $notes = []): array
{
$order = $this->api->order->create([
'receipt' => 'rcpt_' . uniqid(),
'amount' => $amountInPaise,
'currency' => $currency,
'notes' => $notes,
]);
return $order->toArray();
}
public function verifyPaymentSignature(string $orderId, string $paymentId, string $signature): bool
{
try {
$this->api->utility->verifyPaymentSignature([
'razorpay_order_id' => $orderId,
'razorpay_payment_id' => $paymentId,
'razorpay_signature' => $signature,
]);
return true;
} catch (SignatureVerificationError) {
return false;
}
}
public function verifyWebhookSignature(string $payload, string $signature): bool
{
try {
$this->api->utility->verifyWebhookSignature(
$payload,
$signature,
config('services.razorpay.webhook_secret')
);
return true;
} catch (SignatureVerificationError) {
return false;
}
}
}Step 3: Add config/services.php entry
'razorpay' => [
'key_id' => env('RAZORPAY_KEY_ID'),
'key_secret' => env('RAZORPAY_KEY_SECRET'),
'webhook_secret' => env('RAZORPAY_WEBHOOK_SECRET'),
],Step 4: Order Creation Controller
<?php
namespace App\Http\Controllers;
use App\Services\PaymentService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PaymentController extends Controller
{
public function __construct(private PaymentService $paymentService) {}
public function createOrder(Request $request): JsonResponse
{
$request->validate([
'amount' => 'required|integer|min:100', // minimum ₹1
]);
$order = $this->paymentService->createOrder($request->amount);
return response()->json([
'order_id' => $order['id'],
'amount' => $order['amount'],
'currency' => $order['currency'],
'key_id' => config('services.razorpay.key_id'),
]);
}
public function verifyPayment(Request $request): JsonResponse
{
$request->validate([
'razorpay_order_id' => 'required|string',
'razorpay_payment_id' => 'required|string',
'razorpay_signature' => 'required|string',
]);
$isValid = $this->paymentService->verifyPaymentSignature(
$request->razorpay_order_id,
$request->razorpay_payment_id,
$request->razorpay_signature
);
if (!$isValid) {
return response()->json(['message' => 'Invalid payment signature'], 422);
}
// Mark your order as paid in the database here
// Order::where('razorpay_order_id', $request->razorpay_order_id)->update(['status' => 'paid']);
return response()->json(['message' => 'Payment verified successfully']);
}
}Step 5: Webhook Handler
Webhooks are essential for handling payment events server-side (especially when the user closes the browser mid-payment). Always verify the signature before processing.
<?php
namespace App\Http\Controllers;
use App\Services\PaymentService;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
class WebhookController extends Controller
{
public function __construct(private PaymentService $paymentService) {}
public function handle(Request $request): Response
{
$payload = $request->getContent();
$signature = $request->header('X-Razorpay-Signature', '');
if (!$this->paymentService->verifyWebhookSignature($payload, $signature)) {
Log::warning('Razorpay webhook signature mismatch');
return response('Unauthorized', 401);
}
$event = $request->input('event');
match ($event) {
'payment.captured' => $this->handlePaymentCaptured($request->input('payload')),
'payment.failed' => $this->handlePaymentFailed($request->input('payload')),
'refund.created' => $this->handleRefundCreated($request->input('payload')),
default => null,
};
return response('OK', 200);
}
private function handlePaymentCaptured(array $payload): void
{
$paymentId = $payload['payment']['entity']['id'];
$orderId = $payload['payment']['entity']['order_id'];
// Update your database, send receipt email, etc.
Log::info("Payment captured: {$paymentId} for order: {$orderId}");
}
private function handlePaymentFailed(array $payload): void
{
$paymentId = $payload['payment']['entity']['id'];
Log::warning("Payment failed: {$paymentId}");
}
private function handleRefundCreated(array $payload): void
{
$refundId = $payload['refund']['entity']['id'];
Log::info("Refund created: {$refundId}");
}
}Step 6: Routes
// routes/api.php
Route::middleware('auth:sanctum')->group(function () {
Route::post('/payment/create-order', [PaymentController::class, 'createOrder']);
Route::post('/payment/verify', [PaymentController::class, 'verifyPayment']);
});
// Webhook must be excluded from CSRF and auth
Route::post('/webhook/razorpay', [WebhookController::class, 'handle'])
->withoutMiddleware(['auth', VerifyCsrfToken::class]);Step 7: Frontend Integration (Vanilla JS)
async function initiatePayment(amountInPaise) {
// 1. Create order on your backend
const { order_id, amount, currency, key_id } = await fetch('/api/payment/create-order', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ amount: amountInPaise }),
}).then(r => r.json());
// 2. Open Razorpay checkout
const rzp = new Razorpay({
key: key_id,
amount,
currency,
order_id,
name: 'Your App Name',
description: 'Purchase description',
handler: async function (response) {
// 3. Verify payment on your backend
await fetch('/api/payment/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify(response),
});
},
prefill: {
name: 'Customer Name',
email: 'customer@example.com',
contact: '9999999999',
},
theme: { color: '#2563eb' },
});
rzp.open();
}Common Mistakes to Avoid
- Never trust client-side payment confirmation alone — always verify the signature server-side
- Don't store the key_secret in frontend code or version control
- Use idempotency keys when creating orders to avoid duplicate charges on retries
- Always validate webhook signatures before updating order status
- Store razorpay_payment_id and razorpay_order_id in your database for refund support
Note: Test all webhook events using the Razorpay Dashboard → Webhooks → Test Webhook before going live. Switch from rzp_test_ to rzp_live_ keys when ready for production.
Pratik Yewale
Assistant Manager – Software at Qing Aamby City Developers Corporations Limited. Previously Backend Developer at Neuromonk Infotech building scalable APIs, ERP systems, and booking platforms. Published researcher in cricket analytics.
View Portfolio