DEV Community

Chris Earls
Chris Earls

Posted on • Edited on

Laravel Jetstream Subscription Billing With Stripe Checkout and Customer Portal

Update 2/16/21 - This tutorial has been updated to include the recent Stripe Checkout support added to Laravel Cashier.

Laravel Jetstream is a great starting point for a new project. I prefer the Livewire stack, which is used in this guide, but there is also an Inertia.js + Vue option.

I recently started a project using Jetstream and I needed billing functionality. So, I decided to add Stripe Checkout and Customer Portal, offloading all of the billing front-end to Stripe.

Here's how I did it from start to finish.

Install Laravel Jetstream

Let's create a new Laravel Jetstream project with teams enabled. Check out the Jetstream documentation if you're not familiar with it.

laravel new jetstream --jet —-stack=livewire --teams

cd jetstream

You'll want to set up your preferred database at this point and ensure that your application can connect to it. Then, migrate the database.

php artisan migrate

Publish the Jetstream views in your application so we can make a few updates later.

php artisan vendor:publish --tag=jetstream-views

Lastly, let's build the front-end.

npm install && npm run dev

Install Laravel Cashier

Why do we need Cashier? Because it's still a great option to handle the Stripe webhooks. We'll also be taking advantage of the Stripe Checkout support in Cashier.

Install Cashier:

composer require laravel/cashier

If you don't have a Stripe account, you'll want to set that up and add your API keys.

Add the following to your .env file.

STRIPE_KEY=your-stripe-key
STRIPE_SECRET=your-stripe-secret
Enter fullscreen mode Exit fullscreen mode

Team Billing

This is where I fork from the typical Cashier installation. I prefer team billing, but Cashier, by default, expects billing to be set up by user.

You can tell Cashier what model to use by adding an .env variable.

Add the following to your .env file.

CASHIER_MODEL=App\Models\Team

We also need to update the Cashier database migrations to use teams instead of users. Let's publish the migrations to our project so we can make those updates.

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

In migration 2019_05_03_000001_create_customer_columns.php, replace any instance of "users" with "teams".

In migration 2019_05_03_000002_create_subscriptions_table.php, replace any instance of "user_id" with "team_id".

And, let's migrate the database again.

php artisan migrate

Next, you'll add the Billable trait to your Team model.

use Laravel\Cashier\Billable;

class Team extends JetstreamTeam
{
    use Billable;
}
Enter fullscreen mode Exit fullscreen mode

That's it for the initial setup. Now, it's time to add the billing front-end and handle the Stripe webhooks.

Stripe Checkout, Customer Portal and Webhooks

Let's create a controller to handle the portal link.

php artisan make:controller StripeController

And, update the file with the following:

<?php

namespace App\Http\Controllers;

use Exception;
use Illuminate\Http\Request;

