Introducing Stripe payment into your PHP projects

TL;DR

Full working code repository here (with cart simulation)
Simple example on how to implement Stripe PaymentIntent (SCA compliant) into your project to enable payment.

Introduction

If you develop websites, SaaS, webapp or pretty much anything else, you've probably needed some payment features at some point for a project.
Most of the time, if you build your project on a CMS you'll get access to contributed project (such as WooCommerce for WordPress or Commerce for Drupal) that will handle payment process for you and you'll just need to provide API keys to enable payments. This is also handled for you if your project is built upon platform like Sylius, PrestaShop or other eCommerce platforms.

But in some case your project isn't built on top of one of the solutions cited just before, so you'll need to implement your own payment system. (Or maybe you're just curious on how to)
There is many payment solutions available on the market (PayPal, Apple Pay, Six Payment, ...) and some projects tries to aggregate these payment gateways, this is the case for Omnipay.

In order to stay simple we'll focus only on one payment solution today which is Stripe that gives you the ability to accept cards payment on your project.

Stripe implementation

First things first, please sign up on Stripe to access to your secret and public keys and make them accessible to your application.

Please DO NOT register your customers cards into your application/database, send card data DIRECTLY to Stripe, NEVER save cards credentials !

Front

You'll need to build a form for your users which will contain any information you'd like and also user's card information.

<form id="payment-form" data-stripe-public-key="<?= STRIPE_PUBLIC_KEY ?>">
    <div class="sr-combo-inputs-row">
        <div class="sr-input sr-card-element" id="card-element"></div>
        <div id="card-errors"></div>
    </div>

    <div class="sr-field-error" id="card-errors" role="alert"></div>

    <button id="submit">
        <div class="spinner hidden" id="spinner"></div>

        <span id="button-text">
            Pay

            <span id="order-amount"><?= $price ?> <?= STRIPE_CURRENCY_SYMBOL ?></span>
        </span>
    </button>
</form>

STRIPE_PUBLIC_KEY represent your public key and STRIPE_CURRENCY_SYMBOL represent the symbol in which currency you'll charge the customer. (for the example let's pretend it's in euros so symbol is )
Also $price contains the amount that will be charged, this should be understandable by your customers. (something like 12,99)

The main element here is #card-element which will be handled by Stripe.js to display nice inputs for your users to fill card informations. For this to work, include Stripe script in your head tag.

<head>
    <!-- ... -->
    <script src='https://js.stripe.com/v3/'></script>
    <!-- ... -->
</head>

Then you'll also need to write some Javascript to mount Stripe card behaviours to your form, create a script.js file and load it at the end of your body tag.
Here's the start of your file: (plain old vanilla javascript only, adapt to your needs / framework)

var stripe = null;
var form = document.getElementById('payment-form');
var formBtn = document.querySelector('button');
var formBtnText = document.querySelector('#button-text');
var formSpinner = document.querySelector('#spinner');
var cardErrorContainer = document.getElementById('card-errors');
var errorContainer = document.querySelector('.sr-field-error');
var resultContainer = document.querySelector('.sr-result');
var preContainer = document.querySelector('pre');

document.addEventListener('DOMContentLoaded', function () {
    var stripePublicKey = form.dataset.stripePublicKey;
    stripe = Stripe(stripePublicKey);
    var card = stripe.elements().create('card', {
        // This hide zipcode field. If you enable it, you can increase card acceptance
        // and also reduce card fraud. But sometime your users don't like to fill it
        // Please adapt to your need
        hidePostalCode: true,
        style: {
            base: {
                color: '#32325d',
                fontFamily: '"Helvetica Neue", Helvetica, sans-serif',
                fontSmoothing: 'antialiased',
                fontSize: '16px',
                '::placeholder': {
                    color: '#aab7c4',
                },
            },
            invalid: {
                color: '#fa755a',
                iconColor: '#fa755a',
            },
        },
    });

    // Bind Stripe card to the DOM
    card.mount('#card-element');

    // Show errors when completing card information
    card.addEventListener('change', function (e) {
        if (e.error) {
            cardErrorContainer.classList.add('show');
            cardErrorContainer.textContent = e.error.message;
        } else {
            cardErrorContainer.classList.remove('show');
            cardErrorContainer.textContent = '';
        }
    });

    // Launch payment on submit
    form.addEventListener('submit', function (event) {
        event.preventDefault();
        pay(stripe, card);
    });
});

It's pretty much initialization, mounting Stripe card behaviours, listening on card informations, and listening on form submission.
The important part is the call to the pay() function, it will start the payment process when you'll submit the form. Here is the pay function:

// Collect card details and pays for the order
var pay = function (stripe, card) {
    changeLoadingState(true);

    stripe
        .createPaymentMethod('card', card)
        .then(function (result) {
            if (result.error) {
                showError(result.error.message);
                throw result.error.message;
            } else {
                return fetch('/pay.php', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                    },
                    body: JSON.stringify({
                        paymentMethodId: result.paymentMethod.id,
                    }),
                });
            }
        })
        .then(function (result) {
            return result.json();
        })
        .then(function (paymentData) {
            // Card needs 2 step validation / 3D secure
            if (paymentData.requiresAction) {
                handleAction(paymentData.clientSecret);
            }
            // Triggered an error from server, we show it
            else if (paymentData.error) {
                showError(paymentData.error);
            }
            // Everything went perfect, payment completed !
            else {
                orderComplete(paymentData.clientSecret);
            }
        })
        .catch(function (error) {
            console.log(error);
        });
};

