PhonePe Payment V2 Gateway Integration in Laravel Example 1

PhonePe Payment V2 Gateway Integration in Laravel Example 1

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

  1. 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
  2. Install Dependencies
    Laravel’s HTTP client is included by default. Optionally, install Guzzle for additional HTTP functionality:

    composer require guzzlehttp/guzzle
  3. 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.

  4. 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.

  1. 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
  2. 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

  1. Create a Payment record in the database with a pay_id and amount.
  2. Create a related ProductDetail record with product_id, mrp_price, and discount.
  3. Run the server: php artisan serve.
  4. Navigate to http://localhost:8000/pay/{payID} (replace {payID} with a valid pay_id).
  5. Click "Pay Now" to load the PhonePe payment iframe.
  6. Complete or cancel the payment to test the callback and status verification.
  7. 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.

Munna Patel

हाय, मैं एक फुल स्टैक डेवलपर (Full Stack Developer) हूँ, जिसके पास 7 साल का अनुभव (7 Years of Experience) है। मेरा जुनून है वेब डेवलपमेंट (Web Development) और कोडिंग (Coding) को आसान (Easy) और मजेदार बनाना, खासकर हिंदी भाषी ऑडियंस के लिए। मैं InHindi24.com पर हिंदी में टेक ट्यूटोरियल्स (Tech Tutorials in Hindi) शेयर करता हूँ, जिसमें लारवेल (Laravel), HTML, CSS, JavaScript, Python, और बहुत कुछ