How to Integrate PhonePe Payment Gateway v2 in Laravel with Dynamic Payments

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.

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

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

  4. 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'),
    ];
    
  5. 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->string('product_name')->nullable(); // Added for SEO
                $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', '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

  1. Create a Payment record in the database with a pay_id, amount, and product_id.
  2. Create a related ProductDetail record with product_id, mrp_price, discount, and product_name.
  3. Run the server: php artisan serve.
  4. 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.
  5. 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.
  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 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.

Munna Patel

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, और बहुत कुछ