If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeBad news: eventually, someone will want to cancel their subscription to your amazing, awesome service. So sad. But when that happens, let's make it as smooth as possible. Remember: happy customers!
Like everything we do, cancelling a subscription has two parts. First we need to cancel it inside of Stripe and second, we need to update our database, so we know that this user no longer has a subscription.
Start by adding a cancel button to the account page. In account.html.twig
, let's move the h1
down a bit:
... lines 1 - 2 | |
{% block body %} | |
<div class="nav-space"> | |
<div class="container"> | |
<div class="row"> | |
<div class="col-xs-6"> | |
<h1> | |
My Account | |
... lines 10 - 15 | |
</h1> | |
... lines 17 - 51 | |
</div> | |
... lines 53 - 55 | |
</div> | |
</div> | |
</div> | |
{% endblock %} | |
... lines 60 - 61 |
Next, add a form with method="POST"
and make this float right:
... lines 1 - 2 | |
{% block body %} | |
<div class="nav-space"> | |
<div class="container"> | |
<div class="row"> | |
<div class="col-xs-6"> | |
<h1> | |
My Account | |
{% if app.user.subscription %} | |
<form action="{{ path('account_subscription_cancel') }}" method="POST" class="pull-right"> | |
<button type="submit" class="btn btn-danger btn-xs">Cancel Subscription</button> | |
</form> | |
{% endif %} | |
</h1> | |
... lines 17 - 51 | |
</div> | |
... lines 53 - 55 | |
</div> | |
</div> | |
</div> | |
{% endblock %} | |
... lines 60 - 61 |
We don't actually need a form, but now we can put a button inside and this will POST up to our server. I don't always do this right, but since this action will change something on the server, it's best done with a POST request. Add a few classes for styling and say "Cancel Subscription".
I still need to set the action
attribute to some URL... but we need to create that endpoint first!
Open ProfileController
. This file renders the account page, but we're also going to put code in here to handle some other things on this page, like cancelling a subscription and updating your credit card.
Create a new public function cancelSubscriptionAction()
. Give this a URL: @Route("/profile/subscription/cancel")
and a name: account_subscription_cancel
:
... lines 1 - 11 | |
class ProfileController extends BaseController | |
{ | |
... lines 14 - 21 | |
/** | |
* @Route("/profile/subscription/cancel", name="account_subscription_cancel") | |
... line 24 | |
*/ | |
public function cancelSubscriptionAction() | |
{ | |
... lines 28 - 33 | |
} | |
} |
And, since we'll POST here, we might as well require a POST with @Method
- hit tab to autocomplete and add the use
statement - then POST
:
... lines 1 - 4 | |
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method; | |
... lines 6 - 11 | |
class ProfileController extends BaseController | |
{ | |
... lines 14 - 21 | |
/** | |
* @Route("/profile/subscription/cancel", name="account_subscription_cancel") | |
* @Method("POST") | |
*/ | |
public function cancelSubscriptionAction() | |
{ | |
... lines 28 - 33 | |
} | |
} |
With the endpoint setup, copy the route name and go back into the template. Update action
, with path()
then paste the route:
... lines 1 - 2 | |
{% block body %} | |
<div class="nav-space"> | |
<div class="container"> | |
<div class="row"> | |
<div class="col-xs-6"> | |
<h1> | |
... lines 9 - 10 | |
{% if app.user.subscription %} | |
<form action="{{ path('account_subscription_cancel') }}" method="POST" class="pull-right"> | |
... line 13 | |
</form> | |
{% endif %} | |
</h1> | |
... lines 17 - 51 | |
</div> | |
... lines 53 - 55 | |
</div> | |
</div> | |
</div> | |
{% endblock %} | |
... lines 60 - 61 |
And we are setup!
Now, back to step 1: cancel the Subscription in Stripe. Go back to Stripe's documentation and find the section about Cancelling Subscriptions - it'll look a little different than what you see here... because Stripe updated their design right after I recorded. Doh! But, all the same info is there.
Ok, this is simple: retrieve a subscription and then call cancel()
on it. Yes! So easy!
Tip
Since 2018-07-27, Stripe changed the way you cancel a subscription at period end. Use this code for > the updated API:
$sub->cancel_at_period_end = true;
$sub->save();
Or not easy: because you might want to pass this an at_period_end
option set to true. Here's the story: by default, when you cancel a subscription in Stripe, it cancels it immediately. But, by passing at_period_end
set to true, you're saying:
Hey! Don't cancel their subscription now, let them finish the month and then cancel it.
This is probably what you want: after all, your customer already paid for this month, so you'll want them to keep getting the service until its over.
So let's do this! Remember: we've organized things so that all Stripe API code lives inside the StripeClient
object. Fetch that first with $stripeClient = $this->get('stripe_client')
:
... lines 1 - 11 | |
class ProfileController extends BaseController | |
{ | |
... lines 14 - 25 | |
public function cancelSubscriptionAction() | |
{ | |
$stripeClient = $this->get('stripe_client'); | |
... lines 29 - 33 | |
} | |
} |
Next, open this class, find the bottom, and add a new method: public function cancelSubscription()
with one argument: the User
object whose subscription should be cancelled:
... lines 1 - 8 | |
class StripeClient | |
{ | |
... lines 11 - 77 | |
public function cancelSubscription(User $user) | |
{ | |
... lines 80 - 86 | |
} | |
} |
For the code inside - go copy and steal the code from the docs! Yes! Replace the hard-coded subscription id with $user->getSubscription()->getStripeSubscriptionId()
.
... lines 1 - 8 | |
class StripeClient | |
{ | |
... lines 11 - 77 | |
public function cancelSubscription(User $user) | |
{ | |
$sub = \Stripe\Subscription::retrieve( | |
$user->getSubscription()->getStripeSubscriptionId() | |
); | |
... lines 83 - 86 | |
} | |
} |
Then, cancel it at period end:
... lines 1 - 8 | |
class StripeClient | |
{ | |
... lines 11 - 77 | |
public function cancelSubscription(User $user) | |
{ | |
$sub = \Stripe\Subscription::retrieve( | |
$user->getSubscription()->getStripeSubscriptionId() | |
); | |
$sub->cancel([ | |
'at_period_end' => true, | |
]); | |
} | |
} |
Back in ProfileController
, use this! $stripeClient->cancelSubscription()
with $this->getUser()
to get the currently-logged-in-user:
... lines 1 - 11 | |
class ProfileController extends BaseController | |
{ | |
... lines 14 - 25 | |
public function cancelSubscriptionAction() | |
{ | |
$stripeClient = $this->get('stripe_client'); | |
$stripeClient->cancelSubscription($this->getUser()); | |
... lines 30 - 33 | |
} | |
} |
Then, to express how sad we are, add a heard-breaking flash message. Then, redirect back to profile_account
:
... lines 1 - 11 | |
class ProfileController extends BaseController | |
{ | |
... lines 14 - 25 | |
public function cancelSubscriptionAction() | |
{ | |
$stripeClient = $this->get('stripe_client'); | |
$stripeClient->cancelSubscription($this->getUser()); | |
$this->addFlash('success', 'Subscription Canceled :('); | |
return $this->redirectToRoute('profile_account'); | |
} | |
} |
We've done it! But don't test it yet: we still need to do step 2: update our database to reflect the cancellation.
// composer.json
{
"require": {
"php": ">=5.5.9, <7.4",
"symfony/symfony": "3.1.*", // v3.1.10
"doctrine/orm": "^2.5", // v2.7.2
"doctrine/doctrine-bundle": "^1.6", // 1.6.8
"doctrine/doctrine-cache-bundle": "^1.2", // 1.3.0
"symfony/swiftmailer-bundle": "^2.3", // v2.6.2
"symfony/monolog-bundle": "^2.8", // v2.12.1
"symfony/polyfill-apcu": "^1.0", // v1.3.0
"sensio/distribution-bundle": "^5.0", // v5.0.22
"sensio/framework-extra-bundle": "^3.0.2", // v3.0.26
"incenteev/composer-parameter-handler": "^2.0", // v2.1.2
"friendsofsymfony/user-bundle": "~2.0.1", // v2.0.1
"stof/doctrine-extensions-bundle": "^1.2", // v1.2.2
"stripe/stripe-php": "^3.15", // v3.23.0
"doctrine/doctrine-migrations-bundle": "^1.1", // v1.2.1
"phpunit/phpunit": "^5.5", // 5.7.20
"composer/package-versions-deprecated": "^1.11" // 1.11.99
},
"require-dev": {
"sensio/generator-bundle": "^3.0", // v3.1.4
"symfony/phpunit-bridge": "^3.0", // v3.3.0
"hautelook/alice-bundle": "^1.3", // v1.4.1
"doctrine/data-fixtures": "^1.2" // v1.2.2
}
}