It handles the whole process payment but also call differents functions to achieve that;

  • changeLoadingState() handle spinner behaviour
  • showError() show error when needed
  • handleAction() manage additional actions if necessary (which will be dicted by the customer card bank)
  • orderComplete() called when the payment is successful and completed
// Show a spinner on payment submission
var changeLoadingState = function (isLoading) {
    if (isLoading) {
        formBtn.disabled = true;
        formSpinner.classList.remove('hidden');
        formBtnText.classList.add('hidden');
    } else {
        formBtn.disabled = false;
        formSpinner.classList.add('hidden');
        formBtnText.classList.remove('hidden');
    }
};

It toggles the spinner to inform the user of pending process.

// Show an error message
var showError = function (errorMsgText) {
    changeLoadingState(false);

    errorContainer.textContent = errorMsgText;

    setTimeout(function () {
        errorContainer.textContent = '';
    }, 4000);
};

Inform the user with the given error message.

// Shows a success message when the payment is complete
var orderComplete = function (clientSecret) {
    stripe
        .retrievePaymentIntent(clientSecret)
        .then(function (result) {
            var paymentIntent = result.paymentIntent;
            var paymentIntentJson = JSON.stringify(paymentIntent, null, 2);

            form.classList.add('hidden');
            preContainer.textContent = paymentIntentJson;
            resultContainer.classList.remove('hidden');

            setTimeout(function () {
                resultContainer.classList.add('expand');
            }, 200);

            changeLoadingState(false);
        });
};

Payment is completed, adapt to your needs.

// Request authentication (used by some cards that require 3D secure payments / additional validation)
var handleAction = function (clientSecret) {
    stripe
        .handleCardAction(clientSecret)
        .then(function (data) {
            if (data.error) {
                showError('Your card was not authenticated, please try again');
            } else if (data.paymentIntent.status === 'requires_confirmation') {
                fetch('/pay.php', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                    },
                    body: JSON.stringify({
                        paymentIntentId: data.paymentIntent.id,
                    }),
                })
                    .then(function (result) {
                        return result.json();
                    })
                    .then(function (response) {
                        if (response.error) {
                            showError(response.error);
                        } else {
                            orderComplete(clientSecret);
                        }
                    });
            }
        });
};

In some cases, user card bank will require additional step in order to validate the transaction, this function handle it (stripe.handleCardAction(clientSecret)) it is represented by an opening popup from Stripe that asks the user to confirm the payment or to confirm it's 2 step validation in order to validate the whole transaction.
This function will hit the same endpoint (/pay.php) of our server to resubmit the payment but this time it will give the paymentIntentId which has been built during the first step of our payment process (when we called the pay() function).

That's it, the only Javascript we'll need to make payments !

Back

So now we'll need to handle requests made to our endpoints, but first, don't forget to install the Stripe PHP SDK.

$ composer require stripe/stripe-php

(At the current time, I use the last version v7.27.0 of the SDK)

And to better organize ourselves and stay simple we'll create a few files:

  • public/pay.php endpoint to receive payment requests
  • src/StripeHelper.php Helper class to handle differents steps of our payment process
  • src/BodyException.php Exception thrown when request $body aren't formatted correctly

