To use this site please enable javascript on your browser! Stripe Checkout for Recurring Payments, Subscriptions in Laravel and PHP

Stripe Checkout for Recurring Payments, Subscriptions in Laravel and PHP

by Bryce Andy 07:03 Mar 23 '22

When creating SAAS applications and sometimes an E-commerce, recurring payments or subscriptions is one of the crucial parts for the business logic.

Stripe Card Checkout

Many applications accept credit card payments, and today we will see how we can set up recurring payments or subscriptions with Stripe

Prerequisites

NB: If you want to use Laravel Cashier or Laravel Spark, this is NOT the discussion for those packages.

You are going to need the following:

  • Stripe API keys which will be obtained on your developer dashboard
    • Publishable Key - usually starts with pk_
    • Secret key - usually starts with sk_
  • Your PHP version must have the following PHP extensions installed:
    • ext-curl
    • ext-json
    • ext-mbstring
  • You also need to understand how Laravel queues work to understand the subscription section

In this example we are mainly going to use Laravel, and even if you don't use Laravel you can follow along instructions in plain PHP.

Install the Stripe PHP Library

Assuming you already have a PHP or Laravel project, to install the PHP library that uses the Stripe API, run the following command:

composer require stripe/stripe-php

After the library is downloaded, add the following line at the top of your PHP file where you will perform the integration — skip this step if you are using Laravel:

require_once('vendor/autoload.php');

Setup Stripe Configuration

It is recommended you store the API keys in your .env file . Our .env variables should contain the following:

STRIPE_KEY=pk_YourStripePublishableApiKey
STRIPE_SECRET=sk_YourStripeSecretApiKey
STRIPE_CURRENCY=usd
STRIPE_CURRENCY_SMALLEST_UNIT=100

STRIPE_CURRENCY represents a three-lettered lowercase currency value in valid ISO code, i.e eur, usd, gbp. The currency must also be supported by Stripe.

STRIPE_CURRENCY_SMALLEST_UNIT is the value of one unit of that currency in its smallest currency value. For example, if we are paying in US dollars, one unit in USD is $1 and its smallest value is in cents that will give a value of 100.

We need this configuration because Stripe accpets values in the smallest unit. Zero-decimal currencies such as JPY, UGX, etc will have a STRIPE_CURRENCY_SMALLEST_UNIT of 1.

Accessing Configuration Values in Laravel

To access the four config values, we should add an array entry of stripe in the config/services.php file:

// ...
'stripe' => [
    'key' => env('STRIPE_KEY'),
    'secret' => env('STRIPE_SECRET'),
    'currency' => env('STRIPE_CURRENCY'),
    'currency_smallest_unit' => env('STRIPE_CURRENCY_SMALLEST_UNIT'),
],

Then when we need any of these values we can use the config helper:

$currency = config('services.stripe.currency');

Create an Interface for Billing Payments

We will need to create an interface to easily abstract our payment mechanism.

Create a BillableContract.php file in the app/Interfaces/Payment directory:

<?php

namespace App\Interfaces\Payment;

use App\Models\Order;

interface BillableContract
{
    /**
     * Charge the customer using the payment gateway
     *
     * @param  Order  $order
     *
     * @return string
     */
    public function checkout(Order $order);
}

Our interface will have a checkout method that accepts an order object that will be charged. You can replace order with any object of your choice.

When deciding on the object to put in the argument, we want to make sure it will access at-least the amount to be charged and an email of the charged user.

Usually when checking out with most payment gateways they return a string of the checkout URL but with Stripe we will receive a different type of string.

Implementing the Stripe Checkout

To use the interface above, we will create a Stripe.php class that implements the interface. This is where we perform our integration.

Create this new class in the directory app/Integrations/Payment:

<?php

namespace App\Integrations\Payment;

use App\Interfaces\Payment\BillableContract;
use App\Models\Order;

class Stripe implements BillableContract
{
    public function checkout(Order $order): string
    {
        // To do integration here...
    }
}

Card Checkout Stripe API Integration

The card checkout process will involve two (2) steps:

  • Using the STRIPE_SECRET (secret key) we will obtain a client_secret string from the backend and send it to the front end.

This client_secret is a property of a Stripe's PaymentIntent that we will create, and it will contain the details of the customer, the payment details, methods, and everything that's associated with the payment.

  • The final step is where we display the card form (as seen on the first image) in the frontend. To do this we will use the STRIPE_KEY (publishable key) and the client_secret.

