Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine
This course is archived!
This tutorial uses an older version of Symfony of the stripe-php SDK. The majority of the concepts are still valid, though there *are* differences. We've done our best to add notes & comments that describe these changes.

Webhook: Subscription Canceled

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

Eventually, this function will handle several event types. To handle each, create a switch-case statement: switch ($stripeEvent->type):

... lines 1 - 8
class WebhookController extends BaseController
{
... lines 11 - 13
public function stripeWebhookAction(Request $request)
{
... lines 16 - 22
$stripeEvent = $this->get('stripe_client')
->findEvent($eventId);
switch ($stripeEvent->type) {
... lines 27 - 31
}
... lines 33 - 34
}
}

That's the field that'll hold one of those many event types we saw earlier.

The first type we'll handle is customer.subscription.deleted. We'll fill in the logic here in a second. Add the break:

... lines 1 - 8
class WebhookController extends BaseController
{
... lines 11 - 13
public function stripeWebhookAction(Request $request)
{
... lines 16 - 22
$stripeEvent = $this->get('stripe_client')
->findEvent($eventId);
switch ($stripeEvent->type) {
case 'customer.subscription.deleted':
// todo - fully cancel the user's subscription
break;
... lines 30 - 31
}
... lines 33 - 34
}
}

We shouldn't receive any other event types because of how we configured the webhook, but just in case, throw an Exception: "Unexpected webhook from Stripe" and pass the type:

... lines 1 - 8
class WebhookController extends BaseController
{
... lines 11 - 13
public function stripeWebhookAction(Request $request)
{
... lines 16 - 22
$stripeEvent = $this->get('stripe_client')
->findEvent($eventId);
switch ($stripeEvent->type) {
case 'customer.subscription.deleted':
// todo - fully cancel the user's subscription
break;
default:
throw new \Exception('Unexpected webhook type form Stripe! '.$stripeEvent->type);
}
... lines 33 - 34
}
}

At the bottom, well, we can return anything back to Stripe. How about a nice message: "Event Handled" and then the type. Well, there is one important piece: you must return a 200-level status code. If you return a non 200 status code, Stripe will think the webhook failed and will try to send it again, over and over again. But 200 means:

Yo Stripe, it's cool - I heard you, I handled it.

Quick! Cancel the Subscription!

Alright, let's cancel the subscription! First, we need to find the Subscription in our database. And check this out: the subscription id lives at data.object.id. That's because this type of event embeds the subscription in question. Other event types will embed different data.

Add $stripeSubscriptionId = $stripeEvent->data->object->id:

... lines 1 - 8
class WebhookController extends BaseController
{
... lines 11 - 13
public function stripeWebhookAction(Request $request)
{
... lines 16 - 26
switch ($stripeEvent->type) {
case 'customer.subscription.deleted':
$stripeSubscriptionId = $stripeEvent->data->object->id;
... lines 30 - 33
break;
default:
throw new \Exception('Unexpected webhook type form Stripe! '.$stripeEvent->type);
}
... lines 38 - 39
}
... lines 41 - 60
}

Next, the subscription table has a stripeSubscriptionId field on it. Let's query on this! Because I already know I'll want to re-use this next code, I'll put the logic into a private function. On this line, call that future function with $subscription = $this->findSubscription() and pass it $stripeSubscriptionId:

... lines 1 - 8
class WebhookController extends BaseController
{
... lines 11 - 13
public function stripeWebhookAction(Request $request)
{
... lines 16 - 26
switch ($stripeEvent->type) {
case 'customer.subscription.deleted':
$stripeSubscriptionId = $stripeEvent->data->object->id;
$subscription = $this->findSubscription($stripeSubscriptionId);
... lines 31 - 33
break;
default:
throw new \Exception('Unexpected webhook type form Stripe! '.$stripeEvent->type);
}
... lines 38 - 39
}
... lines 41 - 60
}

Scroll down and create this: private function findSubscription() with its $stripeSubscriptionId argument:

