Laravel Jetstream Subscription Billing With Stripe Checkout and Customer Portal
Tags: php, laravel, tutorial, webdev
Published: 2020-12-01 00:12:36

---
title: Laravel Jetstream Subscription Billing With Stripe Checkout and Customer Portal
published: true
description: Learn how to add subscription management to your Laravel Jetstream application using Stripe Checkout and customer portal.
tags: php, laravel, tutorial, webdev
//cover_image: https://direct_url_to_image.jpg
---

**Update 2/16/21 - This tutorial has been updated to include the recent [Stripe Checkout support added to Laravel Cashier](https://laravel.com/docs/8.x/billing#checkout).**

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](https://stripe.com/docs/payments/checkout) and [Customer Portal](https://stripe.com/docs/billing/subscriptions/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](https://jetstream.laravel.com/) 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](https://laravel.com/docs/8.x/billing#checkout).

Install Cashier:

`composer require laravel/cashier`

If you don't have a [Stripe account](https://stripe.com/), 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
```

### 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.

```php
use Laravel\Cashier\Billable;

class Team extends JetstreamTeam
{
    use Billable;
}
```

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
<?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')
        );
    }
}
```

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
<?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();
    }
}

```

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:

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

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:

```php
class Team extends JetstreamTeam
{
    ...

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

    ...
}
```

Finally, add the routes for these new controller actions.

Add the following to the routes file:

```php
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']
);
```

## 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
<?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]);
    }
}
```

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.

```html
<script src="https://js.stripe.com/v3/"></script>
```

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

```html
<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>
```

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

```php
use App\Http\Controllers\BillingController;

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

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.

```html
<!-- Team Billing -->
<x-jet-dropdown-link href="{{ route('billing') }}">
    {{ __('Billing') }}
</x-jet-dropdown-link>
```

## Testing
You'll need to listen for Stripe events locally to test that everything is working. [Install the Stripe CLI](https://stripe.com/docs/stripe-cli) and [listen for the events](https://stripe.com/docs/stripe-cli/webhooks).

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.