If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
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.
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.
"Houston: no signs of life"
Start the conversation!
// 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
}
}