When creating SAAS applications and sometimes an E-commerce, recurring payments or subscriptions is one of the crucial parts for the business logic.
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 aclient_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 theclient_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 thetoggle
method
The current form will look like this:
If you try to start with show: true
in the script, you will see the button is diabled and in a loading state.
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
Show 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 samePaymentIntent
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:
- How would you prevent the customer's card from be recharged when they cancel their subscription?
- What if the user upgrades their subscription to a more expensive plan? Or rather downgrades to a lower?
- How would you handle a card that might be declined?
These are all topics for another day around handling jobs.
Happy coding.
Join to participate in the discussion