PHP - - By Christopher Vundi

Laravel and Braintree: Middleware and Other Advanced Concepts

Building a Subscription-based Courses Website

This article was peer reviewed by Viraj Khatavkar. Thanks to all of SitePoint’s peer reviewers for making SitePoint content the best it can be!


Previously, we saw how to configure a Laravel app to handle Braintree subscriptions.

Braintree Logo

This time, we’ll talk about how to:

  • Prevent users from signing up to the same plan twice
  • Add basic flash messaging to our application
  • Add the ability to swap plans
  • Create middleware to protect some routes based on the subscription status
  • Restrict users with basic subscriptions from accessing premium content
  • Cancel and resume subscriptions
  • Add Braintree notifications to the application’s events via webhooks

Double Subscriptions

As it stands, if we visit the plans index page, we can still see the Choose Plan button for the plan we are currently subscribed to, and this shouldn’t be the case. In the plans index view, let’s add an if conditional to hide the button based on the user’s subscription status:

[...]
@if (!Auth::user()->subscribedToPlan($plan->braintree_plan, 'main'))
    <a href="{{ url('/plan', $plan->slug) }}" class="btn btn-default pull-right">Choose Plan</a>
@endif
[...]

But that’s not to say users can’t access the plan by typing in the URL pointing to the same plan in the address bar. To counter this, let’s update the code in the show action of the PlansController to this:

[...]
public function show(Request $request, Plan $plan)
{
    if ($request->user()->subscribedToPlan($plan->braintree_plan, 'main')) {
        return redirect('home')->with('error', 'Unauthorised operation');
    }

    return view('plans.show')->with(['plan' => $plan]);
}
[...]

Here, we are getting the user from the request object; remember all our routes fall under the auth middleware and thus it’s possible to get the authenticated user. Once we get the user, we check if they are already subscribed to the plan. If that’s the case, we redirect them to the homepage and display a notification. We will implement basic flash messaging later.

One last precaution is preventing users from submitting the payment form with a different plan ID value. It’s possible to inspect the DOM elements and change the value for the hidden input. In our SubscriptionsController, let’s update the store method to this:

[...]
public function store(Request $request)
{
    $plan = Plan::findOrFail($request->plan);

    if ($request->user()->subscribedToPlan($plan->braintree_plan, 'main')) {
        return redirect('home')->with('error', 'Unauthorised operation');
    }

    $request->user()->newSubscription('main', $plan->braintree_plan)->create($request->payment_method_nonce);

    // redirect to home after a successful subscription
    return redirect('home')->with('success', 'Subscribed to '.$plan->braintree_plan.' successfully');
}
[...]

Flash Messaging

Let’s now implement some basic flash messaging to display notifications in the app in response to certain operations. In the resources/views/layouts/app.blade.php file, let’s insert this block right above our content:

[...]
<div class="container">
    <div class="row">
        <div class="col-md-8 col-md-offset-2">
            @include ('layouts.partials._notifications')
        </div>
    </div>
</div>
@yield('content')
[...]

Then, we create the notifications partial:

resources/views/layouts/partials/_notifications.blade.php

@if (session('success'))
    <div class="alert alert-success">
        {{ session('success') }}
    </div>
@endif

@if (session('error'))
    <div class="alert alert-danger">
        {{ session('error') }}
    </div>
@endif

In the partial, we have used the session helper to help us come up with different notification colors based on the session status i.e. success or error.

Swapping Plans

After a user has subscribed to our application, they may occasionally want to change to a new subscription plan.

To accomplish this, we’ll first have to check if the user is subscribed to any plan inside the store method of the SubscriptionsController. If not, we subscribe them to a new plan. To swap a user to a new subscription, we pass the plan’s identifier to the swap method.

Let’s open our SubscriptionsController and update the store method:

public function store(Request $request)
{
    [...]
    if (!$request->user()->subscribed('main')) {
        $request->user()->newSubscription('main', $plan->braintree_plan)->create($request->payment_method_nonce);
    } else {
        $request->user()->subscription('main')->swap($plan->braintree_plan);
    }

    return redirect('home')->with('success', 'Subscribed to '.$plan->braintree_plan.' successfully');
}

