How to Integrate PhonePe Payment Gateway v2 in Laravel with Dynamic Payments
This guide provides a step-by-step tutorial for integrating PhonePe Payment Gateway v2 into a Laravel application using the API endpoint https://api.phonepe.com/apis/pg/checkout/v2/pay
for payment initiation and https://api.phonepe.com/apis/pg/checkout/v2/order/{merchantOrderId}/status
for status verification. It uses a Payment
model for tracking payments, a ProductDetail
model for pricing details, and an HTML <form>
with POST method for payment initiation. The Blade views are optimized for SEO with semantic HTML, meta tags, Open Graph tags, and schema markup to enhance search engine visibility.
Prerequisites
Before starting, ensure you have:
- A Laravel project (version 8 or higher recommended).
- A PhonePe merchant account with
client_id
, client_secret
, merchant_id
, and sandbox credentials. - Composer installed for managing dependencies.
- A database with
payments
and product_details
tables (see Step 2). - Basic knowledge of Laravel, PHP, HTML, 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
Update your .env
file to include PhonePe credentials for both sandbox and production environments:
APP_ENV=local
PHONEPE_SANDBOX_CLIENT_ID=YOUR_SANDBOX_CLIENT_ID
PHONEPE_SANDBOX_CLIENT_SECRET=YOUR_SANDBOX_SECRET
PHONEPE_SANDBOX_MERCHANT_ID=YOUR_SANDBOX_MERCHANT_ID
PHONEPE_PRODUCTION_CLIENT_ID=YOUR_PRODUCTION_CLIENT_ID
PHONEPE_PRODUCTION_CLIENT_SECRET=YOUR_PRODUCTION_SECRET
PHONEPE_PRODUCTION_MERCHANT_ID=YOUR_PRODUCTION_MERCHANT_ID
PHONEPE_CLIENT_VERSION=1
PHONEPE_REDIRECT_URL=https://yourdomain.com/payment-success
Replace YOUR_SANDBOX_CLIENT_ID
, YOUR_SANDBOX_SECRET
, YOUR_SANDBOX_MERCHANT_ID
, YOUR_PRODUCTION_CLIENT_ID
, YOUR_PRODUCTION_SECRET
, and YOUR_PRODUCTION_MERCHANT_ID
with your PhonePe credentials. Set PHONEPE_REDIRECT_URL
to your success route.
Create Configuration File
Create a new configuration file config/phonepe.php
:
<?php
return [
'environment' => env('APP_ENV', 'local'),
'sandbox' => [
'client_id' => env('PHONEPE_SANDBOX_CLIENT_ID'),
'client_secret' => env('PHONEPE_SANDBOX_CLIENT_SECRET'),
'merchant_id' => env('PHONEPE_SANDBOX_MERCHANT_ID'),
'api_url' => 'https://api-preprod.phonepe.com',
'payment_url' => 'https://api.phonepe.com/apis/pg/checkout/v2/pay',
'status_url' => 'https://api.phonepe.com/apis/pg/checkout/v2/order',
],
'production' => [
'client_id' => env('PHONEPE_PRODUCTION_CLIENT_ID'),
'client_secret' => env('PHONEPE_PRODUCTION_CLIENT_SECRET'),
'merchant_id' => env('PHONEPE_PRODUCTION_MERCHANT_ID'),
'api_url' => 'https://api.phonepe.com',
'payment_url' => 'https://api.phonepe.com/apis/pg/checkout/v2/pay',
'status_url' => 'https://api.phonepe.com/apis/pg/checkout/v2/order',
],
'client_version' => env('PHONEPE_CLIENT_VERSION', '1'),
'redirect_url' => env('PHONEPE_REDIRECT_URL'),
];
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->string('product_name')->nullable(); // Added for SEO
$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', 'product_name'];
}
Step 3: Create the Payment Controller
The PaymentController
handles token generation, payment initiation via an HTML POST form using the https://api.phonepe.com/apis/pg/checkout/v2/pay
endpoint, and status verification with the https://api.phonepe.com/apis/pg/checkout/v2/order/{merchantOrderId}/status
endpoint.
<?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 getConfig()
{
$env = config('phonepe.environment', 'local');
return config('phonepe.' . ($env === 'production' ? 'production' : 'sandbox'));
}
private function getAccessToken()
{
$config = $this->getConfig();
$tokenResponse = Http::asForm()->post($config['api_url'] . '/apis/identity-manager/v1/oauth/token', [
'client_id' => $config['client_id'],
'client_version' => config('phonepe.client_version', '1'),
'client_secret' => $config['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);
}
$config = $this->getConfig();
$merchantOrderId = Str::uuid()->toString();
$payload = [
'merchantOrderId' => $merchantOrderId,
'amount' => $payment->amount * 100, // Convert to paise
'expireAfter' => 300,
'metaInfo' => [
'udf1' => 'test1',
'udf2' => 'new param2',
],
'paymentFlow' => [
'type' => 'PG_CHECKOUT',
'message' => 'Please complete your payment',
'merchantUrls' => [
'redirectUrl' => config('phonepe.redirect_url'),
],
],
];
$payResponse = Http::withHeaders([
'Content-Type' => 'application/json',
'Authorization' => 'O-Bearer ' . $tokenData['access_token'],
])->post($config['payment_url'], $payload);
$paymentData = $payResponse->json();
if ($payResponse->successful() && isset($paymentData['redirectUrl'])) {
$payment->update([
'order_id' => $paymentData['orderId'],
'merchant_id' => $config['merchant_id'],
]);
$product = ProductDetail::where('product_id', $payment->product_id)->first();
return view('frontend.payment', [
'paymentUrl' => $paymentData['redirectUrl'],
'merchantOrderId' => $merchantOrderId,
'payment' => $payment,
'mrp_price' => $product->mrp_price ?? 0,
'discount' => $product->discount ?? 0,
'product_name' => $product->product_name ?? 'Product',
]);
}
Log::error('PhonePe payment creation failed', ['response' => $paymentData]);
return response()->json(['error' => 'Failed to create payment'], 500);
}
private function getOrderStatus($merchantOrderId)
{
$config = $this->getConfig();
$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 = $config['status_url'] . '/' . $merchantOrderId . '/status';
$response = Http::withHeaders([
'Content-Type' => 'application/json',
'Authorization' => 'O-Bearer ' . $accessToken,
])->get($url, [
'details' => 'false',
'errorContext' => 'true',
]);
Log::info('PhonePe order status raw response', [
'status' => $response->status(),
'body' => $response->body(),
'url' => $url,
]);
if (!$response->successful()) {
return ['state' => 'FAILED', 'error' => 'API error'];
}
return $response->json();
}
public function paymentSuccess(Request $request)
{
$orderId = $request->input('orderid');
$merchantOrderId = $request->input('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;
$config = $this->getConfig();
Payment::where([
'order_id' => $orderId,
'merchant_id' => $config['merchant_id'],
])->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 with an HTML form. - getOrderStatus: Verifies the payment status using PhonePe’s v2 status API.
- paymentSuccess: Updates the payment record and displays the SEO-optimized success page.
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::post('/payment-success', [PaymentController::class, 'paymentSuccess'])->name('phonepe.success');
Step 5: Create SEO-Optimized Blade Views
Create two Blade views: one for the payment page with an HTML <form>
and one for the success page, both optimized for SEO.
Payment View (frontend/payment.blade.php)
This view uses an HTML <form>
with POST method to redirect to PhonePe’s payment URL, with SEO enhancements.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Securely pay ₹{{ $payment->amount }} for {{ $product_name }} with PhonePe. Enjoy discounts and a seamless checkout experience.">
<meta name="keywords" content="PhonePe payment, {{ $product_name }}, online payment, secure checkout, Laravel payment gateway">
<meta name="robots" content="index, follow">
<meta name="author" content="Your Company Name">
<title>Pay for {{ $product_name }} - Secure PhonePe Checkout</title>
<!-- Open Graph Tags -->
<meta property="og:title" content="Pay for {{ $product_name }} with PhonePe">
<meta property="og:description" content="Complete your payment of ₹{{ $payment->amount }} for {{ $product_name }} using PhonePe's secure gateway.">
<meta property="og:type" content="website">
<meta property="og:url" content="{{ url()->current() }}">
<meta property="og:site_name" content="Your Company Name">
<!-- Schema Markup -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Product",
"name": "{{ $product_name }}",
"description": "Purchase {{ $product_name }} for ₹{{ $payment->amount }} with a ₹{{ $discount }} discount.",
"offers": {
"@type": "Offer",
"priceCurrency": "INR",
"price": "{{ $payment->amount }}",
"availability": "https://schema.org/InStock"
}
}
</script>
</head>
<body>
<header>
<h1>PhonePe Payment for {{ $product_name }}</h1>
</header>
<main>
<section aria-labelledby="payment-details">
<h2 id="payment-details">Payment Details</h2>
<p>Amount: ₹{{ $payment->amount }}</p>
<p>MRP Price: ₹{{ $mrp_price }}</p>
<p>Discount: ₹{{ $discount }}</p>
<form action="{{ $paymentUrl }}" method="POST" id="phonepeForm" aria-label="PhonePe Payment Form">
@csrf
<button type="submit" aria-label="Proceed to pay ₹{{ $payment->amount }} with PhonePe">Pay Now</button>
</form>
</section>
</main>
<footer>
<p>© {{ date('Y') }} Your Company Name. All rights reserved.</p>
</footer>
</body>
</html>
Success View (frontend/success.blade.php)
This view displays the payment outcome with SEO enhancements.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Payment status for order {{ $orderId }}. View transaction details for your PhonePe payment.">
<meta name="keywords" content="PhonePe payment status, transaction details, order confirmation, {{ $orderId }}">
<meta name="robots" content="noindex, follow">
<meta name="author" content="Your Company Name">
<title>Payment Status for Order {{ $orderId }} - PhonePe</title>
<!-- Open Graph Tags -->
<meta property="og:title" content="Payment Status for Order {{ $orderId }}">
<meta property="og:description" content="View the status of your PhonePe payment for order {{ $orderId }}.">
<meta property="og:type" content="website">
<meta property="og:url" content="{{ url()->current() }}">
<meta property="og:site_name" content="Your Company Name">
<!-- Schema Markup -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Order",
"orderNumber": "{{ $orderId }}",
"orderStatus": "{{ $status }}",
"price": "{{ $amount }}",
"priceCurrency": "INR",
"paymentMethod": "{{ $payment_mode ?? 'PhonePe' }}"
}
</script>
</head>
<body>
<header>
<h1>Payment Status for Order {{ $orderId }}</h1>
</header>
<main>
<section aria-labelledby="payment-status">
<h2 id="payment-status">Payment Details</h2>
<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
</section>
</main>
<footer>
<p>© {{ date('Y') }} Your Company Name. All rights reserved.</p>
</footer>
</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
, amount
, and product_id
. - Create a related
ProductDetail
record with product_id
, mrp_price
, discount
, and product_name
. - Run the server:
php artisan serve
. - Test in local environment:
- Set
APP_ENV=local
in .env
. - Navigate to
http://localhost:8000/pay/{payID}
(replace {payID}
with a valid pay_id
). - Click "Pay Now" to submit the form and redirect to PhonePe’s sandbox payment page.
- Test in production environment:
- Set
APP_ENV=production
in .env
. - Deploy to an HTTPS-enabled server.
- Navigate to
/pay/{payID}
and verify the form redirects to PhonePe’s production payment page.
- 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
to prevent injection vulnerabilities. - Secure Credentials: Store
client_id
, client_secret
, and merchant_id
in .env
. - CSRF Protection: The
@csrf
token secures the POST form. - 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
PHONEPE_SANDBOX_CLIENT_ID
, PHONEPE_SANDBOX_CLIENT_SECRET
, PHONEPE_SANDBOX_MERCHANT_ID
, PHONEPE_PRODUCTION_CLIENT_ID
, PHONEPE_PRODUCTION_CLIENT_SECRET
, and PHONEPE_PRODUCTION_MERCHANT_ID
in .env
. - Payment Not Found: Ensure a valid
pay_id
exists in the payments
table. - Redirect URL Issues: Verify the
redirectUrl
in the API response and ensure PHONEPE_REDIRECT_URL
is accessible. - Status API Failure: Check the status endpoint and log details in
storage/logs/laravel.log
.
Conclusion
This guide provides a robust integration of PhonePe Payment Gateway v2 in Laravel using the https://api.phonepe.com/apis/pg/checkout/v2/pay
endpoint, with an HTML <form>
POST method and SEO-optimized views. By leveraging a Payment
model, a ProductDetail
model, and a phonepe.php
config file, you can switch between sandbox and production environments seamlessly. For more details, refer to the PhonePe Developer Documentation.