Best Practices for Stripe Elements w/ Livewire & Alpine

Hi all,

For many of my subscription or payment-based projects, we use Stripe Elements to securely collect payment information.

While I was able to create a working version of a registration + payment form using Livewire and Alpine, I’m interested to know what the best practices are or some ways that you guys have implement Stripe Elements.

The tricky part is the order of events - submit the form, validate the data, communicate with Stripe via JS, send the stripe token back to Livewire, attempt payment, etc.

Here is an example registration form:

<form wire:submit.prevent="register">
    <div>
        <label for="name">
            Name
        </label>

        <input wire:model.lazy="name" type="text"
                class="w-full form-input @error('name') border-red-500 @enderror"
                id="name" name="name"/>

        @error('name')
        <p class="text-red-500 text-xs italic mt-4">{{ $message }}</p>
        @enderror
    </div>
    <div>
        <label for="email">
            Email Address
        </label>

        <input wire:model.lazy="email" type="email"
                class="w-full form-input @error('email')border-red-500 @enderror"
                id="email" name="email"/>

        @error('email')
        <p class="text-red-500 text-xs italic mt-4">{{ $message }}</p>
        @enderror
    </div>
    <div>
        <label for="password">
            Password
        </label>

        <input wire:model.lazy="password" type="password"
                class="w-full form-input @error('password')border-red-500 @enderror"
                id="password" name="password"/>

        @error('password')
        <p class="text-red-500 text-xs italic mt-4">{{ $message }}</p>
        @enderror
    </div>
    <div>
        <label for="password_confirmation">
            Confirm Password
        </label>

        <input wire:model.lazy="password_confirmation" type="password"
                class="w-full form-input @error('password_confirmation')border-red-500 @enderror"
                id="password_confirmation" name="password_confirmation"/>

        @error('password_confirmation')
        <p class="text-red-500 text-xs italic mt-4">{{ $message }}</p>
        @enderror
    </div>
    <div wire:ignore>
        <x-stripe-elements />
    </div>
    <div>
        <label>
            <input type="checkbox"
                    wire:model="terms_and_conditions">
            <span>I agree to the&nbsp;</span>
        </label>

        @error('terms_and_conditions')
        <p class="text-red-500 text-xs italic mt-4">{{ $message }}</p>
        @enderror
    </div>
    <div>
        <button type="submit">
            Register
        </button>
    </div>
</form>

Next, our Stripe Elements blade component:

<div x-data x-on:generate-stripe-token.window="generateStripeToken()">
    <label for="card">
        Credit Card
    </label>
    <div id="card-element">
        <!-- A Stripe Element will be inserted here. -->
    </div>
</div>
<div class="mt-2 text-sm text-red-500" id="card-errors">

</div>
@push('scripts')
    <script src="https://js.stripe.com/v3/"></script>
    <script type="text/javascript">
        // Create a Stripe client.
        var stripe = Stripe('{{ config('services.stripe.publishable_key') }}');

        // Create an instance of Elements.
        var elements = stripe.elements();

        // Custom styling can be passed to options when creating an Element.
        var style = ////// replaced this content for demo

        // Create an instance of the card Element.
        var card = elements.create('card', {style: style});

        // Add an instance of the card Element into the `card-element` <div>.
        card.mount('#card-element');

        // Handle real-time validation errors from the card Element.
        card.addEventListener('change', function (event) {
            var displayError = document.getElementById('card-errors');
            if (event.error) {
                displayError.textContent = event.error.message;
            } else {
                displayError.textContent = '';
            }
        });

        // Handle form submission.
        function generateStripeToken() {
            stripe.createToken(card).then(function (result) {
                if (result.error) {
                    // Inform the user if there was an error.
                    var errorElement = document.getElementById('card-errors');
                    errorElement.textContent = result.error.message;
                } else {
                    // Send the token to your server.
                    @this.set('stripe_token', result.token.id);
                }
            });
        }
    </script>
@endpush

Finally, our Livewire register component class:

<?php

namespace App\Http\Livewire\Auth;

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Arr;
use Livewire\Component;

class Register extends Component
{
    public $name = '';
    public $email = '';
    public $password = '';
    public $password_confirmation = '';
    public $terms_and_conditions = false;
    public $stripe_token = null;

    protected $listeners = ['setStripeToken'];

    public function render()
    {
        return view('livewire.auth.register');
    }

    protected function validationRules()
    {
        return [
            'terms_and_conditions' => 'accepted',
            'name' => ['required', 'string', 'max:255', 'min:2'],
            'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
            'password' => ['required', 'string', 'min:8', 'confirmed'],
        ];
    }

    public function updated($field)
    {
        $this->validateOnly($field, Arr::except($this->validationRules(), 'password'));
    }