Let’s see if things are working as intended by choosing a different plan, filling in fake card details (same details used when subscribing to a new plan in the previous post) then submitting the form. If we look inside the subscriptions table, we should notice the plan for the authenticated user has changed. This change should also be reflected in Braintree under transactions. Next, we want to protect routes based on subscription status.

Protecting Routes

For this application, we want to introduce lessons. Only subscribed users should have access to lessons. We’ll generate a LessonsController:

php artisan make:controller LessonsController

… then head over to the controller and create an index action. We won’t be creating views for this, just displaying some text:

[...]
public function index()
{
    return 'Normal Lessons';
}
[...]

Then, we create the route pointing to this index action inside the route group with the auth middleware:

Route::group(['middleware' => 'auth'], function () {
    Route::get('/lessons', 'LessonsController@index');
});

Let’s also update our navbar to include a link to these ‘lessons’:

resources/views/layouts/app.blade.php

[...]
<ul class="nav navbar-nav navbar-left">
    <li><a href="{{ url('/plans') }}">Plans</a></li>
    <li><a href="{{ url('/lessons') }}">Lessons</a></li>
</ul>
[...]

If we now click on Lessons from the navbar, we’ll see the text “Normal Lessons” regardless of the subscription status. This shouldn’t be the case. Only subscribed users should be able to access lessons. To enable this behavior, we’ll have to create some middleware:

php artisan make:middleware Subscribed

Let’s open the file in app/Http/Middleware/Subscribed.php and update the handle method to this:

[...]
public function handle($request, Closure $next)
{
    if (!$request->user()->subscribed('main')) {
        if ($request->ajax() || $request->wantsJson()) {
            return response('Unauthorized.', 401);
        }
        return redirect('/plans');
    }

    return $next($request);
}
[...]

In the code block above, we are checking for users that are not subscribed to any plan. If that’s the case, whether using ajax or not, they will get the Unauthorized response or else be redirected to the plans’ index page. If a user is already subscribed to a plan, we’ll just proceed with the next request.

With this defined, let’s head over to app/Http/Kernel.php and register our middleware to the $routeMiddleware so we can call the middleware within our routes:

app/Http/Kernel.php

protected $routeMiddleware = [
    [...]
    'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
    'subscribed' => \App\Http\Middleware\Subscribed::class,
];

We can now create another route group inside the group with the auth middleware, but this time around, we pass in subscribed as the middleware. It’s inside this new route group where we will define the route to access lessons:

Route::group(['middleware' => 'auth'], function () {
    [...]
    Route::group(['middleware' => 'subscribed'], function () {
        Route::get('/lessons', 'LessonsController@index');
    });
});

If we try signing up a new user, then clicking on Lessons from the navbar, we will be redirected to /plans. If we subscribe to a plan with the new account, we now have access to lessons.

Protecting Premium Content from Basic Users

For our app, we want to provide basic and premium content. Users with basic plans will have access to normal lessons but only users with premium subscriptions will have access to premium lessons. For demonstration purposes, let’s create a premium method inside LessonsController:

[...]
public function premium()
{
    return 'Premium Lessons';
}
[...]

Let’s also update the navbar to include a link pointing to Premium Lessons:

[...]
<ul class="nav navbar-nav navbar-left">
    <li><a href="{{ url('/plans') }}">Plans</a></li>
    <li><a href="{{ url('/lessons') }}">Lessons</a></li>
    <li><a href="{{ url('/prolessons') }}">Premium Lessons</a></li>
</ul>
[...]

We could have placed the route to access the premium lessons inside the route group with the subscribed middleware but that will let anyone access premium content regardless of whether they are subscribed or not. We will have to create another middleware to prevent basic users from accessing premium content:

php artisan make:middleware PremiumSubscription

The code going into the PremiumSubscription middleware won’t be so different from the one we had in the Subscribed middleware. The only difference is that we’ll have to be plan specific when checking the user’s subscription status. If the user is not subscribed to the premium plan, they’ll be redirected to the plans’ index page:

app/Http/Middleware/Premium.php

[...]
public function handle($request, Closure $next)
{
    if (!$request->user()->subscribed('premium', 'main')) {
        if ($request->ajax() || $request->wantsJson()) {
            return response('Unauthorized.', 401);
        }
        return redirect('/plans');
    }

    return $next($request);
}
[...]

Let’s register this new middleware in the Kernel before creating a new route group for premium users:

app/Http/Kernel.php

protected $routeMiddleware = [
    [...]
    'subscribed' => \App\Http\Middleware\Subscribed::class,
    'premium-subscribed' => \App\Http\Middleware\PremiumSubscription::class,
];

Then, we create the new route group just below the group with the subscribed middleware:

Route::group(['middleware' => 'auth'], function () {
    [...]
    Route::group(['middleware' => 'premium-subscribed'], function () {
        Route::get('/prolessons', 'LessonsController@premium');
    });
});

And that’s it. Anyone with a basic subscription cannot access premium content, but premium users can access both premium and basic content. Next, we’ll look at how to cancel and resume subscriptions.

Cancelling Subscriptions

To enable cancelling and resuming subscriptions, we’ll have to create a new page. Let’s open our SubscriptionsController and add an index action. It is inside the index action where we will return the view to manage subscriptions:

[...]
class SubscriptionController extends Controller
{
    public function index()
    {
        return view('subscriptions.index');
    }
    [...]
}

Let’s first create a subscriptions folder inside views before creating the index view. Then, we paste the snippet below inside the view:

resources/views/subscriptions/index.blade.html

@extends('layouts.app')

@section('content')
<div class="container">
    <div class="row">
        <div class="col-md-8 col-md-offset-2">
            <div class="panel panel-default">
                <div class="panel-heading">Manage Subscriptions</div>

                <div class="panel-body">
                    ...
                </div>
            </div>
        </div>
    </div>
</div>
@endsection

We should then define a route pointing to this view. This route should be inside the route group with the subscribed middleware:

Route::group(['middleware' => 'subscribed'], function () {
    Route::get('/lessons', 'LessonsController@index');
    Route::get('/subscriptions', 'SubscriptionController@index');
});

Let’s update the dropdown in the navbar to include a link pointing to the index view we just created (place the link above the logout link). This link should only be visible to subscribed users:

resources/views/layouts/app.blade.html

<ul class="dropdown-menu" role="menu">
    [...]
    <li>
        @if (Auth::user()->subscribed('main'))
            <a href="{{ url('/subscriptions') }}">
                Manage subscriptions
            </a>
        @endif
    </li>
    [...]
</ul>

The next step is creating a link to enable users to cancel their subscriptions. For this, we’ll need a form. The form is important since we need to use CSRF protection to make sure the user who is cancelling the subscription is indeed the correct user. Let’s create the form inside the div with the class panel-body:

resources/views/subscriptions/index.blade.html

[...]
<div class="panel-body">
    @if (Auth::user()->subscription('main')->cancelled())
        <!-- Will create the form to resume a subscription later -->
    @else
        <p>You are currently subscribed to {{ Auth::user()->subscription('main')->braintree_plan }} plan</p>
        <form action="{{ url('subscription/cancel') }}" method="post">
            <button type="submit" class="btn btn-default">Cancel subscription</button>
            {{ csrf_field() }}
        </form>
    @endif
</div>
[...]

Notice how we have a conditional to check if the user has a cancelled subscription, and if that’s the case, we’ll leave them with the option to resume it. If the user still has an active subscription, that’s when the form to cancel will show up.

When a subscription is cancelled, Cashier will automatically set the ends_at column in our database. This column is used to know when the subscribed method should begin returning false. For example, if a customer cancels a subscription on March 1st, but the subscription was not scheduled to end until March 5th, the subscribed method will continue to return true until March 5th.

With the form to cancel subscriptions in place, we can now define the various controller actions and routes to handle the cancellation process.

In the SubscriptionsController, let’s add a cancel method:

[...]
class SubscriptionController extends Controller
{
    public function cancel(Request $request)
    {
        $request->user()->subscription('main')->cancel();

        return redirect()->back()->with('success', 'You have successfully cancelled your subscription');
    }
    [...]
}

Here, we are grabbing the user from the request object, getting their subscription, then calling Cashier’s cancel method on this subscription. After that, we redirect the user back to the same page but with a notification that the cancellation was successful.

We also need a route to handle the posting action of the form:

Route::group(['middleware' => 'subscribed'], function () {
    [...]
    Route::post('/subscription/cancel', 'SubscriptionController@cancel');
});

Resuming Subscriptions

