Build a secure, production-ready Razorpay payment integration in Laravel with complete transaction management, server-side payment verification, OTP authentication, custom SMS notifications, and webhook support.
Description
This implementation provides a complete payment workflow:
- π³ Razorpay payment gateway integration
- π Secure Razorpay signature verification
- π Unique transaction ID for every payment
- π¦ Razorpay Order ID and Payment ID tracking
- π° Amount and currency verification
- π️ Complete payment and customer data stored in MySQL
- π Transaction event/audit history
- π± OTP generation after successful payment
- π Hashed OTP storage with expiration
- π« OTP attempt limits and duplicate OTP protection
- π¬ Custom SMS message after successful payment
- ⚡ Queue-based SMS sending
- π Razorpay webhook integration
- π‘️ Idempotent payment processing
- ❌ Payment failure handling
- πΈ Refund/status tracking support
- π Detailed payment response storage
- π Transaction reconciliation and debugging support
- π Production-focused security practices
Complete Payment Flow
Customer↓Create Transaction↓Generate Transaction ID↓Create Razorpay Order↓Razorpay Checkout↓Payment Successful↓Server-Side Signature Verification↓Verify Order + Amount + Currency↓Save Payment Details↓Mark Transaction as PAID↓Generate OTP↓Store Hashed OTP↓Send Custom SMS↓Customer Enters OTP↓Verify OTP↓Mark Transaction COMPLETED
Goal: Create a reliable Laravel payment system where every payment is securely tracked from transaction creation → Razorpay payment → verification → OTP → final completion, with a complete database audit trail.
I’ll use Laravel 12-style structure, Eloquent, queued SMS, signed Razorpay checkout, server-side verification, webhook handling, idempotency, OTP hashing, expiry, and attempt limits.
Important: Replace the
SmsServiceimplementation with your actual SMS provider credentials/API. The payment logic itself does not depend on the SMS provider.
1. Install Razorpay
composer require razorpay/razorpay
.env
APP_NAME="My Laravel App"APP_ENV=productionAPP_DEBUG=falseAPP_URL=https://example.comDB_CONNECTION=mysqlDB_HOST=127.0.0.1DB_PORT=3306DB_DATABASE=my_databaseDB_USERNAME=my_userDB_PASSWORD=my_passwordRAZORPAY_KEY_ID=rzp_live_xxxxxxxxxRAZORPAY_KEY_SECRET=xxxxxxxxxxxxxxxxRAZORPAY_WEBHOOK_SECRET=xxxxxxxxxxxxxxxxQUEUE_CONNECTION=databaseOTP_EXPIRY_MINUTES=10OTP_MAX_ATTEMPTS=5
config/services.php
'razorpay' => ['key' => env('RAZORPAY_KEY_ID'),'secret' => env('RAZORPAY_KEY_SECRET'),'webhook_secret' => env('RAZORPAY_WEBHOOK_SECRET'),],'otp' => ['expiry_minutes' => env('OTP_EXPIRY_MINUTES', 10),'max_attempts' => env('OTP_MAX_ATTEMPTS', 5),],
2. Database structure
We'll create:
transactionstransaction_eventsotps
The important identifier is our own:
transaction_id
For example:
TXN-20260820-A8F92KLM7P
This is separate from Razorpay's:
razorpay_order_idrazorpay_payment_id
3. Transactions migration
Create:
php artisan make:migration create_transactions_table
<?phpuse 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('transactions', function (Blueprint $table) {$table->id();$table->uuid('uuid')->unique();$table->string('transaction_id', 50)->unique();$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();$table->string('customer_name');$table->string('customer_email');$table->string('customer_phone', 20);$table->decimal('amount', 12, 2);$table->string('currency', 3)->default('INR');$table->string('status')->default('created');$table->string('razorpay_order_id')->nullable()->unique();$table->string('razorpay_payment_id')->nullable()->unique();$table->string('razorpay_signature')->nullable();$table->string('payment_method')->nullable();$table->string('payment_status')->nullable();$table->timestamp('paid_at')->nullable();$table->timestamp('failed_at')->nullable();$table->text('failure_reason')->nullable();$table->json('razorpay_order_response')->nullable();$table->json('razorpay_payment_response')->nullable();$table->json('metadata')->nullable();$table->timestamps();$table->index(['user_id', 'status']);$table->index(['customer_phone']);$table->index(['created_at']);});}public function down(): void{Schema::dropIfExists('transactions');}};
4. Transaction events
This gives you an audit trail.
php artisan make:migration create_transaction_events_table
<?phpuse 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('transaction_events', function (Blueprint $table) {$table->id();$table->foreignId('transaction_id')->constrained('transactions')->cascadeOnDelete();$table->string('event');$table->string('source')->nullable();$table->json('payload')->nullable();$table->timestamps();$table->index(['transaction_id','event']);});}public function down(): void{Schema::dropIfExists('transaction_events');}};
5. OTP migration
php artisan make:migration create_otps_table
<?phpuse 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('otps', function (Blueprint $table) {$table->id();$table->foreignId('transaction_id')->constrained('transactions')->cascadeOnDelete();$table->string('phone', 20);$table->string('otp_hash');$table->timestamp('expires_at');$table->unsignedTinyInteger('attempts')->default(0);$table->unsignedTinyInteger('max_attempts')->default(5);$table->timestamp('verified_at')->nullable();$table->timestamp('last_sent_at')->nullable();$table->timestamps();$table->index(['transaction_id','verified_at']);});}public function down(): void{Schema::dropIfExists('otps');}};
Run:
php artisan migrate
6. Transaction model
app/Models/Transaction.php
<?phpnamespace App\Models;use Illuminate\Database\Eloquent\Model;use Illuminate\Database\Eloquent\Relations\HasMany;use Illuminate\Database\Eloquent\Relations\BelongsTo;class Transaction extends Model{protected $fillable = ['uuid','transaction_id','user_id','customer_name','customer_email','customer_phone','amount','currency','status','razorpay_order_id','razorpay_payment_id','razorpay_signature','payment_method','payment_status','paid_at','failed_at','failure_reason','razorpay_order_response','razorpay_payment_response','metadata',];protected $casts = ['amount' => 'decimal:2','paid_at' => 'datetime','failed_at' => 'datetime','razorpay_order_response' => 'array','razorpay_payment_response' => 'array','metadata' => 'array',];public function events(): HasMany{return $this->hasMany(TransactionEvent::class);}public function otps(): HasMany{return $this->hasMany(Otp::class);}public function user(): BelongsTo{return $this->belongsTo(User::class);}}
7. TransactionEvent model
app/Models/TransactionEvent.php
<?phpnamespace App\Models;use Illuminate\Database\Eloquent\Model;use Illuminate\Database\Eloquent\Relations\BelongsTo;class TransactionEvent extends Model{protected $fillable = ['transaction_id','event','source','payload',];protected $casts = ['payload' => 'array',];public function transaction(): BelongsTo{return $this->belongsTo(Transaction::class);}}
8. OTP model
app/Models/Otp.php
<?phpnamespace App\Models;use Illuminate\Database\Eloquent\Model;use Illuminate\Database\Eloquent\Relations\BelongsTo;class Otp extends Model{protected $fillable = ['transaction_id','phone','otp_hash','expires_at','attempts','max_attempts','verified_at','last_sent_at',];protected $casts = ['expires_at' => 'datetime','verified_at' => 'datetime','last_sent_at' => 'datetime',];protected $hidden = ['otp_hash',];public function transaction(): BelongsTo{return $this->belongsTo(Transaction::class);}}
9. Razorpay service
Create:
app/Services/RazorpayService.php
<?phpnamespace App\Services;use Razorpay\Api\Api;class RazorpayService{protected Api $api;public function __construct(){$this->api = new Api(config('services.razorpay.key'),config('services.razorpay.secret'));}public function createOrder(string $receipt,int $amountInPaise,string $currency = 'INR'): array {$order = $this->api->order->create(['receipt' => $receipt,'amount' => $amountInPaise,'currency' => $currency,]);return $order->toArray();}public function verifyPayment(string $orderId,string $paymentId,string $signature): bool {$this->api->utility->verifyPaymentSignature(['razorpay_order_id' => $orderId,'razorpay_payment_id' => $paymentId,'razorpay_signature' => $signature,]);return true;}public function fetchPayment(string $paymentId): array{return $this->api->payment->fetch($paymentId)->toArray();}}
10. Transaction service
This is where the important business logic lives.
app/Services/PaymentService.php
<?php'-' .strtoupper(Str::random(10));$transaction = Transaction::create(['uuid' => (string) Str::uuid(),'transaction_id' => $transactionId,'user_id' => $data['user_id'] ?? null,'customer_name' => $data['name'],'customer_email' => $data['email'],'customer_phone' => $data['phone'],'amount' => $data['amount'],'currency' => 'INR','status' => 'creating','metadata' => $data['metadata'] ?? null,]);$amountPaise = (int) round($transaction->amount * 100);$order = $this->razorpay->createOrder($transaction->transaction_id,$amountPaise,$transaction->currency);$transaction->update(['razorpay_order_id' => $order['id'],'status' => 'created','razorpay_order_response' => $order,]);$transaction->events()->create(['event' => 'order_created','source' => 'application','payload' => $order,]);return $transaction->fresh();});}public function verifyAndCapturePayment(Transaction $transaction,array $paymentData): Transaction {return DB::transaction(function () use ($transaction,$paymentData) {$transaction = Transaction::query()->whereKey($transaction->id)->lockForUpdate()->firstOrFail();/** Idempotency:** If the payment was already processed,* don't process it again.*/if ($transaction->status === 'paid') {return $transaction;}$this->razorpay->verifyPayment($paymentData['razorpay_order_id'],$paymentData['razorpay_payment_id'],$paymentData['razorpay_signature']);/** Make sure the payment belongs to* our transaction.
11. OTP service
app/Services/OtpService.php
<?php'expires_at' => now(),]);$otp = (string) random_int(100000, 999999);$record = Otp::create(['transaction_id' => $transaction->id,'phone' => $transaction->customer_phone,'otp_hash' => Hash::make($otp),'expires_at' => now()->addMinutes(config('services.otp.expiry_minutes', 10)),'attempts' => 0,'max_attempts' => config('services.otp.max_attempts',5),'last_sent_at' => now(),]);/** Never return/store plaintext OTP in DB.** Return it temporarily so the SMS job can use it.*/$record->plain_otp = $otp;return $record;});}public function verify(Transaction $transaction,string $inputOtp): bool {return DB::transaction(function () use ($transaction,$inputOtp) {$otp = Otp::query()->where('transaction_id', $transaction->id)->whereNull('verified_at')->latest()->lockForUpdate()->first();if (!$otp) {throw new RuntimeException('OTP not found.');}if ($otp->expires_at->isPast()) {throw new RuntimeException('OTP has expired.');}if ($otp->attempts >= $otp->max_attempts) {throw new RuntimeException('Maximum OTP attempts exceeded.');}$otp->increment('attempts');if (!Hash::check($inputOtp, $otp->otp_hash)) {throw new RuntimeException('Invalid OTP.');}$otp->update(['verified_at' => now(),]);
12. SMS service
Create:
app/Services/SmsService.php
<?phpnamespace App\Services;class SmsService{public function sendOtp(string $phone,string $name,string $otp): bool {$message ="Hello {$name}, your payment was successful. " ."Your OTP is {$otp}. " ."This OTP is valid for 10 minutes. " ."Do not share this OTP with anyone.";/** Integrate your SMS provider here.** Example:** Http::post(...)*/logger()->info('OTP SMS prepared', ['phone' => $phone,'message' => $message,]);return true;}}
Do not log the OTP in production. The logger above is only illustrative; remove the message from production logs.
13. Queue job for SMS
Sending SMS inside the payment request can slow down the payment response.
Create:
php artisan make:job SendOtpSmsJob
app/Jobs/SendOtpSmsJob.php
<?phpnamespace App\Jobs;use App\Models\Transaction;use App\Services\SmsService;use Illuminate\Contracts\Queue\ShouldQueue;use Illuminate\Foundation\Queue\Queueable;class SendOtpSmsJob implements ShouldQueue{use Queueable;public int $tries = 3;public function __construct(public int $transactionId,public string $otp) {}public function handle(SmsService $sms): void{$transaction = Transaction::findOrFail($this->transactionId);$sms->sendOtp($transaction->customer_phone,$transaction->customer_name,$this->otp);}}
14. Payment controller
Create:
php artisan make:controller PaymentController
app/Http/Controllers/PaymentController.php
<?php}return response()->json(['success' => true,'transaction_id' =>$transaction->transaction_id,'message' =>'Payment successful. OTP has been sent.',]);} catch (Throwable $e) {Log::error('Payment verification failed',['message' => $e->getMessage(),'transaction_id' =>$request->transaction_id,]);return response()->json(['success' => false,'message' =>'Payment verification failed.',], 400);}}public function otpPage(string $transactionId) {$transaction = Transaction::where('transaction_id',$transactionId)->firstOrFail();abort_unless($transaction->status === 'paid',403);return view('payment.otp',compact('transaction'));}public function verifyOtp(Request $request){$validated = $request->validate(['transaction_id' =>'required|string','otp' =>'required|digits:6',]);try {$transaction = Transaction::where('transaction_id',$validated['transaction_id'])->firstOrFail();$this->otpService->verify($transaction,$validated['otp']);return response()->json(['success' => true,'message' =>'OTP verified successfully.',]);} catch (Throwable $e) {
15. Routes
routes/web.php
<?phpuse App\Http\Controllers\PaymentController;use Illuminate\Support\Facades\Route;Route::get('/payment',[PaymentController::class, 'checkout'])->name('payment.checkout');Route::post('/payment/create-order',[PaymentController::class, 'createOrder'])->name('payment.create');Route::post('/payment/success',[PaymentController::class, 'success'])->name('payment.success');Route::get('/payment/otp/{transactionId}',[PaymentController::class, 'otpPage'])->name('payment.otp');Route::post('/payment/verify-otp',[PaymentController::class, 'verifyOtp'])->name('payment.verify-otp');
If your payment requires authentication, use:
Route::middleware('auth')->group(function () {Route::get('/payment',[PaymentController::class, 'checkout']);Route::post('/payment/create-order',[PaymentController::class, 'createOrder']);// ...});
16. Checkout Blade
resources/views/payment/checkout.blade.php
<!DOCTYPE html><html><head><title>Payment</title><meta name="csrf-token"content="{{ csrf_token() }}"><script src="https://checkout.razorpay.com/v1/checkout.js"></script></head><body><h2>Make Payment</h2><form id="paymentForm"><inputtype="text"id="name"placeholder="Name"required><br><br><inputtype="email"id="email"placeholder="Email"required><br><br><inputtype="text"id="phone"placeholder="Phone"required><br><br><inputtype="number"id="amount"value="499"min="1"required><br><br><button type="submit">Pay Now</button></form><script>const form = document.getElementById('paymentForm');form.addEventListener('submit', async function (e) {e.preventDefault();const data = {name: document.getElementById('name').value,email: document.getElementById('email').value,phone: document.getElementById('phone').value,amount: document.getElementById('amount').value};const response = await fetch(
17. OTP page
resources/views/payment/otp.blade.php
<!DOCTYPE html><html><head><title>Verify OTP</title><meta name="csrf-token"content="{{ csrf_token() }}"></head><body><h2>Verify OTP</h2><p>Payment successful.</p><p>OTP has been sent to:{{ substr($transaction->customer_phone, 0, 3) }}******</p><form id="otpForm"><inputtype="hidden"id="transaction_id"value="{{ $transaction->transaction_id }}"><inputtype="text"id="otp"maxlength="6"pattern="[0-9]{6}"placeholder="Enter OTP"required><br><br><button type="submit">Verify OTP</button></form><div id="message"></div><script>
18. Razorpay webhook
This is extremely important for production.
The browser can disappear after payment. Your server should also receive Razorpay's webhook.
Create:
app/Http/Controllers/RazorpayWebhookController.php
<?php$payload,config('services.razorpay.webhook_secret'));if (!hash_equals($expectedSignature,$signature)) {Log::warning('Invalid Razorpay webhook signature');return response()->json(['message' => 'Invalid signature'], 400);}$data = $request->json()->all();$event = $data['event'] ?? null;$paymentEntity =$data['payload']['payment']['entity']?? null;if (!$paymentEntity) {return response()->json(['success' => true]);}$razorpayOrderId =$paymentEntity['order_id']?? null;if (!$razorpayOrderId) {return response()->json(['success' => true]);}DB::transaction(function () use ($razorpayOrderId,$paymentEntity,$event) {$transaction =Transaction::where('razorpay_order_id',$razorpayOrderId)->lockForUpdate()->first();if (!$transaction) {Log::warning('Transaction not found for webhook',['order_id' =>$razorpayOrderId]);return;}/** Store webhook event.*/$transaction->events()->create(['event' =>$event ?? 'unknown','source' =>'razorpay_webhook','payload' =>$paymentEntity,
19. Webhook route
Put this in routes/api.php:
use App\Http\Controllers\RazorpayWebhookController;Route::post('/razorpay/webhook',[RazorpayWebhookController::class, 'handle']);
The webhook endpoint should not require CSRF because Razorpay is calling it server-to-server.
Configure the webhook in your Razorpay dashboard to:
https://example.com/api/razorpay/webhook
Subscribe to at least:
payment.capturedpayment.failedorder.paid
20. Queue setup
Because SMS is queued, create the jobs table if necessary:
php artisan make:queue-tablephp artisan migrate
Then run:
php artisan queue:work --tries=3
For production, use Supervisor rather than manually running queue:work.
Example Supervisor configuration:
[program:laravel-worker]process_name=%(program_name)s_%(process_num)02dcommand=php /var/www/html/artisan queue:work --sleep=3 --tries=3 --timeout=90autostart=trueautorestart=truestopasgroup=truekillasgroup=truenumprocs=2redirect_stderr=truestdout_logfile=/var/www/html/storage/logs/worker.logstopwaitsecs=3600
21. Payment statuses
I recommend using:
creatingcreatedpaidcompletedfailedrefundedcancelled
The lifecycle is:
creating↓created↓paid↓completed
If payment fails:
created↓failed
If later refunded:
paid/completed↓refunded
22. Example database record
After payment, transactions could contain:
id: 25uuid: 2a9c...uuidtransaction_id: TXN-20260820-A8F92KLM7Puser_id: 15customer_name: Rahulcustomer_email: rahul@example.comcustomer_phone: 9876543210amount: 499.00currency: INRstatus: paidpayment_status: capturedrazorpay_order_id: order_Random123razorpay_payment_id: pay_Random456payment_method: upipaid_at: 2026-08-20 15:20:30
And the Razorpay response is preserved in:
razorpay_order_responserazorpay_payment_response
This is useful for reconciliation and debugging.
23. Transaction event history
You can then see:
transaction_eventsorder_createdpayment_verifiedpayment.capturedotp_verified
For example:
TXN-20260820-A8F92KLM7P15:20:01 order_created15:20:29 payment_verified15:20:31 payment.captured15:20:35 otp_verified
This is much better than trying to infer the payment history from a single transactions row.
24. Very important: prevent duplicate OTPs
There is one more production concern.
The browser callback and webhook can both arrive.
You must not do this:
generateOtp();sendSms();
every time a payment callback arrives.
Instead, use a locked transaction and check whether a valid OTP already exists.
For even stronger protection, add an otp_sent_at column to transactions:
$table->timestamp('otp_sent_at')->nullable();
Then inside the locked transaction:
if (!$transaction->otp_sent_at) {// Generate OTP$transaction->update(['otp_sent_at' => now(),]);// Dispatch SMS}
That makes the OTP process explicitly idempotent.
25. Security checklist
For a real production deployment, I recommend all of these:
✓ HTTPS✓ Razorpay signature verification✓ Razorpay webhook signature verification✓ Server-side amount verification✓ Server-side currency verification✓ Transaction/order ID matching✓ DB row locking✓ Idempotency protection✓ OTP hashed in database✓ OTP expiration✓ OTP attempt limit✓ SMS queued✓ Payment audit events✓ Razorpay response stored✓ No card information stored✓ No plaintext OTP stored✓ No OTP in production logs✓ Rate limiting on OTP endpoints✓ Rate limiting on payment creation✓ Production APP_DEBUG=false✓ Queue worker with Supervisor✓ Database backups✓ Payment reconciliation
One change I strongly recommend
For the most robust version, don't make the frontend callback responsible for generating the OTP. Let the server-side payment confirmation service own the entire process:
Razorpay payment↓Signature verification↓Amount/order verification↓Transaction locked↓Transaction = PAID↓Create OTP exactly once↓Commit DB transaction↓Queue SMS
Then the Razorpay webhook can independently reconcile the transaction without accidentally sending another OTP.
If you tell me your Laravel version (10/11/12), whether you're using login/authentication, and which SMS provider (MSG91, Twilio, Fast2SMS, etc.), I can adapt this into the exact production structure for your project, including the SMS API implementation and rate-limited resend-OTP endpoint.