phplaravelstripe-payments

How to implement Stripe Payment Element in Laravel


I'm trying to implement Stripe's new Payment Element in Laravel, which supports Apple Pay, Google Pay and cards by default and uses the user's country to select the most relevant payment methods, but all of the guides that I can find online for integrating Stripe with Laravel use the old Card Element or don't even use the PaymentIntents API at all.

Stripe's documentation itself only covers the PHP implementation, and I can't figure out from that how to implement it in a Laravel-idiomatic way, which at the moment seems like an overwhelming task. How exactly would I go about implementing the new Stripe Payment Element in Laravel?


Solution

  • I finally managed to get this working, and as a solution it's sufficient for a site accepting one-time payments, although if you plan to take recurring payments I would recommend taking the plunge and setting up Laravel Cashier as recommended by jszobody.

    I made some notable changes to the code in the Stripe docs, although apart from those changes the code below is virtually untouched and could definitely be written in a more Laravel way by someone more experienced in Laravel, and I'd welcome any edits accordingly.


    Routes/web.php:

    Route::get('/payment', [PaymentController::class, "index"]);
    Route::post('/charge', [PaymentController::class, "pay"]);
    

    Create the referenced controller with php artisan make:controller PaymentController, and give PaymentController the following content:

    <?php
    
    namespace App\Http\Controllers;
    use Illuminate\Http\Request;
    use App\Models\Payment;
    use Exception;
    use Stripe;
    
    class PaymentController extends Controller
    {
     
        public function index()
        {
            return view('payment');
        }
     
        public function pay(Request $request)
        {
    
            // Replace with your secret key, found in your Stripe dashboard
            Stripe\Stripe::setApiKey('<YOUR_SECRET_KEY>'); 
    
            function calculateOrderAmount(array $items): int {
                return 499;
            }
    
            header('Content-Type: application/json');
    
            try {
    
                $jsonStr = file_get_contents('php://input');
                $jsonObj = json_decode($jsonStr);
    
                $paymentIntent = Stripe\PaymentIntent::create([
                    'amount' => calculateOrderAmount($jsonObj->items),
                    'currency' => 'gbp', // Replace with your country's primary currency
                    'automatic_payment_methods' => [
                        'enabled' => true,
                    ],
                    // Remove if you don't want to send automatic email receipts after successful payment
                    "receipt_email" => $request->email 
                ]);
    
                $output = [
                    'clientSecret' => $paymentIntent->client_secret,
                ];
    
                echo json_encode($output);
            } catch (Exception $e) {
                return back()->with(['error' => $e->getMessage()]);
            }
        }
    }
    

    stripe_checkout.js:

    The most significant change here from the Stripe docs was manually passing through the CSRF token in the HTTP headers. Leaving this out results in HTTP 419 errors due to a conflict with Laravel, and it took me much longer than it should have to solve this. Tip: when debugging stuff like this, working your way methodically through each error you see in the browser console is the key to getting things working.

    // Replace with your public key, found in your Stripe dashboard
    const stripe = Stripe("<YOUR_PUBLIC_KEY>"); 
    
    const items = [{ id: "xl-tshirt" }];
    
    let elements;
    
    initialize();
    checkStatus();
    
    document
        .querySelector("#payment-form")
        .addEventListener("submit", handleSubmit);
    
    async function initialize() {
        const { clientSecret } = await fetch("/charge", {
            method: "POST",
            headers: {
                "X-CSRF-TOKEN": document.querySelector('input[name="_token"]').value,
            },
            body: JSON.stringify({ items }),
        }).then((r) => r.json());
    
        elements = stripe.elements({ clientSecret });
    
        const paymentElement = elements.create("payment");
        paymentElement.mount("#payment-element");
    }
    
    async function handleSubmit(e) {
        e.preventDefault();
        setLoading(true);
    
        const { error } = await stripe.confirmPayment({
            elements,
            confirmParams: {
                // Replace with your payment completion page
                return_url: "http://localhost/success",
            },
        });
    
        if (error.type === "card_error" || error.type === "validation_error") {
            showMessage(error.message);
        } else {
            showMessage("An unexpected error occured.");
        }
    
        setLoading(false);
    }
    
    async function checkStatus() {
        const clientSecret = new URLSearchParams(window.location.search).get(
            "payment_intent_client_secret"
        );
    
        if (!clientSecret) {
            return;
        }
    
        const { paymentIntent } = await stripe.retrievePaymentIntent(clientSecret);
    
        switch (paymentIntent.status) {
            case "succeeded":
                showMessage("Payment succeeded!");
                break;
            case "processing":
                showMessage("Your payment is processing.");
                break;
            case "requires_payment_method":
                showMessage("Your payment was not successful, please try again.");
                break;
            default:
                showMessage("Something went wrong.");
                break;
        }
    }
    
    // ------- UI helpers -------
    
    function showMessage(messageText) {
        const messageContainer = document.querySelector("#payment-message");
    
        messageContainer.classList.remove("hidden");
        messageContainer.textContent = messageText;
    
        setTimeout(function () {
            messageContainer.classList.add("hidden");
            messageText.textContent = "";
        }, 4000);
    }
    
    function setLoading(isLoading) {
        if (isLoading) {
            document.querySelector("#submit").disabled = true;
            document.querySelector("#spinner").classList.remove("hidden");
            document.querySelector("#button-text").classList.add("hidden");
        } else {
            document.querySelector("#submit").disabled = false;
            document.querySelector("#spinner").classList.add("hidden");
            document.querySelector("#button-text").classList.remove("hidden");
        }
    }
    

    stripe_checkout.css (unchanged from the documentation):

    /* Variables */
    * {
      box-sizing: border-box;
    }
    
    body {
      font-family: -apple-system, BlinkMacSystemFont, sans-serif;
      font-size: 16px;
      -webkit-font-smoothing: antialiased;
      display: flex;
      justify-content: center;
      align-content: center;
      height: 100vh;
      width: 100vw;
    }
    
    form {
      width: 30vw;
      min-width: 500px;
      align-self: center;
      box-shadow: 0px 0px 0px 0.5px rgba(50, 50, 93, 0.1),
        0px 2px 5px 0px rgba(50, 50, 93, 0.1), 0px 1px 1.5px 0px rgba(0, 0, 0, 0.07);
      border-radius: 7px;
      padding: 40px;
    }
    
    .hidden {
      display: none;
    }
    
    #payment-message {
      color: rgb(105, 115, 134);
      font-size: 16px;
      line-height: 20px;
      padding-top: 12px;
      text-align: center;
    }
    
    #payment-element {
      margin-bottom: 24px;
    }
    
    /* Buttons and links */
    button {
      background: #5469d4;
      font-family: Arial, sans-serif;
      color: #ffffff;
      border-radius: 4px;
      border: 0;
      padding: 12px 16px;
      font-size: 16px;
      font-weight: 600;
      cursor: pointer;
      display: block;
      transition: all 0.2s ease;
      box-shadow: 0px 4px 5.5px 0px rgba(0, 0, 0, 0.07);
      width: 100%;
    }
    button:hover {
      filter: contrast(115%);
    }
    button:disabled {
      opacity: 0.5;
      cursor: default;
    }
    
    /* spinner/processing state, errors */
    .spinner,
    .spinner:before,
    .spinner:after {
      border-radius: 50%;
    }
    .spinner {
      color: #ffffff;
      font-size: 22px;
      text-indent: -99999px;
      margin: 0px auto;
      position: relative;
      width: 20px;
      height: 20px;
      box-shadow: inset 0 0 0 2px;
      -webkit-transform: translateZ(0);
      -ms-transform: translateZ(0);
      transform: translateZ(0);
    }
    .spinner:before,
    .spinner:after {
      position: absolute;
      content: "";
    }
    .spinner:before {
      width: 10.4px;
      height: 20.4px;
      background: #5469d4;
      border-radius: 20.4px 0 0 20.4px;
      top: -0.2px;
      left: -0.2px;
      -webkit-transform-origin: 10.4px 10.2px;
      transform-origin: 10.4px 10.2px;
      -webkit-animation: loading 2s infinite ease 1.5s;
      animation: loading 2s infinite ease 1.5s;
    }
    .spinner:after {
      width: 10.4px;
      height: 10.2px;
      background: #5469d4;
      border-radius: 0 10.2px 10.2px 0;
      top: -0.1px;
      left: 10.2px;
      -webkit-transform-origin: 0px 10.2px;
      transform-origin: 0px 10.2px;
      -webkit-animation: loading 2s infinite ease;
      animation: loading 2s infinite ease;
    }
    
    @-webkit-keyframes loading {
      0% {
        -webkit-transform: rotate(0deg);
        transform: rotate(0deg);
      }
      100% {
        -webkit-transform: rotate(360deg);
        transform: rotate(360deg);
      }
    }
    @keyframes loading {
      0% {
        -webkit-transform: rotate(0deg);
        transform: rotate(0deg);
      }
      100% {
        -webkit-transform: rotate(360deg);
        transform: rotate(360deg);
      }
    }
    
    @media only screen and (max-width: 600px) {
      form {
        width: 80vw;
        min-width: initial;
      }
    }
    

    payment.blade.view:

    <script src="https://js.stripe.com/v3/"></script>
    <script src="{{ asset('js/stripe_checkout.js') }}" defer></script>
    <link href="{{ asset('css/stripe_checkout.css') }}" rel="stylesheet" />
    
      ...
    
    <div class="stripe-container">
      {{ csrf_field() }}
      <form id="payment-form">
      <div id="payment-element">
      <!-- Stripe.js injects the Payment Element here-->
      </div>
      <button id="submit">
      <div class="spinner hidden" id="spinner"></div>
      <span id="button-text">Pay £9.99</span>
      </button>
      <div id="payment-message" class="hidden"></div>
      </form>
    </div>