The user must still be on their “grace period” in order to resume a subscription. If the user cancels a subscription and then resumes that subscription before the subscription has fully expired, they will not be billed immediately. Instead, their subscription will simply be re-activated, and they will be billed on the original billing cycle.

The procedure to resume a subscription is similar to what we did when cancelling a subscription. The only difference is we call the resume method on a subscription instead of the cancel method. We will also notify the user when the grace period is supposed to end on top of the form.

Let’s update our resources/views/subscriptions/index.blade.html view to include the form which lets users resume subscriptions:

<div class="panel-body">
    @if (Auth::user()->subscription('main')->cancelled())
        <p>Your subscription ends on {{ Auth::user()->subscription('main')->ends_at->format('dS M Y') }}</p>
        <form action="{{ url('subscription/resume') }}" method="post">
            <button type="submit" class="btn btn-default">Resume subscription</button>
            {{ csrf_field() }}
        </form>
    @else
    [...]

Then, we create the controller action and routes to handle the form’s posting action:

app/Http/controllers/SubscriptionsController.php

[...]
class SubscriptionController extends Controller
{
    [...]
    public function resume(Request $request)
    {
        $request->user()->subscription('main')->resume();

        return redirect()->back()->with('success', 'You have successfully resumed your subscription');
    }
    [...]
}

Let’s update our routes to accommodate the form’s posting action:

Route::group(['middleware' => 'subscribed'], function () {
    [...]
    Route::post('/subscription/cancel', 'SubscriptionController@cancel');
    Route::post('/subscription/resume', 'SubscriptionController@resume');
});

As you can see, Cashier makes it extremely easy to manage subscriptions. Next, we’ll look at how to set up webhooks for our application.

Webhooks

Both Stripe and Braintree can notify our application of a variety of events via webhooks. A webhook is a part of your application that can be called when an action happens on a third party service or some other server. In simpler terms, how does our application know if someone’s card was declined when we try to charge them for membership, or their card expires, or something else goes wrong? When that happens on Braintree, we want our application to know about it so we can cancel the subscription.

To handle Braintree webhooks, we define a route that points to Cashier’s webhook controller. This controller will handle all incoming webhook requests and dispatch them to the proper controller method:

Route::post(
    'braintree/webhooks',
    '\Laravel\Cashier\Http\Controllers\WebhookController@handleWebhook'
);

Note that the route is not inside any route group as we don’t need to be authenticated for this.

By default, the WebhookController will automatically handle cancelling subscriptions that have too many failed charges (as defined by your Braintree settings); however, you can extend this controller to handle any webhook event you like. You can read more on how to extend the WebhookController here.

Once we have registered this route, let’s configure the webhook URL in the Braintree control panel settings. There are a variety of events we can choose to be notified about, but for this tutorial, we will just stick to cancelled:

Webhooks and CSRF Protection

Since Braintree webhooks need to bypass Laravel’s CSRF protection, we have to list the URI as an exception in our VerifyCsrfToken middleware or list the route outside of the web middleware group:

app/Http/Middleware/VerifyCsrfToken.php

protected $except = [
    'braintree/*',
];

Testing Webhooks

So far, we’ve been developing our application locally, and its not possible to access the application from another computer.

To test if the webhooks are working as intended, we’ll have to use something like Ngrok to serve the application.

Ngrok is a handy tool and service that allows us to tunnel requests from the wide open Internet to our local machine when it’s behind a NAT or firewall. Serving the app via Ngrok will also provide us with a URL which lets anyone access our app from another machine. Let’s update Braintree’s webhook URL to use this new URL:

We can test things out by manually cancelling a subscription for a user from Braintree’s control panel. Once we cancel the subscription, Braintree will send this data to the webhook we just created, then Cashier will handle the rest.

If we now look at the data in the subscriptions table, we notice the ends_at column for the user whose subscription we just cancelled has been updated.

Conclusion

We’ve come a long way to get to this point. Our app now supports subscription features found in most websites. We might not have exhausted all the methods offered by Cashier, but as you might have noticed, Cashier makes it extremely easy to manage Braintree subscriptions thereby saving developers a lot of time.

Cashier is not limited to Braintree as it also supports Stripe. If you think we’ve failed to cover some of the more important features, or have your own implementations to demonstrate, please let us know in the comments!

Sponsors