PayPal Instant Payment Notification (IPN) Handling with Laravel

This is a follow-up article from my previous post A guide to Integrate Omnipay PayPal with Laravel, at the end of that article I have mentioned that I will be writing about handling PayPal Instant Payment Notification (IPN) with Laravel. So finally it is published, so you can go through it.

Below, I am going to show in detail about the way of handling the webhook notification that comes from PayPal into the Laravel application.

If you are a beginner level developer I highly recommend you to read this official doc to understand the flow of PayPal Instant Payment Notification.

Why handling IPN is necessary?

Let's say a customer at your site is trying to pay for certain service or goods, your application takes the user to PayPal to complete the payment, the user makes payment, it is completed but due to some technical error the payment completed page from PayPal couldn't return to your application to handle completed orders. In that case, your application may fail to send emails, update the database for recently paid order status and its payment status.

To handle this situation PayPal gives a webhook service that sends POST request with a payload about the payment status.

Let's get started :)

Before diving into the code first let's understand how to set up a notification URL that PayPal actually uses to send a POST request to your web application. There are actually two different ways to setup IPN notification URL.

  • You can set up a static end that never changes and that is always ready to handle the PayPal webhook that may arrive at any time into your application. Follow the below steps from this official doc.
null
  • Another way is that we can send a dynamic notify URL with a payment request implicitly from the code, that way we don't need to worry about the setting up notify URL under PayPal > My Account. Below I will show how to send a notify URL with the payment request.

In the previous article, you can see how to send the notify URL with the payment request, I have made the notify URL to be dynamic with parameters like order_id, environment, so the URL changes for each order.

    $paypal = new PayPal;

    $response = $paypal->complete([
        'amount' => $paypal->formatAmount($order->amount),
        'transactionId' => $order->id,
        'currency' => 'USD',
        'cancelUrl' => $paypal->getCancelUrl($order),
        'returnUrl' => $paypal->getReturnUrl($order),
        'notifyUrl' => $paypal->getNotifyUrl($order),
    ]);

    //route for generating notify URL

    /**
     * @param $order
     */
    public function getNotifyUrl($order)
    {
        $env = config('paypal.credentials.sandbox') ? "sandbox" : "live";

        return route('webhook.paypal.ipn', [$order->id, $env]);
    }

To understand this article, I recommend you to read the previous post about integrating PayPal with Laravel.

Let's start diving into the code.

Installation

composer require sudiptpa/paypal-ipn

If you are using Laravel v4 stick with 1.0.x-dev in your composer.json file.

{
    "require": {
        "sudiptpa/paypal-ipn": "1.0.x-dev",
    }
}

Setup

Defining a route to handle POST request coming from PayPal server.

Route::post('/webhook/paypal/{order?}/{env?}', [
    'name' => 'PayPal Express IPN',
    'as' => 'webhook.paypal.ipn',
    'uses' => 'PayPalController@webhook',
]);

After creating a route to accept incoming POST request via PayPal, you need to keep in mind that Laravel by default filters each HTTP requests entering the application with VerifyCsrfToken middleware. In order to allow the above route to access the application the URL should be excluded from the csrf check.

I have previously covered about it with another article "Disabling CSRF on Specific Route via Middleware", so you can go throgh it to understand how to do that.

Let's create a migration for storing IPN records in the database.

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateTablePaypalIpnRecords extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('paypal_ipn_records', function (Blueprint $table) {
            $table->increments('id');
            $table->integer('order_id')->nullable();
            $table->string('verified');
            $table->string('transaction_id');
            $table->string('payment_status');
            $table->string('request_method')->nullable();
            $table->string('request_url')->nullable();
            $table->longText('request_headers')->nullable();
            $table->longText('payload')->nullable();
            $table->timestamps();
            $table->softDeletes();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('paypal_ipn_records');
    }
}

A new model PayPalIPN.php for storing the IPN logs into database.

<?php

namespace App;

use App\Order;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;

/**
 * Class PayPalIPN
 * @package App
 */
class PayPalIPN extends Model
{
    use SoftDeletes;

    const COMPLETED = "Completed";
    const IPN_FAILURE = "FALIURE";
    const IPN_INVALID = "INVALID";
    const IPN_VERIFIED = "VERIFIED";

    /**
     * @var boolean
     */
    public $timestamps = true;

    /**
     * @var array
     */
    protected $dates = ['deleted_at'];

    /**
     * @var array
     */
    protected $fillable = ['order_id', 'verified', 'transaction_id', 'payment_status', 'request_method', 'request_url', 'request_headers', 'payload'];

    /**
     * @var string
     */
    protected $table = 'paypal_ipn_records';

    /**
     * @return boolena
     */
    public function isCompleted()
    {
        return in_array($this->payment_status, [self::COMPLETED]);
    }

