Adding Stripe Checkout and Customer Portal to your Laravel application

under Laravel

Here's a quick overview of how you can add Stripe Checkout and Stripe Customer Portal to your Laravel application via Laravel Cashier.

This guide assumes you have a Stripe dev account setup and access to your Stripe Key and Secret. You should also have your products and pricing configure in Stripe. You will need at least one Price ID.

Resources

Considerations

First, you need to figure out which entity in your application will be considered the billable entity. For example, if you have an application with teams where each team signs up and pays for a number of seats, then your Team model will be your billable entity. However, if your application has users which sign up and pay for themselves, then your User model will be your billable entity.

Environment variables

We need to setup some environment variables for Laravel Cashier to pass along to stripe. You only need to set CASHIER_MODEL if your billable model is not App\Models\User.

1CASHIER_MODEL=App\Models\Team
2STRIPE_KEY=XXXXXXX
3STRIPE_SECRET=XXXXXXX

Package installation

As of Feb 9, 2020, the released version of Laravel Cashier supports Stripe checkout. You can install Laravel Cashier with:

1composer require laravel/cashier

On the frontend, we will be using the Stripe JS SDK, so make sure to include that on your page somewhere:

1<script src="https://js.stripe.com/v3/" defer></script>

Migrations

I recommend publishing Cashier's migrations into your local migrations directory, so that you have full control over them:

1php artisan vendor:publish --tag="cashier-migrations"

If your billable model is not User, make sure to change the table in the CreateCustomerColumns migration we just published to the table that corresponds to your billable model.

1<?php
2 
3// I changed 'users' to 'teams'
4 
5Schema::table('teams', function (Blueprint $table) {
6 $table->string('stripe_id')->nullable()->index();
7 $table->string('card_brand')->nullable();
8 $table->string('card_last_four', 4)->nullable();
9 $table->timestamp('trial_ends_at')->nullable();
10});

I also had to change the CreateSubscriptionsTable migration to reference my billable entity's table:

1<?php
2 
3class CreateSubscriptionsTable extends Migration
4{
5 /**
6 * Run the migrations.
7 *
8 * @return void
9 */
10 public function up()
11 {
12 Schema::create('subscriptions', function (Blueprint $table) {
13 $table->bigIncrements('id');
14 $table->unsignedBigInteger('team_id'); // I changed this from `user_id`
15 $table->string('name');
16 $table->string('stripe_id');
17 $table->string('stripe_status');
18 $table->string('stripe_plan')->nullable();
19 $table->integer('quantity')->nullable();
20 $table->timestamp('trial_ends_at')->nullable();
21 $table->timestamp('ends_at')->nullable();
22 $table->timestamps();
23 
24 $table->index(['team_id', 'stripe_status']); // I changed this from `user_id`
25 });
26 }
27}

Since we published Cashier's migrations, we should also tell Cashier not to run its default migrations. Add this to your AppServiceProvider.register method:

1<?php
2 
3use Laravel\Cashier\Cashier;
4 
5/**
6 * Register any application services.
7 *
8 * @return void
9 */
10public function register()
11{
12 Cashier::ignoreMigrations();
13}

Model setup

Next, we need to configure our billable model. In my case, the billable model is Team. Add the Billable trait to your model:

1<?php
2 
3namespace App\Models;
4 
5use Laravel\Cashier\Billable;
6 
7class Team extends Model
8{
9 use Billable;
10}

Redirect to subscription page

If your user is logged in but doesn't have an active subscription, we need to redirect them to a page asking them to subscribe. The following examples will be specific to Inertia, but the concepts can be used on any Laravel stack.

Middleware

We're going to add a middleware which we will use to confirm the user has an active subscription. You can pass the name of the subscription into subscribed().

1<?php
2 
3namespace App\Http\Middleware;
4 
5use Closure;
6 
7class BillingMiddleware
8{
9 public function handle($request, Closure $next)
10 {
11 $user = $request->user();
12 
13 if ($user && !$user->currentTeam->subscribed('default')) {
14 return redirect('subscription');
15 }
16 
17 return $next($request);
18 }
19}

We then need to add the new middleware to our HTTP Kernel:

1<?php
2 
3protected $routeMiddleware = [
4 // ...
5 'billing' => BillingMiddleware::class
6];

