Laravel & Braintree: Middleware and Other Advanced Concepts
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.
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!