... lines 1 - 8
class WebhookController extends BaseController
{
... lines 11 - 41
/**
* @param $stripeSubscriptionId
* @return \AppBundle\Entity\Subscription
* @throws \Exception
*/
private function findSubscription($stripeSubscriptionId)
{
... lines 49 - 59
}
}

Query by adding $subscription = $this->getDoctrine()->getRepository('AppBundle:Subscription') and then findOneBy() passing this an array with one item: stripeSubscriptionId - the field name to query on - set to $stripeSubscriptionId:

... lines 1 - 8
class WebhookController extends BaseController
{
... lines 11 - 46
private function findSubscription($stripeSubscriptionId)
{
$subscription = $this->getDoctrine()
->getRepository('AppBundle:Subscription')
->findOneBy([
'stripeSubscriptionId' => $stripeSubscriptionId
]);
if (!$subscription) {
throw new \Exception('Somehow we have no subscription id ' . $stripeSubscriptionId);
}
... lines 58 - 59
}
}

If there is no matching Subscription... well, that shouldn't happen! But just in case, throw a new Exception with a really confused message. Something is not right.

Finally, return the $subscription on the bottom:

... lines 1 - 8
class WebhookController extends BaseController
{
... lines 11 - 46
private function findSubscription($stripeSubscriptionId)
{
$subscription = $this->getDoctrine()
->getRepository('AppBundle:Subscription')
->findOneBy([
'stripeSubscriptionId' => $stripeSubscriptionId
]);
if (!$subscription) {
throw new \Exception('Somehow we have no subscription id ' . $stripeSubscriptionId);
}
return $subscription;
}
}

Ok, head back up to the action method. Hmm, so all we really need to do now is call the cancel() method on $subscription! But let's get a little bit more organized. Open SubscriptionHelper and add a new method there: public function fullyCancelSubscription() with the Subscription object that should be canceled. Below, really simple, say $subscription->cancel(). Then, use the Doctrine entity manager to save this to the database:

... lines 1 - 8
class SubscriptionHelper
{
... lines 11 - 73
public function fullyCancelSubscription(Subscription $subscription)
{
$subscription->cancel();
$this->em->persist($subscription);
$this->em->flush($subscription);
}
}

Mind blown!

Back in the controller, call this! Above the switch statement, add a $subscriptionHelper variable set to $this->get('subscription_helper'):

... lines 1 - 8
class WebhookController extends BaseController
{
... lines 11 - 13
public function stripeWebhookAction(Request $request)
{
... lines 16 - 25
$subscriptionHelper = $this->get('subscription_helper');
switch ($stripeEvent->type) {
... lines 28 - 36
}
... lines 38 - 39
}
... lines 41 - 60
}

Finally, call $subscriptionHelper->fullyCancelSubscription($subscription):

... lines 1 - 8
class WebhookController extends BaseController
{
... lines 11 - 13
public function stripeWebhookAction(Request $request)
{
... lines 16 - 25
$subscriptionHelper = $this->get('subscription_helper');
switch ($stripeEvent->type) {
case 'customer.subscription.deleted':
$stripeSubscriptionId = $stripeEvent->data->object->id;
$subscription = $this->findSubscription($stripeSubscriptionId);
$subscriptionHelper->fullyCancelSubscription($subscription);
break;
default:
throw new \Exception('Unexpected webhook type form Stripe! '.$stripeEvent->type);
}
return new Response('Event Handled: '.$stripeEvent->type);
}
... lines 41 - 60
}

And that is it!

Yep, there's some setup to get the webhook controller started, but now we're in really good shape.

Of course... we have no way to test this... So, ya know, just make sure you do a really good job of coding and hope for the best! No, that's crazy!, I'll show you a few ways to test this next. But also, don't forget to configure your webhook URL in Stripe once you finally deploy this to beta and production. I have a webhook setup for each instance on KnpU.

Leave a comment!

0
Login or Register to join the conversation
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial uses an older version of Symfony of the stripe-php SDK. The majority of the concepts are still valid, though there *are* differences. We've done our best to add notes & comments that describe these changes.

What PHP libraries does this tutorial use?

// 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
    }
}
userVoice