If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
The reason that webhooks are so hard is... well, they're kind of impossible to test. There are certain things on Stripe - like my card being declined during a renewal, which are really hard to simulate. And even if we could, Stripe can't send a webhook to my local computer. Well actually, that last part isn't entirely true, but more on that later.
So let's look at a few strategies for making sure that your webhooks are air tight. I do not want to mess something up with these.
The first strategy - and the one we use here on KnpU - is to create an automated test that sends a fake webhook to the URL.
To start, let's install PHPUnit into the project:
composer require phpunit/phpunit --dev
While Jordi is working on that, go back to the code and find a tutorials
directory.
This is a special directory I created and you should have it if you downloaded the
start code from this page. It has a few things to make our life easier.
Copy the WebhookControllerTest.php
file and put it into the tests/AppBundle/Controller
directory. Let's check this out:
... lines 1 - 2 | |
namespace Tests\AppBundle\Controller; | |
use AppBundle\Entity\Subscription; | |
use AppBundle\Entity\User; | |
use Doctrine\ORM\EntityManager; | |
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; | |
class WebhookControllerTest extends WebTestCase | |
{ | |
private $container; | |
/** @var EntityManager */ | |
private $em; | |
public function setUp() | |
{ | |
self::bootKernel(); | |
$this->container = self::$kernel->getContainer(); | |
$this->em = $this->container->get('doctrine')->getManager(); | |
} | |
private function createSubscription() | |
{ | |
$user = new User(); | |
$user->setEmail('fluffy'.mt_rand().'@sheep.com'); | |
$user->setUsername('fluffy'.mt_rand()); | |
$user->setPlainPassword('baa'); | |
$subscription = new Subscription(); | |
$subscription->setUser($user); | |
$subscription->activateSubscription( | |
'plan_STRIPE_TEST_ABC'.mt_rand(), | |
'sub_STRIPE_TEST_XYZ'.mt_rand(), | |
new \DateTime('+1 month') | |
); | |
$this->em->persist($user); | |
$this->em->persist($subscription); | |
$this->em->flush(); | |
return $subscription; | |
} | |
} |
This is the start of a test that's specifically written for Symfony. If you're not using Symfony, the code will look different, but the idea is fundamentally the same.
This test boots Symfony's kernel so that we have access to its container, and all
the useful objects inside, like the entity manager! I also added a private function
called createSubscription()
. We're not using this yet, but by calling it, it will
create a new user in the database, give that user an active subscription, and save
everything. This won't be a real subscription in Stripe - it'll just live in our
database, and will have a random stripeSubscriptionId
.
Because here's the strategy:
/webhooks/stripe
with fake JSON, where the
subscription id in the JSON matches the fake data in the database;Add the test: public function testStripeCustomerSubscriptionDeleted()
:
... lines 1 - 9 | |
class WebhookControllerTest extends WebTestCase | |
{ | |
... lines 12 - 22 | |
public function testStripeCustomerSubscriptionDeleted() | |
{ | |
... lines 25 - 29 | |
} | |
... lines 31 - 52 | |
} |
OK, step 1: create the subscription in the database: $subscription = $this->createSubscription()
.
Easy! Step 2: send the webhook... which I'll put as a TODO for now. Then step 3:
$this->assertFalse()
that $subscription->isActive()
:
... lines 1 - 9 | |
class WebhookControllerTest extends WebTestCase | |
{ | |
... lines 12 - 22 | |
public function testStripeCustomerSubscriptionDeleted() | |
{ | |
$subscription = $this->createSubscription(); | |
// todo - send the cancellation webhook | |
$this->assertFalse($subscription->isActive()); | |
} | |
... lines 31 - 52 | |
} |
Make sense?
To send the webhook, we first need to prepare a JSON string that matches what Stripe
sends. At the bottom of the class, create a new private function called
getCustomerSubscriptionDeletedEvent()
with a $subscriptionId
argument:
... lines 1 - 9 | |
class WebhookControllerTest extends WebTestCase | |
{ | |
... lines 12 - 67 | |
private function getCustomerSubscriptionDeletedEvent($subscriptionId) | |
{ | |
... lines 70 - 122 | |
} | |
} |
To fill this in, go copy the real JSON from the test webhook. Paste it here with
$json = <<<EOF
enter, and paste!
... lines 1 - 9 | |
class WebhookControllerTest extends WebTestCase | |
{ | |
... lines 12 - 67 | |
private function getCustomerSubscriptionDeletedEvent($subscriptionId) | |
{ | |
$json = <<<EOF | |
{ | |
"created": 1326853478, | |
"livemode": false, | |
"id": "evt_00000000000000", | |
"type": "customer.subscription.deleted", | |
"object": "event", | |
"request": null, | |
"pending_webhooks": 1, | |
"api_version": "2016-07-06", | |
"data": { | |
"object": { | |
"id": "%s", | |
"object": "subscription", | |
"application_fee_percent": null, | |
"cancel_at_period_end": true, | |
"canceled_at": 1469731697, | |
"created": 1469729305, | |
"current_period_end": 1472407705, | |
"current_period_start": 1469729305, | |
"customer": "cus_00000000000000", | |
"discount": null, | |
"ended_at": 1470436151, | |
"livemode": false, | |
"metadata": { | |
}, | |
"plan": { | |
"id": "farmer_00000000000000", | |
"object": "plan", | |
"amount": 9900, | |
"created": 1469720306, | |
"currency": "usd", | |
"interval": "month", | |
"interval_count": 1, | |
"livemode": false, | |
"metadata": { | |
}, | |
"name": "Farmer Brent (monthly)", | |
"statement_descriptor": null, | |
"trial_period_days": null | |
}, | |
"quantity": 1, | |
"start": 1469729305, | |
"status": "canceled", | |
"tax_percent": null, | |
"trial_end": null, | |
"trial_start": null | |
} | |
} | |
} | |
EOF; | |
... lines 121 - 122 | |
} | |
} |
Now here's the important part: our controller reads the data.object.id
key to find
the subscription id. Replace this with %s
:
... lines 1 - 9 | |
class WebhookControllerTest extends WebTestCase | |
{ | |
... lines 12 - 67 | |
private function getCustomerSubscriptionDeletedEvent($subscriptionId) | |
{ | |
$json = <<<EOF | |
{ | |
... lines 72 - 79 | |
"data": { | |
"object": { | |
"id": "%s", | |
... lines 83 - 94 | |
}, | |
... lines 96 - 116 | |
} | |
} | |
} | |
EOF; | |
... lines 121 - 122 | |
} | |
} |
Then, finish the function with return sprintf($json, $subscriptionId)
:
... lines 1 - 9 | |
class WebhookControllerTest extends WebTestCase | |
{ | |
... lines 12 - 67 | |
private function getCustomerSubscriptionDeletedEvent($subscriptionId) | |
{ | |
$json = <<<EOF | |
... lines 71 - 119 | |
EOF; | |
return sprintf($json, $subscriptionId); | |
} | |
} |
Now, this function will create a realistic-looking JSON string, but with whatever subscriptionId
we want!
Back in the test function, add $eventJson = $this->getCustomerSubscriptionDeletedEvent()
and pass it $subscription->getStripeSubscriptionId()
, which is some fake, random
value that the function below created:
... lines 1 - 9 | |
class WebhookControllerTest extends WebTestCase | |
{ | |
... lines 12 - 22 | |
public function testStripeCustomerSubscriptionDeleted() | |
{ | |
$subscription = $this->createSubscription(); | |
$eventJson = $this->getCustomerSubscriptionDeletedEvent( | |
$subscription->getStripeSubscriptionId() | |
); | |
... lines 30 - 42 | |
$this->assertFalse($subscription->isActive()); | |
} | |
... lines 45 - 123 | |
} |
To send the request, create a $client
variable set to $this->createClient()
.
This is Symfony's internal HTTP client: its job is to make requests to our app.
If you want, you can also use something different, like Guzzle. It doesn't really
matter because - one way or another - you just need to make an HTTP request to the
endpoint.
Now for the magic: call $client->request()
and pass it a bunch of arguments:
POST
for the HTTP method, /webhooks/stripe
, then a few empty arrays for parameters,
files and server. Finally, for the $content
argument - the body of the request -
pass it $eventJson
:
... lines 1 - 9 | |
class WebhookControllerTest extends WebTestCase | |
{ | |
... lines 12 - 22 | |
public function testStripeCustomerSubscriptionDeleted() | |
{ | |
$subscription = $this->createSubscription(); | |
$eventJson = $this->getCustomerSubscriptionDeletedEvent( | |
$subscription->getStripeSubscriptionId() | |
); | |
$client = $this->createClient(); | |
$client->request( | |
'POST', | |
'/webhooks/stripe', | |
[], | |
[], | |
[], | |
$eventJson | |
); | |
... lines 40 - 42 | |
$this->assertFalse($subscription->isActive()); | |
} | |
... lines 45 - 123 | |
} |
And because things almost never work for me on the first try... and because I know
this won't work yet, let's dump($client->getResponse()->getContent())
to see what
happened in case there's an error. Also add a sanity check, $this->assertEquals()
that 200 matches $client->getResponse()->getStatusCode()
:
... lines 1 - 9 | |
class WebhookControllerTest extends WebTestCase | |
{ | |
... lines 12 - 22 | |
public function testStripeCustomerSubscriptionDeleted() | |
{ | |
... lines 25 - 31 | |
$client->request( | |
'POST', | |
'/webhooks/stripe', | |
[], | |
[], | |
[], | |
$eventJson | |
); | |
dump($client->getResponse()->getContent()); | |
$this->assertEquals(200, $client->getResponse()->getStatusCode()); | |
$this->assertFalse($subscription->isActive()); | |
} | |
... lines 45 - 123 | |
} |
Let's run the test! But not in this video... this video is getting too long. So go get some more coffee and then come back. Then, to the test!
"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
}
}