Stripe Payment Gateway Integration with Laravel

Before through this blog, I've covered some of the popular payment gateways like PayPal, SecurePay, eSewa, etc.

This article is about the Stripe payment gateway to handle one-time payments with express checkout.

Introduction

Stripe is a payment service provider dealing with online payment processing for businesses around the globe.

Stripe offers different APIs for payment processing, which can be for one-time or subscription-based and recurring payments, and many more.

In this article, we're writing about the steps involved in integrating the express checkout.

Prerequisites

This article is for developers having an experience of mid-level or above with Laravel and PHP, as we won't cover the basics.

So we assume you already have experience with the below points.

  • Having good knowledge of PHP and Laravel, third-party APIs.
  • Understand composer and use packages and libraries with the composer in the Laravel projects.

Setup

We assume you already have a Laravel project installed in your machine, and you are ready to start the stripe integration.

Install official stripe-php-sdk from packagist with composer using the below command.

composer require stripe/stripe-php

Before diving into the code, go to the stripe dashboard and grab the API credentials needed for the integration.

The red border boxes shown in the above picture have the place to reveal the Publishable key and Secret key from the stripe dashboard under the Developers tab.

As of crafting this article, Laravel is at 9.* version.

You can then store the API credentials, depending on how you manage the API credentials via database or put them on the .env file.

Here we are going to use the .env file for this article.

STRIPE_PUBLISHABLE_KEY=YOUR-KEY-HERE
STRIPE_SECRET_KEY=YOUR-SECRET-KEY-HERE

Coding

Let's set up routes in Laravel where the user can access the order and continue through the payment flow.

<?php

use App\Http\Controllers\Payments\StripeController;
use Illuminate\Support\Facades\Route;

Route::get('/checkout/overview', [StripeController::class, 'overview'])
    ->name('checkout.overview');
Route::post('/checkout/payment/{order}/stripe', [StripeController::class, 'payment'])
    ->name('checkout.payment');
Route::get('/checkout/payment/{order}/approved', [StripeController::class, 'approved'])
    ->name('checkout.approved');
Route::get('/checkout/payment/{order}/cancelled', [StripeController::class, 'cancelled'])
    ->name('checkout.cancelled');

Now, we need a controller for handling the incoming request and responses from the route.

<?php
namespace App\Http\Controllers\Payments;

use App\Models\Order;
use App\Payments\Stripe;
use Illuminate\Routing\Controller;

class StripeController extends Controller
{
    public function overview()
    {
        // implement your own order overview here

        $order = Order::query()
            ->whereHas('items')
            ->with(['items'])
            ->paymentPending()
            ->first();

        return view('stripe', ['order' => $order]);
    }

    /**
     * @param $uuid
     */
    public function payment($uuid)
    {
        // implement your own order here

        $order = Order::paymentPending()
            ->where('uuid', $uuid)
            ->firstOrFail();

        return Stripe::initialize($order);
    }

    /**
     * @param $uuid
     */
    public function approved($uuid)
    {
        $order = Order::query()
            ->where('uuid', $uuid)
            ->firstOrFail();

        return Stripe::captured($order);
    }

    /**
     * @param $uuid
     */
    public function cancelled($uuid)
    {
        // render your cancelled page here

        return redirect()->route('checkout.overview')
            ->with('error', 'You have cancelled the payment. Therefore, the order has not been placed yet.');
    }
}

To interact with the database, we need a model. Here we have only created a single Order.php model and used the order items as static values to simplify the integration.

return new class extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('orders', function (Blueprint $table) {
            $table->increments('id');
            $table->uuid();
            $table->string('transaction_id')->nullable();
            $table->float('amount')->unsigned()->nullable();
            $table->integer('payment_status')->unsigned()->default(0);
            $table->timestamps();
            $table->softDeletes();
        });
    }

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

You have to deal with the dynamic order items based on your application architecture.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Order extends Model
{
    use HasFactory;

    const PAYMENT_COMPLETED = 1;
    const PAYMENT_PENDING = 0;

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

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

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

    /**
     * @return \App\Models\OrderItem
     */
    public function items()
    {
        return $this->hasMany(OrderItem::class);
    }

    /**
     * @param $query
     * @return \Illuminate\Database\Query\Builder
     */
    public function scopePaymentPending($query)
    {
        return $query->where("{$this->table}.payment_status", self::PAYMENT_PENDING);
    }
}
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class OrderItem extends Model
{
    use HasFactory;

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

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

    /**
     * @var array
     */
    protected $fillable = ['uuid', 'order_id', 'name', 'sku', 'quantity', 'description', 'amount'];

    /**
     * @return \App\Models\Order
     */
    public function order()
    {
        return $this->belongsTo(Order::class);
    }
}