So here is the pay.php endpoint:

<?php
# public/pay.php

/**
 * Main file, it handles payment process
 */

use App\BodyException;
use App\StripeHelper;
use Stripe\Stripe;

require __DIR__ . '/../vendor/autoload.php';

// Will return JSON
header('Content-Type: application/json');

// Only accessible by POST method
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(400);

    echo json_encode([
        'error' => 'Invalid request.',
    ]);

    exit;
}

// Begin payment process
try {
    // Set Stripe key
    Stripe::setApiKey(STRIPE_SECRET_KEY);

    // Your cart implementation, it can be anything you want
    $cart = \App\ImaginaryContainer::get('app.cart');

    // Calculate the total amount for the payment intent, once again, implement yours here
    $amount = StripeHelper::calculateAmountFromCart($cart);

    // Build a body object from the request, we'll use it for creating our payment intent
    $body = StripeHelper::buildBodyFromRequest();

    // Build the payment intent
    $intent = StripeHelper::createPaymentIntent($body, $amount);

    // Payment completed, put your logic here
    if (StripeHelper::isPaymentIntentCompleted($intent)) {
        // Register payment in database, redirect user, ...
    }

    // Build the response
    $response = StripeHelper::generateResponse($intent);

    echo json_encode($response);
}
// Could happen if the request wasn't correctly formatted
catch (BodyException $e) {
    http_response_code(400);

    echo json_encode([
        'error' => $e->getMessage(),
    ]);

    exit;
}
// Catch any other error, just in case
catch (Exception $e) {
    echo json_encode([
        'error' => $e->getMessage(),
    ]);
}