    /**
     * @return boolena
     */
    public function isVerified()
    {
        return in_array($this->verified, [self::IPN_VERIFIED]);
    }

    /**
     * @return mixed
     */
    public function orders()
    {
        return $this->belongsTo(Order::class);
    }
}

Adding few new methods in Order.php model for easy condition check and model scope to perform database operation.

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;

/**
 * Class Order
 * @package App
 */
class Order extends Model
{
    use SoftDeletes;

    const COMPLETED = 1;
    const PENDING = 0;

    /**
     * @var string
     */
    protected $table = 'orders';

    /**
     * @var array
     */
    protected $dates = ['deleted_at'];

    /**
     * @var array
     */
    protected $fillable = ['transaction_id', 'amount', 'payment_status'];

    /**
     * @param Builder $query
     * @param string $transaction_id
     * @return mixed
     */
    public function scopeFindByTransactionId($query, $transaction_id)
    {
        return $query->where('transaction_id', $transaction_id);
    }

    /**
     * Payment completed.
     *
     * @return boolean
     */
    public function paid()
    {
        return in_array($this->payment_status, [self::COMPLETED]);
    }

    /**
     * Payment is still pending.
     *
     * @return boolean
     */
    public function unpaid()
    {
        return in_array($this->payment_status, [self::PENDING]);
    }
}

Now creating a webhook() method on PayPalController.php

<?php

namespace App\Http\Controllers;

use App\Order;
use App\PayPal;
use App\Repositories\IPNRepository;
use Illuminate\Http\Request;
use PayPal\IPN\Listener\Http\ArrayListener;

/**
 * Class PayPalController
 * @package App\Http\Controllers
 */
class PayPalController extends Controller
{
    /**
     * @param IPNRepository $repository
     */
    public function __construct(IPNRepository $repository)
    {
        $this->repository = $repository;
    }

    /**
     * @param $order_id
     * @param $env
     * @param Request $request
     */
    public function webhook($order_id, $env, Request $request)
    {
        $listener = new ArrayListener;

        if ($env == 'sandbox') {
            $listener->useSandbox();
        }

        $listener->setData($request->all());

        $listener = $listener->run();

        $listener->onInvalid(function (IPNInvalid $event) use ($order_id) {
            $this->repository->handle($event, PayPalIPN::IPN_INVALID, $order_id);
        });

        $listener->onVerified(function (IPNVerified $event) use ($order_id) {
            $this->repository->handle($event, PayPalIPN::IPN_VERIFIED, $order_id);
        });

        $listener->onVerificationFailure(function (IPNVerificationFailure $event) use ($order_id) {
            $this->repository->handle($event, PayPalIPN::IPN_FAILURE, $order_id);
        });

        $listener->listen();
    }
}

Again, creating another class IPNRepository.php that act as a bridge between model and controller.

<?php
namespace App\Repositories;

use App\Order;
use App\PayPalIPN;
use Illuminate\Http\Request;

/**
 * Class IPNRepository
 * @package App\Repositories
 */
class IPNRepository
{
    /**
     * @param Request $request
     */
    public function __construct(Request $request)
    {
        $this->request = $request;
    }

    /**
     * @param $event
     * @param $verified
     * @param $order_id
     */
    public function handle($event, $verified, $order_id)
    {
        $object = $event->getMessage();

        if (is_numeric($order_id)) {
            $order = Order::find($order_id);
        }

        if (empty($order)) {
            $order = Order::findByTransactionId(
                $object->get('txn_id')
            )->first();
        }

        $paypal = PayPalIPN::create([
            'verified' => $verified,
            'transaction_id' => $object->get('txn_id'),
            'order_id' => $order ? $order->id : null,
            'payment_status' => $object->get('payment_status'),
            'request_method' => $this->request->method(),
            'request_url' => $this->request->url(),
            'request_headers' => json_encode($this->request->header()),
            'payload' => json_encode($this->request->all()),
        ]);

        if ($paypal->isVerified() && $paypal->isCompleted()) {
            if ($order && $order->unpaid()) {
                $order->update([
                    'payment_status' => $order::COMPLETED,
                ]);

                // notify customer
                // notify order handling staff
                // update database logic
            }
        }
    }
}

Testing

PayPal provides an Instant Payment Notification (IPN) simulator to test your integration.

If you want to test with real sandbox credentials, use your staging server for your application, as PayPal never comes to your local development environment with IPN request.

I personally use Insomnia, to design, and test APIs.

Conclusion

The complete source code is available in the github repository, where I push the real codes I prepare while creating every single tutorial on my blog.

Thanks for reading this post up to the end, if you think this post is worth reading, feel free to share with others, also if you have feedback please post in the comment section below.

Last Updated: Jan 7, 2021

Happy Coding!