Controller

Create the controller which will start a new Stripe Checkout session, and then return the Checkout Session ID to the UI.

1<?php
2 
3namespace App\Http\Controllers\Teams;
4 
5use App\Http\Controllers\Controller;
6use Illuminate\Http\Request;
7use Inertia\Inertia;
8 
9class ManageSubscriptionController extends Controller
10{
11 public function __invoke(Request $request)
12 {
13 $checkout = $request->user()->currentTeam->newSubscription('default', config('stripe.price_id'))->checkout();
14 
15 return Inertia::render('Teams/ManageSubscription', [
16 'stripeKey' => config('cashier.key'),
17 'sessionId' => $checkout->id
18 ]);
19 }
20}

You'll notice in the above example that I am referencing config('stripe.price_id'). This comes from the Stripe Dashboard where you configure your products and pricing. I'll leave it up to you to figure out how you want to determine this value. Most people store their plans/pricing in a Laravel config file and then pull them from there based on what the user selected from your UI.

Don't forget to add the route for the above controller:

1<?php
2 
3Route::get('/subscription', ManageSubscriptionController::class)->name('subscription');

Note that the name of the route above matches the name of the route we are redirecting to in the BillingMiddleware.

Inertia View

Here's my full Inertia (Vue) component to render the subscription page. This probably won't be copy/pastable, but hopefully it can guide you in the right direction.

1<template>
2 <page :title="title">
3 <div class="py-10 mx-auto max-w-7xl sm:px-6 lg:px-8">
4 <div class="text-center">
5 <h2 class="text-2xl font-bold">
6 Hold up! You need an active subscription first.
7 </h2>
8 <jet-button class="mt-4" @click.native="checkout">
9 Head to the checkout page
10 </jet-button>
11 </div>
12 </div>
13 </page>
14</template>
15 
16<script>
17import Page from '../../Layouts/Page';
18import AppLayout from '../../Layouts/AppLayout';
19import JetButton from '../../Jetstream/Button';
20export default {
21 layout: AppLayout,
22 components: {
23 Page,
24 JetButton,
25 },
26 props: {
27 stripeKey: {
28 type: String,
29 required: true,
30 },
31 sessionId: {
32 type: String,
33 required: true,
34 },
35 },
36 computed: {
37 title() {
38 return 'Manage Subscription';
39 },
40 },
41 methods: {
42 checkout() {
43 window
44 .Stripe(this.stripeKey)
45 .redirectToCheckout({
46 sessionId: this.sessionId,
47 })
48 .then(function (result) {
49 console.error('result', result);
50 });
51 },
52 },
53};
54</script>

The key to the above component is the checkout method which we call via a button (user input). The checkout method is called with our public Stripe Key (passed from the backend), and the sessionId which is the Stripe Checkout Session ID (also passed from the backend).

From here, Laravel Cashier will take care of updating your database tables with the accurate subscription info, all via webhooks. You will need to make sure you've configured webhooks on the Stripe Dashboard though. The endpoint that Laravel Cashier automatically registers is: /stripe/webhook.

Billing portal endpoint

The Billing Portal is the easiest part of this whole process. All you need to do is register a new endpoint which redirects to the billing portal.

1<?php
2 
3namespace App\Http\Controllers\Teams;
4 
5use App\Http\Controllers\Controller;
6use Illuminate\Http\Request;
7 
8class BillingPortalController extends Controller
9{
10 public function __invoke(Request $request)
11 {
12 return $request->user()->currentTeam->redirectToBillingPortal();
13 }
14}

And then define the route:

1<?php
2 
3Route::get('/billing-portal', BillingPortalController::class);

And then link to that new route from wherever you want in your application. In my Jetstream application, I've added it to the dropdown menu:

1<jet-dropdown-link href="/billing-portal" :external="true">
2 Billing Portal
3</jet-dropdown-link>

Summary

There are a bunch of steps involved, but mostly its following the Laravel Cashier documentation. You can check out the Laravel docs repo on GitHub to see the documentation changes made for the Billing Portal: https://github.com/laravel/docs/compare/stripe-checkout...master


Thanks for reading this article!

Hopefully you found this article useful! If you did, share it on Twitter!

Found an issue with the article? Submit your edits against the repository.