    public function register()
    {
        $this->validate($this->validationRules());

        // Fire event that Alpine will listen to and attempt 
        // to send card data to Stripe. If successful will set 
        // $stripe_token in Livewire component
        $this->dispatchBrowserEvent('generate-stripe-token');

        if (! $this->stripe_token) {
            // Early exit here?
        }

        // Create a DB transaction, create user, attempt Stripe subscription
        DB::beginTransaction();

        $user = User::create([
            'name' => $this->name,
            'email' => $this->email,
            'password' => Hash::make($this->password),
        ]);


        try {
            
            $user->newSubscription('main', 'main')->create($this->stripe_token);
            DB::commit();

            event(new Registered($user));

            $this->guard()->login($user);
            return redirect()->route('dashboard');
            
        } catch (\Exception $e) {

            DB::rollback();
            // Send error back to user

        }
    }

    public function setStripeToken($stripeToken)
    {
        $this->stripe_token = $stripeToken;
    }
}
  • Does this seem like the correct approach to you? Are there are changes you would make?
  • Is the “flow” correct, as far as the order of steps being taken, for validation, communication with Stripe JS, etc.?

I’d love to hear any thoughts you guys have.

Thanks,
Cory

You posted a lot of code :slight_smile: and your title says Alpine, I cannot find any Alpine in the code examples.
I just implemented Laravel Cashier, Stripe Elements and Livewire.
From what I can see your code looks fine.
Are there any errors you need help with?

Check out the Stripe Elements component in particular, which uses Alpine to get the value of the Stripe token and pass to Livewire.

The above code works, I’m just trying to get a sense for whether this is the “best” method for Stripe Elements + Livewire/AlpineJS, or whether improvements could be made.

I have a different approach, don’t use Alpine for the click event.
Followed the Laravel cashier docs.
Here is my code related to Stripe Elements

Livewire component (stripped version)

public $paymentmethod;

public function updatedPaymentmethod()
    {
        if (filled($this->model->stripe_id)) {
            $this->add_or_update_payment_method();
        } else {
            // notify user error
        }
    }

    public function add_or_update_payment_method()
    {
        try {
            if (!$this->model->hasPaymentMethod()) {
                $this->model->addPaymentMethod($this->paymentmethod);
                $this->model->updateDefaultPaymentMethod($this->paymentmethod);
            } else {
                $this->model->updateDefaultPaymentMethod($this->paymentmethod);
            }
            // notify success
        } catch (\Exception $e) {
            // error handling
        }
    }

public function render()
    {
        return view('livewire.subscriptions.card', [
            'intent' => auth()->user()->createSetupIntent(),
        ]);
    }

card.blade.php

<div>
        <div>{{$error}}</div>
        <label class="block">
            <span>Card holder name</span>
            <input id="card-holder-name" placeholder="Jane Doe">
        </label>

        <label class="block">
            <span>Your card</span>
            <!-- Stripe Elements Placeholder -->
            <div wire:ignore id="card-element"></div>
            <div id="error-wrapper"></div>
        </label>

        <button id="card-button" data-secret="{{ $intent->client_secret }}">
            Register card
        </button>
 </div>

@push('scripts')
    <script src="https://js.stripe.com/v3/"></script>
    <script>

            const stripe = Stripe('{{config('services.stripe.key')}}')
            const elements = stripe.elements()
            const cardElement = elements.create('card')

            cardElement.mount('#card-element')

            const cardHolderName = document.getElementById('card-holder-name')
            const cardButton = document.getElementById('card-button')
            const clientSecret = cardButton.dataset.secret

            cardButton.addEventListener('click', async (e) => {
                const {setupIntent, error} = await stripe.confirmCardSetup(clientSecret, {
                    payment_method: {
                        card: cardElement, billing_details: {name: cardHolderName.value},
                    },
                })

                if (error) {
                    let errorWrapper = document.getElementById('error-wrapper')
                    errorWrapper.textContent = error.error
                    console.info(error)
                } else {
                    @this.set('paymentmethod', setupIntent.payment_method)
                }
            })
    </script>
@endpush

I am trying to do the same without Alpine.js, could you please share the full version of your Livewire component. Thanks!

I’ll outline my approach

Before showing the views below, create a payment intent and store this in a transactions table along with a status, the value, and link to the user

Then show the user the following view;

@extends('tall.layout',['title'=>'Subscription Payment'])
@section('content')

<div class="container py-8 mx-auto mt-16 text-center ">
    <h1 class="my-4 text-3xl font-bold text-teal-600">Payment for <span class="text-teal-400">{{ $plan->title }}</span> Subscription</h1>
    <h2 class="my-4 text-xl font-bold text-gray-400">Please authorise payment of <span class="text-teal-400">{{ $plan->sign }}{{ number_format($plan->price,2)}}</span></h2>

    <div id="" class="px-8 py-4 mx-auto mt-4 border border-teal-200 rounded-lg shadow sr-payment-panel lg:w-8/12 sm:w-3/4 md:w-10/12">
        <form id="payment-form" class="mt-4 sr-payment-form">
            <div class="sr-combo-inputs-row">
                <div class="sr-input sr-card-element" id="card-element"></div>
            </div>
            <div class="pb-1 text-base font-bold text-left text-red-600 sr-field-error" id="card-errors" role="alert"></div>
            <button id="submit" class="text-white bg-teal-700 hover:bg-teal-600">
                <div class="hidden spinner" id="spinner"></div>
                <span id="button-text">Pay {{ $plan->sign }}{{ number_format($plan->price,2)}}</span><span id="order-amount"></span>
            </button>
        </form>
    </div>
    <div class="hidden mt-32 text-2xl font-bold text-teal-300 payment-complete" >
        @livewire('stripe-confirm',['transaction' => $transaction->id])
    </div>