We've got a simple page to display a continue to the payment form.

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://cdn.tailwindcss.com"></script>
<title>Payment</title>
</head>
<body>
<section class="container mx-auto mt-4 mb-4">
    <form action="{{ route('checkout.payment', [$order->uuid]) }}" method="POST">
        @csrf

        <button type="submit" class="bg-slate-900 text-white text-sm font-semibold h-10 px-6 rounded-md sm:w-auto">Continue to Payment</button>
    </form>
</section>
</body>
</html>

For an e-commerce website, this page needs to be an order overview with order items and payment gateways choices for users.

Finally, we have a dedicated class created to handle the initialization of stripe checkout.

<?php
namespace App\Payments;

use Exception;
use Stripe\Checkout\Session;
use Stripe\Stripe as Checkout;

class Stripe
{
    /**
     * @param $order
     */
    public static function initialize($order)
    {
        $static = new static;

        $checkout = Checkout::setApiKey(env('STRIPE_SECRET_KEY'));

        try {
            $session = Session::create([
                'client_reference_id' => $order->uuid,
                'billing_address_collection' => 'required',
                'line_items' => $static->toLineItems($order),
                'mode' => 'payment',
                'success_url' => $static->toReturnUrl($order),
                'cancel_url' => $static->toCancelUrl($order),
            ]);
        } catch (Exception $e) {
            return redirect()->route('checkout.overview')
                ->with('error', 'Unknown error occurred, please try again later.');
        }

        $order->update(['transaction_id' => $session->id]);

        if (filter_var($session->url, FILTER_VALIDATE_URL)) {
            return redirect()->to($session->url, 303);
        }

        return redirect()->route('checkout.overview')
            ->with('error', 'Unknown error occurred, please try again later.');
    }

    /**
     * @param $order
     */
    public static function captured($order)
    {
        $checkout = Checkout::setApiKey(env('STRIPE_SECRET_KEY'));

        try {
            $session = Session::retrieve($order->transaction_id);
        } catch (Exception $e) {
            return redirect()->route('checkout.overview')
                ->with('error', 'The payment was not successful, please retry again.');
        }

        if ($approved = $session && $session->payment_status == 'paid') {
            $order->update(['payment_status' => $order::PAYMENT_COMPLETED]);

            // take user to order placed page with message

            return redirect()->route('checkout.overview')
                ->with('success', 'Thank you for placing an order, the payment was captured successfully.');
        }

        return redirect()->route('checkout.overview')
            ->with('error', 'The payment was not successful, please retry again.');
    }

    /**
     * @param $order
     * @return array
     */
    public function toLineItems($order)
    {
        $stack = [];

        $static = new static;

        foreach ($order->items as $item) {
            $stack[] = [
                'price_data' => [
                    'currency' => 'USD',
                    'product_data' => [
                        'name' => $item->name,
                    ],
                    'unit_amount' => $static->toCentAmount($item->amount),
                ],
                'quantity' => $item->quantity,
            ];
        }

        return $stack;
    }

    /**
     * @param $order
     */
    public function toCancelUrl($order)
    {
        return route('checkout.cancelled', $order->uuid);
    }

    /**
     * @param $order
     */
    public function toReturnUrl($order)
    {
        return route('checkout.approved', $order->uuid);
    }

    /**
     * @param $amount
     */
    public function toCentAmount($amount)
    {
        return (int) ($amount * 100);

        return number_format($amount, 2, '.', '');
    }
}


The request parameters are validated, and if everything is accurate, the request will be auto redirected to the payment gateway service to handle the actual payment.

After the payment, the user gets redirected back to the merchant website. The response received needs to be validated again to make sure the transaction was fully captured or not.

Webhook

We highly recommend handling the payment events via webhook. The webhook is a legit way to verify the payment status of an order.

The webhook is a far more secure way to verify the payment status of an order because it happens behind the scene on your server, as no user interaction is involved.

Conclusion

Thanks for following the article up to the end. I hope it was helpful to you.

Feel free to share this article on social media if you wish to share it in your circle.