The form can be customized with a theme and colors we want using the Stripe Appearance API. Then we send the card data to Stripe and wait for a positive response or show errors.

Create a Stripe PaymentIntent and Return its client_secret

Every Stripe PaymentIntent object has to be associated with a Stripe Customer object. We can't create a PaymentIntent without an ID of the Stripe Customer object.

use App\Models\Order;
use Stripe\Exception\ApiErrorException;
use Stripe\StripeClient;

/**
 * @throws ApiErrorException
 */
public function checkout(Order $order): string
{
    $customerData = ['email' => $order->email];

    // Check if a Stripe customer with the email exists, otherwise create one
    $customers = (new StripeClient(config('services.stripe.secret')))->customers;

    $customerId = ! $customers->all($customerData)->count()
        ? $customers->create($customerData)->id
        : $customers->all($customerData)->first()->id;

    // Obtain a client_secret from the customer's PaymentIntent
    $paymentIntent = (new StripeClient(config('services.stripe.secret')))
        ->paymentIntents
        ->create([
            'customer' => $customerId,
            'setup_future_usage' => 'off_session',
            'amount' => $order->amount * config('services.stripe.currency_smallest_unit'),
            'currency' => config('services.stripe.currency'),
            'metadata' => [
                'order_id' => $order->id,
            ],
            'payment_method_types' => [
                'card',
            ],
            'description' => 'Pament for ' . $order->products->first()->name,
        ]);

    return $paymentIntent->client_secret;
}

Note that we have associated the PaymentIntent with the order using the metadata property.

The most important property here setup_future_usage is set to off_session, where we will be able to charge the user's card when they are offline for subscriptions and recurring payments. Make sure the user is aware of this and put the details on your terms of usage.

After creating the client_secret, we want to fetch it from a controller and send it to a view. Assume we were creating a new order in the OrderController and we wanted to pay straight away:

// routes/web.php
use App\Http\Controllers\OrderController;