We broke down payment process in smaller steps contained in our StripeHelper class (for the tutorial sake, we don't care about SOLID principles) for simplicity purpose, so we can clearly read how the process is executed through our endpoint.

  1. First we can see our endpoint will only return JSON
  2. We allow only POST request
  3. We set our Stripe secret key
  4. We retrieve our cart, it could be anything, an array, an object, or anything you want, this is your own implementation. Stripe don't care about that, Stripe only need a total amount that will be linked to the PaymentIntent to know how much will be charged the customer
  5. We extract the total amount from our cart, once again, this method will need to be overrided to work with your own implementation of your cart. Also notice that the total amount MUST be in cents
  6. We get our body informations from the request (request made by our script.js that gives payment intent data in it's body)
  7. We build our PaymentIntent object from the body and the amount
  8. If the payment intent is fully completed then it's a success ! We can do business logic from here (log the payment, redirect user to order complete page, prepare carrier, ...)
  9. We send back a response to the client

Also notice that we wrapped the whole process in a try/catch block, indeed, during the process many troubles can happen (body from the request wasn't formatted correctly, Stripe API didn't respond to our calls, ...) so we'll catch any of these errors to prevent even more troubles.

I won't cover cart topic because it's not the goal of this post but you can find a simulation of one in the demo example.

See how the steps have been broken in smaller pieces:

# StripeHelper::buildBodyFromRequest()

/**
 * Build the body that will be used by stripe payment intent from the request
 *
 * @return object
 *
 * @throws \App\BodyException
 */
public static function buildBodyFromRequest(): object
{
    $input = file_get_contents('php://input');
    $body = json_decode($input);

    // Check if the body has been correctly decoded.
    if (json_last_error() !== JSON_ERROR_NONE) {
        throw new BodyException('Invalid request.');
    }

    return $body;
}

From that we get an stdClass object which contains either the paymentMethodId key (from pay() function in script.js) or the paymentIntentId key (from handleAction() function in script.js). Of course, it could contains much more if on your front you gave additional keys to the body of your request.

# StripeHelper::createPaymentIntent()

/**
 * Try to build a PaymentIntent object
 *
 * @param object $body
 * @param int $amount
 *
 * @return PaymentIntent
 *
 * @throws Exception Thrown if the payment intent couldn't be create
 */
public static function createPaymentIntent(object $body, int $amount): PaymentIntent
{
    try {
        // Create new PaymentIntent with a PaymentMethod ID from the client.
        if (!empty($body->paymentMethodId)) {
            $intent = PaymentIntent::create([
                'amount' => $amount,
                'currency' => STRIPE_CURRENCY,
                'payment_method' => $body->paymentMethodId,
                'confirmation_method' => 'manual',
                'confirm' => true,
                // This is only used for integration test, you can test them here: https://stripe.com/docs/payments/accept-a-payment#web-test-integration
                // 'metadata' => ['integration_check' => 'accept_a_payment'],
            ]);
            // After create, if the PaymentIntent's status is succeeded, fulfill the order.

            return $intent;
        }
        // Confirm the PaymentIntent to finalize payment after handling a required action on the client.
        else if (!empty($body->paymentIntentId)) {
            $intent = PaymentIntent::retrieve($body->paymentIntentId);
            $intent->confirm();
            // After confirm, if the PaymentIntent's status is succeeded, fulfill the order.

            return $intent;
        }
    }
    // Could happen if the request fail
    catch (ApiErrorException $e) {
        throw new Exception("An error occurred when proccessing payment. Please retry later");
    }

    // If $body has been altered, then maybe wished arguments have been removed
    throw new Exception("An error occurred when proccessing payment. Please retry later");
}

So here we try to build our PaymentIntent object, in the first condition (if (!empty($body->paymentMethodId))) it will create a brand new PaymentIntent object (that would be initiated by the pay() function from front) which will be assigned a unique ID.

In the case that the customer card bank don't need additional validation, all good, the payment will be successful on first request ! But in some case the bank will require the user to confirm the payment (2 step validation) or to secure it (3D secure). Those validations are part of the SCA protocol.

So in the second scenario, our process will tell the front that it needs additional actions from the customer to finish payment process.
It will go through the handleAction() function on our front then resubmit the payment request but this time with the paymentIntentId which have been created in our first step (since it's this payment intent we wanted to confirm).

This second request will go into our second condition (else if (!empty($body->paymentIntentId))) to be retrieved by Stripe and confirmed.

If for some reason this process fail for any reasons then we'll throw exceptions, to prevent the payment flow to continue.

# StripeHelper::generateResponse()

/**
 * Generate correct response array based on payment intent status
 *
 * @param \Stripe\PaymentIntent $intent
 *
 * @return array
 */
public static function generateResponse(PaymentIntent $intent): array
{
    switch ($intent->status) {
        // Card requires authentication
        case PaymentIntent::STATUS_REQUIRES_ACTION:
        case 'requires_source_action':
            return [
                'requiresAction' => true,
                'paymentIntentId' => $intent->id,
                'clientSecret' => $intent->client_secret,
            ];

        // Card was not properly authenticated, suggest a new payment method
        case PaymentIntent::STATUS_REQUIRES_PAYMENT_METHOD:
        case 'requires_source':
            return [
                'error' => "Your card was denied, please provide a new payment method",
            ];

        // Payment is complete, authentication not required
        // To cancel the payment after capture you will need to issue a Refund (https://stripe.com/docs/api/refunds)
        case PaymentIntent::STATUS_SUCCEEDED:
            return [
                'clientSecret' => $intent->client_secret,
            ];

        case PaymentIntent::STATUS_CANCELED:
            return [
              'error' => "The payment has been canceled, please retry later"
            ];

        // If other unexpected case occurs then we return an error
        default:
            return [
                'error' => "An error has occurred, please retry later",
            ];
    }
}

Generate the response returned to the client. Notice that we specify requiresAction if the payment intent status says it needs further action to be completed. This is this key that triggers our pay() function to call the handleAction() function.

# StripeHelper::isPaymentIntentCompleted()

/**
 * Is the given payment intent completed ?
 *
 * @param PaymentIntent $intent
 *
 * @return bool
 */
public static function isPaymentIntentCompleted(PaymentIntent $intent): bool
{
    return $intent->status === 'succeeded';
}

Simply returns if the payment intent is completed.

Solution

Well, I do believe this tutorial is simple enough to get started with Stripe API and build your own custom implementation from it. I didn't use any front or back end frameworks so it's easier for everyone to understand plain PHP / JS.

Also, don't forget that you can do many things with Stripe; recurring payments, saving customers, sending invoices, ...

You can find the official example from Stripe here from which I built this solution upon. (please notice that the original example don't work if you install it, you'll need to tweak it a bit to make it work)

Here is all the working code repository for the sample project

Learn more about Stripe Payments and it's documentation here

Don't hesitate to give feedbacks or correct me if you see any mistakes

Thanks for reading !

March 01, 2020