How to Integrate PhonePe Payment Gateway v2 in Laravel with Dynamic Payments
Integrating PhonePe Payment Gateway v2 into a Laravel application allows you to process secure online payments with dynamic amounts and verify transaction statuses. This guide provides a step-by-step tutorial on setting up PhonePe v2 in Laravel, using a Payment
model for payment tracking and a ProductDetail
model for pricing details. The implementation includes token generation, payment initiation, and status verification, complete with code examples.
Prerequisites
Before starting, ensure you have:
- A Laravel project (version 8 or higher recommended).
- A PhonePe merchant account with
client_id
, client_secret
, and sandbox credentials. - Composer installed for managing dependencies.
- A database with
payments
and product_details
tables (see Step 2). - Basic knowledge of Laravel, PHP, JavaScript, and Eloquent ORM.
- An HTTPS-enabled server (required by PhonePe in production).
Step 1: Set Up Your Laravel Environment
Create a Laravel Project
If you don’t have a project, create one using Composer:
composer create-project laravel/laravel phonepe-payment
cd phonepe-payment
Install Dependencies
Laravel’s HTTP client is included by default. Optionally, install Guzzle for additional HTTP functionality:
composer require guzzlehttp/guzzle
Configure Environment Variables
Add the following to your .env
file:
PHONEPE_CLIENT_ID=YOUR_CLIENT_ID
PHONEPE_CLIENT_SECRET=YOUR_SECRET_ID
PHONEPE_CLIENT_VERSION=1
PHONEPE_REDIRECT_URL=https://yourdomain.com/payment-success
Replace YOUR_CLIENT_ID
and YOUR_SECRET_ID
with your PhonePe credentials. Set PHONEPE_REDIRECT_URL
to your success route.
Serve the Application
Start the Laravel development server:
php artisan serve
Step 2: Set Up Database and Models
Create two models: Payment
for tracking payments and ProductDetail
for pricing information.
Create Migrations
Run the following to create migrations for the payments
and product_details
tables:
php artisan make:migration create_payments_table
php artisan make:migration create_product_details_table
Update the migration files in database/migrations/
:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreatePaymentsTable extends Migration
{
public function up()
{
Schema::create('payments', function (Blueprint $table) {
$table->id();
$table->string('pay_id')->unique();
$table->unsignedBigInteger('product_id');
$table->decimal('amount', 10, 2);
$table->string('order_id')->nullable();
$table->string('merchant_id')->nullable();
$table->string('status')->nullable();
$table->string('transaction_id')->nullable();
$table->string('payment_mode')->nullable();
$table->string('error_message')->nullable();
$table->json('response_payload')->nullable();
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('payments');
}
}
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateProductDetailsTable extends Migration
{
public function up()
{
Schema::create('product_details', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('product_id');
$table->decimal('mrp_price', 10, 2);
$table->decimal('discount', 10, 2)->nullable();
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('product_details');
}
}
Run the migrations:
php artisan migrate
Create Models
Generate the Payment
and ProductDetail
models:
php artisan make:model Payment
php artisan make:model ProductDetail
Update the models in app/Models/
:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Payment extends Model
{
protected $fillable = [
'pay_id', 'product_id', 'amount', 'order_id', 'merchant_id',
'status', 'transaction_id', 'payment_mode', 'error_message', 'response_payload'
];
}
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ProductDetail extends Model
{
protected $fillable = ['product_id', 'mrp_price', 'discount'];
}
Step 3: Create the Payment Controller
The PaymentController
handles token generation, payment initiation with dynamic amounts, and payment status verification.
<?php
namespace App\Http\Controllers;
use App\Models\Payment;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Http;
use App\Models\ProductDetail;
use Illuminate\Support\Str;
class PaymentController extends Controller
{
private function getAccessToken()
{
$tokenResponse = Http::asForm()->post('https://api.phonepe.com/apis/identity-manager/v1/oauth/token', [
'client_id' => env('PHONEPE_CLIENT_ID'),
'client_version' => env('PHONEPE_CLIENT_VERSION', '1'),
'client_secret' => env('PHONEPE_CLIENT_SECRET'),
'grant_type' => 'client_credentials',
]);
$tokenData = $tokenResponse->json();
if (!$tokenResponse->successful()) {
Log::error('PhonePe token request failed', [
'status' => $tokenResponse->status(),
'body' => $tokenResponse->body(),
'json' => $tokenData,
]);
return ['access_token' => '', 'expires_at' => ''];
}
$accessToken = $tokenData['access_token'] ?? null;
if (!$accessToken) {
Log::error('PhonePe access token missing', ['response' => $tokenData]);
return ['access_token' => '', 'expires_at' => ''];
}
return [
'access_token' => $accessToken,
'expires_at' => $tokenData['expires_at'] ?? '',
];
}
public function initiatePayment($payID)
{
if (empty($payID)) {
return response()->json(['error' => 'Invalid payment ID'], 400);
}
$payment = Payment::where('pay_id', $payID)->first();
if (!$payment) {
return response()->json(['error' => 'Payment not found'], 404);
}
$tokenData = $this->getAccessToken();
if (empty($tokenData['access_token'])) {
return response()->json(['error' => 'Failed to retrieve access token'], 500);
}
$merchantOrderId = Str::uuid()->toString();
$payload = [
'merchantOrderId' => $merchantOrderId,
'amount' => $payment->amount * 100,
'expireAfter' => 300,
'metaInfo' => [
'udf1' => 'test1',
'udf2' => 'new param2',
],
'paymentFlow' => [
'type' => 'PG_CHECKOUT',
'message' => 'Please complete your payment',
'merchantUrls' => [
'redirectUrl' => env('PHONEPE_REDIRECT_URL'),
],
],
];
$payResponse = Http::withHeaders([
'Content-Type' => 'application/json',
'Authorization' => 'O-Bearer ' . $tokenData['access_token'],
])->post('https://api.phonepe.com/apis/pg/checkout/v2/pay', $payload);
$paymentData = $payResponse->json();
if ($payResponse->successful() && isset($paymentData['redirectUrl'])) {
$payment->update([
'order_id' => $paymentData['orderId'],
'merchant_id' => $merchantOrderId,
]);
$product = ProductDetail::where('product_id', $payment->product_id)->first();
return view('frontend.payment', [
'orderId' => $paymentData['orderId'],
'redirectTokenUrl' => $paymentData['redirectUrl'],
'merchantOrderId' => $merchantOrderId,
'payment' => $payment,
'mrp_price' => $product->mrp_price ?? 0,
'discount' => $product->discount ?? 0,
]);
}
Log::error('PhonePe payment creation failed', ['response' => $paymentData]);
return response()->json(['error' => 'Failed to create payment'], 500);
}
private function getOrderStatus($merchantOrderId)
{
$tokenData = $this->getAccessToken();
$accessToken = $tokenData['access_token'];
if (!$accessToken) {
Log::error('PhonePe order status: access token missing');
return ['state' => 'FAILED', 'error' => 'Access token not available'];
}
$url = "https://api.phonepe.com/apis/pg/checkout/v2/order/".$merchantOrderId."/status";
$response = Http::withHeaders([
'Content-Type' => 'application/json',
'Authorization' => 'O-Bearer ' . $accessToken,
])->get($url, [
'details' => 'false',
'errorContext' => 'true',
]);
Log::error('PhonePe order status raw response', [
'status' => $response->status(),
'body' => $response->body(),
'url' => $url,
'access_token' => $accessToken,
]);
if (!$response->successful()) {
return ['state' => 'FAILED', 'error' => 'API error'];
}
return $response->json();
}
public function paymentSuccess(Request $request)
{
$orderId = $request->query('orderid');
$merchantOrderId = $request->query('moid');
$statusResponse = $this->getOrderStatus($merchantOrderId);
$paymentState = $statusResponse['state'] ?? 'UNKNOWN';
$amount = $statusResponse['amount'] ?? 'UNKNOWN';
$details = $statusResponse['paymentDetails'][0] ?? [];
$transactionId = $details['transactionId'] ?? null;
$paymentMode = $details['paymentMode'] ?? null;
$errorCode = $details['errorCode'] ?? null;
$detailedError = $details['detailedErrorCode'] ?? null;
$errorMessage = $errorCode && $detailedError ? "$errorCode - $detailedError" : null;
Payment::where([
'order_id' => $orderId,
'merchant_id' => $merchantOrderId,
])->update([
'status' => $paymentState,
'error_message' => $errorMessage,
'transaction_id' => $transactionId,
'payment_mode' => $paymentMode,
'response_payload' => json_encode($statusResponse),
]);
return view('frontend.success', [
'orderId' => $orderId,
'merchantOrderId' => $merchantOrderId,
'transactionId' => $transactionId,
'amount' => ($amount/100),
'status' => $paymentState,
'error' => $errorMessage,
]);
}
}
Place this file in app/Http/Controllers/PaymentController.php
. The controller includes:
- getAccessToken: Retrieves an OAuth access token using client credentials.
- initiatePayment: Initiates a payment for a given
payID
, fetching the amount from the Payment
model and rendering a payment view. - getOrderStatus: Verifies the payment status using PhonePe’s status API.
- paymentSuccess: Updates the payment record and displays the success page with transaction details.
Step 4: Define Routes
Define routes in routes/web.php
to handle payment initiation and success.
<?php
use App\Http\Controllers\PaymentController;
use Illuminate\Support\Facades\Route;
Route::get('/pay/{payID}', [PaymentController::class, 'initiatePayment'])->name('phonepe.initiate');
Route::get('/payment-success', [PaymentController::class, 'paymentSuccess'])->name('phonepe.success');
Step 5: Create Blade Views
Create two Blade views: one for the payment page and one for the success page.
Payment View (frontend/payment.blade.php)
This view loads the PhonePe JavaScript SDK and initiates the payment in an iframe.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PhonePe Payment</title>
<script src="https://mercury.phonepe.com/web/bundle/checkout.js"></script>
</head>
<body>
<h4>PhonePe Payment Integration</h4>
<p>Amount: ₹{{ $payment->amount }}</p>
<p>MRP Price: ₹{{ $mrp_price }}</p>
<p>Discount: ₹{{ $discount }}</p>
<button id="payButton">Pay Now</button>
<script>
document.getElementById('payButton').addEventListener('click', function () {
var tokenUrl = '{{ $redirectTokenUrl }}';
function paymentCallback(response) {
if (response === 'USER_CANCEL') {
alert('Payment was cancelled by the user.');
} else if (response === 'CONCLUDED') {
alert('Payment done successfully.');
window.location.href = '{{ route("phonepe.success") }}?orderid={{ $orderId }}&moid={{ $merchantOrderId }}';
}
}
if (window.PhonePeCheckout && window.PhonePeCheckout.transact) {
window.PhonePeCheckout.transact({
tokenUrl: tokenUrl,
callback: paymentCallback,
type: 'IFRAME'
});
} else {
alert('PhonePeCheckout is not available.');
}
});
</script>
</body>
</html>
Success View (frontend/success.blade.php)
This view displays the payment outcome with transaction details.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Payment Success</title>
</head>
<body>
<h4>Payment Status</h4>
<p>Order ID: {{ $orderId }}</p>
<p>Merchant Order ID: {{ $merchantOrderId }}</p>
<p>Transaction ID: {{ $transactionId ?? 'N/A' }}</p>
<p>Amount: ₹{{ $amount }}</p>
<p>Status: {{ $status }}</p>
@if($error)
<p>Error: {{ $error }}</p>
@endif
</body>
</html>
Create a frontend
directory in resources/views/
for both payment.blade.php
and success.blade.php
.
Step 6: Test the Integration
- Create a
Payment
record in the database with a pay_id
and amount
. - Create a related
ProductDetail
record with product_id
, mrp_price
, and discount
. - Run the server:
php artisan serve
. - Navigate to
http://localhost:8000/pay/{payID}
(replace {payID}
with a valid pay_id
). - Click "Pay Now" to load the PhonePe payment iframe.
- Complete or cancel the payment to test the callback and status verification.
- Verify the success page at
/payment-success
with transaction details.
Step 7: Security Considerations
- HTTPS: Use HTTPS in production to comply with PhonePe’s requirements.
- Validate Input: Sanitize
payID
and redirectUrl
to prevent injection or open redirect vulnerabilities. - Secure Credentials: Store
client_id
and client_secret
in .env
. - Logging: Avoid logging sensitive data like access tokens in production logs.
- Database Security: Ensure the
payments
table is indexed for performance and encrypted if storing sensitive data.
Step 8: Troubleshooting
- Token Request Failed: Verify
client_id
and client_secret
in .env
. - Payment Not Found: Ensure a valid
pay_id
exists in the payments
table. - PhonePeCheckout Not Available: Check the PhonePe JavaScript SDK URL.
- Status API Failure: Verify the status endpoint and log details in
storage/logs/laravel.log
.
Conclusion
Integrating PhonePe Payment Gateway v2 in Laravel with dynamic amounts and status verification is straightforward with this guide. By using a Payment
model for tracking and a ProductDetail
model for pricing, you can create a robust payment system. The provided code handles token generation, payment initiation, and status updates, ensuring a secure and reliable integration. For more details, refer to the PhonePe Developer Documentation.