class StripeController extends Controller
{
    public function portal(Request $request)
    {
        return $request->user()->currentTeam->redirectToBillingPortal(
            route('dashboard')
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Let's make a controller to handle the Stripe checkout.session.completed webhook. This is the webhook that is called after a person successfully sets up their subscription and is not handled by Cashier.

php artisan make:controller WebhookController

Update the file with the following:

<?php

namespace App\Http\Controllers;

use App\Models\Team;
use Illuminate\Support\Facades\DB;
use Laravel\Cashier\Http\Controllers\WebhookController as CashierController;

class WebhookController extends CashierController
{
    public function handleCheckoutSessionCompleted(array $payload)
    {
        $data = $payload['data']['object'];
        $team = Team::findOrFail($data['client_reference_id']);

        DB::transaction(function () use ($data, $team) {
            $team->update(['stripe_id' => $data['customer']]);

            $team->subscriptions()->create([
                'name' => 'default',
                'stripe_id' => $data['subscription'],
                'stripe_status' => 'active'
            ]);
        });

        return $this->successMethod();
    }
}

Enter fullscreen mode Exit fullscreen mode

One gotcha is that we need to add an exception in the CSRF middlware for the Stripe webhooks.

Update the app/Http/Middleware/VerifyCsrfToken.php class with the following:

class VerifyCsrfToken extends Middleware
{
    /**
     * The URIs that should be excluded from CSRF verification.
     *
     * @var array
     */
    protected $except = [
        'stripe/*'
    ];
}
Enter fullscreen mode Exit fullscreen mode

Another gotcha is that we need to make sure the "stripe_id" column is fillable on the Team model.

Update the Team model with the following:

class Team extends JetstreamTeam
{
    ...

    protected $fillable = [
        'name',
        'personal_team',
        'stripe_id'
    ];

    ...
}
Enter fullscreen mode Exit fullscreen mode

Finally, add the routes for these new controller actions.

Add the following to the routes file:

use App\Http\Controllers\StripeController;
use App\Http\Controllers\WebhookController;

Route::middleware(['auth:sanctum', 'verified'])->group(function () {
    Route::get('/stripe/portal', [StripeController::class, 'portal'])->name('stripe.portal');
});

Route::post(
    '/stripe/webhook',
    [WebhookController::class, 'handleWebhook']
);
Enter fullscreen mode Exit fullscreen mode

Billing Controller & View

We still need to give the user a way to access this new team billing functionality. Let's add a billing controller, Stripe.js and create the Jetstream-flavored billing view.

Let's create the billing controller:

php artisan make:controller BillingController

Update the file with the following:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class BillingController extends Controller
{
    public function index() {
        $checkoutPlan1 = Auth::user()->currentTeam->checkout('price_123', [
            'success_url' => route('dashboard'),
            'cancel_url' => route('dashboard'),
            'mode' => 'subscription'
        ]);
        $checkoutPlan2 = Auth::user()->currentTeam->checkout('price_456', [
            'success_url' => route('dashboard'),
            'cancel_url' => route('dashboard'),
            'mode' => 'subscription'
        ]);

        return view('billing', ['checkout1' => $checkoutPlan1, 'checkout2' => $checkoutPlan2]);
    }
}
Enter fullscreen mode Exit fullscreen mode

You'll want to update the Stripe price IDs (e.g. price_123) with your own.

Add Stripe.js to resources/views/layouts/app.blade.php in the <head> section.

<script src="https://js.stripe.com/v3/"></script>
Enter fullscreen mode Exit fullscreen mode

Create a new view at resources/views/billing.blade.php and update with the following:

<x-app-layout>
    <x-slot name="header">
        <h2 class="font-semibold text-xl text-gray-800 leading-tight">
            {{ __('Billing') }}
        </h2>
    </x-slot>

    <div>
        <div class="max-w-7xl mx-auto py-10 sm:px-6 lg:px-8">
            <div class="mt-10 sm:mt-0">
                <x-jet-action-section>
                    <x-slot name="title">
                        {{ __('Manage Subscription') }}
                    </x-slot>

                    <x-slot name="description">
                        {{ __('Subscribe, upgrade, downgrade or cancel your subscription.') }}
                    </x-slot>

                    <x-slot name="content">
                        <p>{{ env('APP_NAME') . __(' uses Stripe for billing. You will be taken to Stripe\'s website to manage your subscription.') }}</p>

                        @if (Auth::user()->currentTeam->subscribed('default'))
                            <div class="mt-6">
                                <a class="px-6 py-3 bg-indigo-500 rounded text-white" href="{{ route('stripe.portal') }}">
                                    {{ __('Manage Subscription') }}
                                </a>
                            </div>
                        @else
                            <div id="error-message" class="hidden p-2 mt-4 bg-pink-100"></div>

                            <div class="mt-4">
                                {{ $checkout1->button('Subscribe to Plan 1') }}
                            </div>
                            <div class="mt-4">
                                {{ $checkout2->button('Subscribe to Plan 2') }}
                            </div>
                        @endif
                    </x-slot>
                </x-jet-action-section>
             </div>
        </div>
    </div>
</x-app-layout>
Enter fullscreen mode Exit fullscreen mode

Let's add a route to make this billing view work. Add the following to your routes file:

use App\Http\Controllers\BillingController;

Route::middleware(['auth:sanctum', 'verified'])->group(function () {
    Route::get('/billing', [BillingController::class, 'index'])->name('billing');
});
Enter fullscreen mode Exit fullscreen mode

Finally, we'll add a "Billing" link to the Jetstream navigation dropdown.

In navigation-dropdown.blade.php, add the following in the "Team Management" section.

<!-- Team Billing -->
<x-jet-dropdown-link href="{{ route('billing') }}">
    {{ __('Billing') }}
</x-jet-dropdown-link>
Enter fullscreen mode Exit fullscreen mode

Testing

You'll need to listen for Stripe events locally to test that everything is working. Install the Stripe CLI and listen for the events.

You can forward the events to your local site using the --forward-to flag.

stripe listen --forward-to jetstream.test/stripe/webhook

Be sure to replace jetstream.test with your local site's address.

That's it! 🎉

This was super long, but I hope it helped someone. Laravel Jetstream is a great starting point and Stripe Checkout and Customer Portal make it easy to add subscription and billing management.

Top comments (17)

Collapse
 
foxrocks76 profile image
FoxRocks76

Thanks Chris, this was super helpful! The only problem I ran into is that when I changed those migrations, I got an error because the customers table was being created before the teams table, but I was able to fix that by just renaming the migration files so they went in the correct order. Full disclosure: I was doing this on an existing (new) project I started, so I expect I just didn't install Jetstream and Cashier in the same order you did.
Cheers!

Collapse
 
ostap profile image
Ostap Brehin • Edited

In Laravel 11, I believe the middleware config would be in bootstrap/app.php

    ->withMiddleware(function (Middleware $middleware) {
        $middleware->validateCsrfTokens(except: [
            'stripe/webhook',
        ]);
Enter fullscreen mode Exit fullscreen mode
Collapse
 
ibourgeois profile image
Derek Bourgeois

You are not importing the Stripe JS library, so this tutorial will result in:

Uncaught ReferenceError: Stripe is not defined
    at billing:408
Enter fullscreen mode Exit fullscreen mode

You can add the following to resources/views/billing.blade.php, above the <script> tag:

<script src="https://js.stripe.com/v3/"></script>
Enter fullscreen mode Exit fullscreen mode
Collapse
 
cearls profile image
Chris Earls • Edited

Good catch! I've added this.

Collapse
 
alexweissman profile image
Alex Weissman

Thanks, this was very helpful! A few notes:

  • When you try to run the Cashier migrations, you will get an error because Cashier tries to create the same sessions table as Jetstream
  • The Stripe event is actually checkout.session.completed
  • I believe the callback at the end of your JS should be handleFetchResult, not handleResult?
Collapse
 
cearls profile image
Chris Earls

Thanks for your feedback, Alex! I have made updates to address the last two notes, but I'm not able to reproduce the sessions table error. 🤔

Collapse
 
websilvercraft profile image
websilvercraft

Nice integration, for added flexibility, I added a way to select between multiple payment providers using service container and contextual binding. However I didn't know about jetstream, seems a nice solution to prevent inventing the wheel.

Collapse
 
grsherwin profile image
grsherwin

Chris, This is a very good article, but I must be missing something. I followed each of your steps and get the page below, but when I click on a button nothing happens. I would have expected it to go to stripe. Do I need to set something else up?

Collapse
 
jamestechdude profile image
James Erikson • Edited

I wanted to add... At least nowadays, when you publish the migrations to switch to team_id for stripe, you will have to also rename your team migrations for jetstream to run first before the stripe migrations do

Also, I wish there was a tutorial for the inertia version of Jetstream as it seems like I can't get this setup completely. I get up to the part and then I'm stuck since the view and navigation dropdown files aren't the same, but I'll format them to what I have

Thank you!

Collapse
 
mckltech profile image
MCKLtech

Is it possible to start a subscription from the billing portal? In other words, the checkout is entirely handled by Stripe in their portal?

Collapse
 
cearls profile image
Chris Earls

No, you can only manage existing subscriptions with the billing portal.

Collapse
 
mckltech profile image
MCKLtech

Interesting! Would be a nice feature for Stripe to add as it would remove the requirement for checkout and decouple billing entirely.

Thank you for the tutorial, very insightful.

Collapse
 
rabol profile image
Steen Rabol

For the installation I think this would work as well:

laravel new cool-project --jet --teams

Collapse
 
cearls profile image
Chris Earls • Edited

Thanks! I've updated the tutorial to use the installer.

Collapse
 
esbenc_volsgaard_4b9903 profile image
Esben C. Volsgaard

Great article! I’m having an issue with the model though… when I define it as the Team model in my .env, it still tries to user the User model and as a result it fails - any tips?