Route::post('orders', [OrderController::class, 'store'];

// app/Http/Controllers/OrderController.php
public function store()
{
    $order = Order::create([...]);

    // Fetch the client secret...
}

In order to use the Stripe.php Billable class, we need to tell Laravel that we require it as an implementation of the BillableContract interface.

To do that, open the app/Providers/AppServiceProvider.php and bind the Stripe class in the register method:

use App\Integrations\Payment\Stripe;
use App\Interfaces\Payment\BillableContract;

public function register()
{
    $this->app->bind(BillableContract::class, Stripe::class);
}

Then in the OrderController:

use App\Interfaces\Payment\BillableContract;
use App\Models\Order;

public function store()
{
    $order = Order::create([...]);

    // Fetch the client secret from Stripe.php...
    $clientSecret = app()->make(BillableContract::class)->checkout($order);

    // Send it to the frontend view
    return view('stripe_checkout', compact('clientSecret'));
}

All that's left is to display the card payment form.

Display the Card Payment Form and Collect Payments

The final step is to display the card payment form to the user and collect card details for payment.

Let's first create the stripe_checkout view. We are going to use Tailwind CSS and Alpine Js in the frontend. Add a stripe_checkout.blade.php file in the resources/views directory:

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
    <head>
        <meta charset="utf-8">
        <meta
            name="viewport"
            content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"
        />
        <!-- Tailwind CSS CDN -->
        <script src="https://cdn.tailwindcss.com"></script>
        <!-- Alpine Js & Stripe Js-->
        <script src="//unpkg.com/alpinejs" defer></script>
        <script src="https://js.stripe.com/v3/"></script>
    </head>
    <body class="antialiased max-w-7xl mx-auto">
        <div class="min-h-[calc(100vh-64px)]">
            <!-- Header -->
            <header class="bg-white border-b">
                <div class="mx-auto py-6 px-4 sm:px-6 lg:px-8">
                    <h1 class="font-bold text-xl text-slate-800 leading-tight">
                      Stripe order checkout
                     </h1>
                </div>
            </header>
            <!-- Main content -->
            <main class="mx-auto">
              <!-- Display form here -->
            </main>
        </div>
        <!-- Footer -->
        <footer class="border-t"></footer>
        <!-- Javascript -->
    </body>
</html>

Note: The CDN link I have included for Tailwind CSS in the head CANNOT be used in production. Follow instructions on installing Tailwind CSS in Laravel if you haven't already.

We are going to put the following form in the main content:

<!-- Main content -->
<main class="mx-auto">
    <form
        id="payment-form"
        class="my-12 w-5/6 md:max-w-screen-2xl mx-auto p-4 flex flex-col justify-center items-center space-y-4"
        x-data
    >
        <div id="payment-element"></div>
        <button
            id="submit-button"
            type="submit"
            class="tracking-widest focus:outline-none transition ease-in-out duration-150 px-4 py-2 rounded-full
            border font-semibold bg-orange-500 border-transparent text-white uppercase hover:bg-orange-400
            active:bg-slate-900 disabled:opacity-25 shadow-orange-500/50 shadow-lg flex space-x-3 items-center"
            x-bind:disabled="$store.loader.show"
        >
            <span>Make payment</span>
            <svg
                xmlns="http://www.w3.org/2000/svg"
                viewbox="0 0 24 24"
                class="text-slate-50 animate-spin h-6 w-6"
                x-show="$store.loader.show"
            >
                <path fill="none" d="M0 0h24v24H0z"/>
                <path class="fill-current" d="M18.364 5.636L16.95 7.05A7 7 0 1019 12h2a9 9 0 11-2.636-6.364z"/>
            </svg>
        </button>
        <div id="error-message" class="p-4 text-red-500"></div>
    </form>
</main>

Then in the javascript section, add this script:

<!-- Javascript -->
<script type="text/javascript" id="preloaderStoreJs">
    document.addEventListener('alpine:init', () => {
        Alpine.store('loader', {
            show: false,
            toggle() {
                this.show = ! this.show
            },
        })
    });
</script>

Explanation of the Code

  • The form has three (3) parts
    • a payment-element which will be loaded later with the Stripe Appearance API
    • a submit button for the form containing a loader when the button is disabled
    • an error section which we will display error messages
  • The Alpine Js script controls the visibility of the loader using the show state and the toggle method

The current form will look like this:

Form button enabled

If you try to start with show: true in the script, you will see the button is diabled and in a loading state.

Form button disabled

Loading the payment element from Stripe

To load Stripe's payment element add another script:

<script type="text/javascript" id="checkoutJs">
    const stripe = Stripe('{{ config("services.stripe.key") }}');
    const options = {
        clientSecret: '{{ $clientSecret }}',
        appearance: {
        // Customize to the theme you prefer https://stripe.com/docs/stripe-js/appearance-api
            theme: 'flat',
            variables: {
                colorPrimary: '#fb9736',
            },
        },
    };
    const elements = stripe.elements(options);
    const paymentElement = elements.create('payment');

    paymentElement.mount('#payment-element');

    // Submit payment
    const form = document.getElementById('payment-form');
    form.addEventListener('submit', async (event) => {
        event.preventDefault();

        // Disable the button
        Alpine.store('loader').toggle();

        // Fetch the error when making the payment incase it occurs
        const {error} = await stripe.confirmPayment({
            elements,
            confirmParams: {
                return_url: '{{ route('stripe-checkout.callback') }}',
            },
        });

        if (error) {
            // Enable the button
            Alpine.store('loader').toggle();

            // Display the error in the error container
            const messageContainer = document.querySelector('#error-message');
            messageContainer.textContent = error.message;
        }
    });
</script>

The code is self explanatory. But note, we have sent a return_url when making the payment. Therefore, we need to create this route:

// routes/web.php
use App\Http\Controllers\StripeCallbackController;

Route::get('stripe-checkout/callback', StripeCallbackController::class)
    ->name('stripe-checkout.callback');

The customer will now be able to see the payment form and make their payment. If an error occurs during payment, it will be shown in the section below the form

Card Declined ErrorShow the Payment Status

We created a StripeCallbackController so that the user may see a different page after their payment was made.

Because we set the return_url and its action is that of the StripeCallbackController, Stripe will send two parameters to this route controller:

  • The payment_intent ID, which is of the same PaymentIntent we created on the backend,
  • The client_secret we previously sent to the frontend to create the payment element

We are going to use only the payment_intent ID to query the PaymentIntent object in order to obtain the status of the payment: 

use Stripe\Exception\ApiErrorException;
use Stripe\PaymentIntent;
use Stripe\StripeClient;

class StripeCallbackController extends Controller
{
    /**
     * @throws ApiErrorException
     */
    public function __invoke()
    {
        // Query the PaymentIntent object
        $paymentIntent = (new StripeClient(config('services.stripe.secret')))
            ->paymentIntents
            ->retrieve(request('payment_intent'));

        if (
        ! collect([PaymentIntent::STATUS_PROCESSING, PaymentIntent::STATUS_SUCCEEDED])
            ->contains($paymentIntent->status)
        ) {
            // If the payment was unsuccessful, mark the order status as failed
        }
        if ($paymentIntent->status === PaymentIntent::STATUS_SUCCEEDED) {
            // Mark the order to be successful
        }
        // Order can be obtained from the intent metadata $order = Order::find($paymentIntent->metadata->order_id)

        // Select a message to match the payment status
        $message = match ($paymentIntent->status) {
            PaymentIntent::STATUS_SUCCEEDED => "Thank you! Your payment has been received.",
            PaymentIntent::STATUS_PROCESSING => "Payment processing. We'll update you when payment is received.",
            default => "Payment failed. Please try again later.",
        };

        // Send customer to a thank you page with the message
        return view('thank_you', compact('message'));
    }
}

As the customer is sent to the thank you page, we should create that view and display the message.

Just make sure the message variable is visible in that view:

// resources/views/thank_you.blade.php

<body>
    {{ $message }}
</body>

Note: The PHP function used in the controller (match()) is from PHP 8 only. If you are using a lower version of PHP you can change to use a switch statement.

Subscriptions / Recurring Card Payments with Stripe

We've already seen how we can make card payments with the user on_session on the card payment form.

To make off_session payments, we need to use the same PaymentIntent we created because we set :

'setup_future_usage' => 'off_session',

This means after making the card payment on the form, our payment method can be re-used later to make payments off_session, when the user is not interacting with our application.

It will make sense to fetch the PaymentIntent object after the first successful payment, then we create a Laravel job to charge the user again after a period of time (subscription).

Create the job first:

php artisan make:job ChargeUserSubscription

Back to the StripeCallbackController, dispatch the new ChargeUserSubscription job after a month when the first payment is a success:

use App\Enums\OrderStatus;
use App\Jobs\ChargeUserSubscription;
use Stripe\PaymentIntent;

public function __invoke()
{
    // $paymentIntent ...

    // Check if the payment was successful
    if ($paymentIntent->status === PaymentIntent::STATUS_SUCCEEDED) {
        ChargeUserSubscription::dispatch($paymentIntent)
            ->delay(now()->addDays(30));
    }

    // $message ...
}

Define the job

Now we have to perform the off session payment when the job is processed after 30 days. In the app/Jobs/ChargeUserSubscription.php file, let's add the logic:

<?php

namespace App\Jobs;
// ...
use App\Models\Order;
use Stripe\Exception\ApiErrorException;
use Stripe\PaymentIntent;
use Stripe\StripeClient;

class ChargeUserSubscription implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        public PaymentIntent $intent,
    ){}

    /**
     * Execute the job.
     *
     * @return void
     * @throws ApiErrorException
     */
    public function handle()
    {
        $order = Order::find($this->intent->metadata->order_id);

        $paymentIntent = (new StripeClient(config('services.stripe.secret')))
            ->paymentIntents
            ->create([
                'amount' => $order->amount * config('services.stripe.currency_smallest_unit'),
                'currency' => config('services.stripe.currency'),
                'customer' => $this->intent->customer,
                'payment_method' => $this->intent->payment_method,
                'off_session' => true,
                'confirm' => true,
                'description' => 'Subscription payment for order ' .  $order->id,
            ]);

        if ($paymentIntent->status === PaymentIntent::STATUS_SUCCEEDED) {
            // Our subscription was charged successfully
            // Charge again subscription after 30 days?
            ChargeUserSubscription::dispatch($this->intent)->delay(now()->addDays(30));
        }
    }
}

That is all you need to make the payment recurring every month after it has been successful.

Note how the two parameters off_session and confirm make the difference.

Conclusion

You now have your subscriptions working how you want. However there are some caveats to consider:

  1. How would you prevent the customer's card from be recharged when they cancel their subscription?
  2. What if the user upgrades their subscription to a more expensive plan? Or rather downgrades to a lower?
  3. How would you handle a card that might be declined?

These are all topics for another day around handling jobs.

Happy coding.

1
Updated 09:03 Mar 27 '22

If you like this content, please consider buying me coffee.
Thank you for your support!

Become a Patron!

Join to participate in the discussion

Romil Tatariya Avatar Romil Tatariya 01:12 Dec 18 '23
Hi, I have followed all the steps that you provided but can you please share the database value that I have to add in the order table?