</div>

@endsection

{{-- CSS --}}
@section('page-css')
    <link href="/css/stripe.css" rel="stylesheet">
@endsection

{{-- JS --}}
@section('page-js')
<script src="https://js.stripe.com/v3/"></script>

<script>
    // A reference to Stripe.js
    var stripe;

    var orderData = {
        currency: "{{ $paymentIntent->currency }}"
    };

    // Disable the button until we have Stripe set up on the page
    document.querySelector("button").disabled = true;

    (function() {

        stripe = Stripe('{{ config('services.stripe.key')}}') //Stripe(data.publishableKey);
        var elements = stripe.elements();
        var style = {
            base: {
            color: "#32325d",
            fontFamily: 'Arial,"Helvetica Neue", Helvetica, sans-serif',
            fontSmoothing: "antialiased",
            fontSize: "16px",
            "::placeholder": {
                color: "#aab7c4"
            }
        },
        invalid: {
            color: "#fa755a",
            iconColor: "#fa755a"
            }
        };
                
        var card = elements.create("card", { style: style });
        card.mount("#card-element");
                
        document.querySelector("button").disabled = false;
        
        // Handle form submission.
        var form = document.getElementById("payment-form");
        form.addEventListener("submit", function(event) {
            event.preventDefault();
            pay(stripe, card, '{{ $paymentIntent->client_secret }}');
        });
    })();

    /*
    * Calls stripe.confirmCardPayment which creates a pop-up modal to
    * prompt the user to enter extra authentication details without leaving your page
    */
    var pay = function(stripe, card, clientSecret) {
    changeLoadingState(true);

    // Initiate the payment.
    // If authentication is required, confirmCardPayment will automatically display a modal
    stripe
        .confirmCardPayment(clientSecret, { payment_method: { card: card } })
        .then(function(result) {
            if (result.error) {
                // Show error to your customer
                showError(result.error.message);
            } else {
                // The payment has been processed!
                orderComplete(clientSecret);
            }
        });
    };

    /* ------- Post-payment helpers ------- */

    /* Shows a success / error message when the payment is complete */
    var orderComplete = function(clientSecret) {
        document.querySelector(".sr-payment-panel").classList.add("hidden");

        document.querySelector(".payment-complete").classList.remove("hidden");

        // setTimeout(function() {
        //         window.location.href = '{{ route('student.stripe.purchased')}}';
        // }, 2000);
                
    };

    var showError = function(errorMsgText) {
        changeLoadingState(false);
        var errorMsg = document.querySelector(".sr-field-error");
        errorMsg.textContent = errorMsgText;
        setTimeout(function() {
            errorMsg.textContent = "";
        }, 8000);
    };

    // Show a spinner on payment submission
    var changeLoadingState = function(isLoading) {
        if (isLoading) {
            document.querySelector("button").disabled = true;
            document.querySelector("#spinner").classList.remove("hidden");
            document.querySelector("#button-text").classList.add("hidden");
        } else {
            document.querySelector("button").disabled = false;
            document.querySelector("#spinner").classList.add("hidden");
            document.querySelector("#button-text").classList.remove("hidden");
        }
    };


</script>

@endsection

This is more or less the code from the Stripe Elements sample, but with an additional div with class of payment-complete. This div contains a livewire component and is normally hidden.

The only job of the Livewire component is to monitor the status of the transaction via the transactions table. It does this via the wire:poll attribute. It does this whether the div is showing or not. When the Stripe javascript submits the payment detail, it hides the credit card form and shows the Livewire section.

The Livewire component is simply

<?php

namespace App\Http\Livewire;

use App\Transaction;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Livewire\Component;

class StripeConfirm extends Component
{
    public $transaction_id;

    public function mount($transaction)
    {
        $this->transaction_id = $transaction;
    }

    public function render()
    {
        $transaction = Transaction::find($this->transaction_id);

        if($transaction->status == 'succeeded') {
            $this->redirect(route('student.stripe.purchased'));
        }

        return view('livewire.stripe-confirm');
    }
}

it keeps rendering the stripe-confirm view until the transaction status changes, at which point it redirects to a page that shows a confirmation.

The component’s view

<div wire:poll class="leading-normal">
    Please wait for Stripe payment confirmation.
</div>

The page will stay in this state until the transaction changes. It does this via a webhook and a separate endpoint.

The process needs to be improved to handle payment failure. The tricky part is that it’s impossible to know how long the SCA transaction approval might take - its up to the user.

1 Like