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 second webhook type we need to handle is called invoice.payment_succeeded
.
This one fires when a subscription is successfully renewed. Well, actually,
it fires whenever any invoice is paid, but we'll sort that out later.
This webhook is important to us for 2 reasons.
First, each subscription has a $billingPeriodEndsAt
value that we use to show the
user when Stripe will charge them next:
... lines 1 - 10 | |
class Subscription | |
{ | |
... lines 13 - 40 | |
/** | |
* @ORM\Column(type="datetime", nullable=true) | |
*/ | |
private $billingPeriodEndsAt; | |
... lines 45 - 129 | |
} |
Obviously, that needs to be updated each month!
Second, when you charge your customer, you should probably send them a nice email about it, and maybe even attach a receipt! So let's get this setup.
Right now, Stripe is not sending us this webhook type. In the dashboard, update the RequestBin webhook and set it to receive all webhooks, instead of just the few that we select. You don't need to do this, but it does make it easier to keep your various webhooks - like for your staging and production servers - identical.
But now we need to do some work in WebhookController
. In the default
section
of the switch-case, we will now receive unsupported webhooks, and that's cool!
Remove the exception:
... lines 1 - 8 | |
class WebhookController extends BaseController | |
{ | |
... lines 11 - 13 | |
public function stripeWebhookAction(Request $request) | |
{ | |
... lines 16 - 31 | |
switch ($stripeEvent->type) { | |
... lines 33 - 50 | |
default: | |
// allow this - we'll have Stripe send us everything | |
// throw new \Exception('Unexpected webhook type form Stripe! '.$stripeEvent->type); | |
} | |
... lines 55 - 56 | |
} | |
... lines 58 - 77 | |
} |
Each webhook type has a JSON body that looks a little different. Head back to the
dashboard to send a test webhook, this time for the invoice.payment_succeeded
event
. Hit "Send test webhook" and then go refresh RequestBin.
Hmm, ok. This time, the embedded object is an invoice. But we will need to know the Stripe subscription ID that this invoice is for. And that's tricky: an invoice may not actually contain a subscription. If you just buy some products on our site, that creates an invoice... but with no subscription.
Fortunately, the data
key covers this: it has a subscription
field. This will
either be blank if there's no subscription or it will hold the subscription ID.
In other words, it's perfect!
Back in WebhookController
and add a second case
statement invoice.payment_succeeded
.
Add the break
, then let's get to work:
... lines 1 - 8 | |
class WebhookController extends BaseController | |
{ | |
... lines 11 - 13 | |
public function stripeWebhookAction(Request $request) | |
{ | |
... lines 16 - 31 | |
switch ($stripeEvent->type) { | |
... lines 33 - 39 | |
case 'invoice.payment_succeeded': | |
... lines 41 - 49 | |
break; | |
default: | |
// allow this - we'll have Stripe send us everything | |
// throw new \Exception('Unexpected webhook type form Stripe! '.$stripeEvent->type); | |
} | |
... lines 55 - 56 | |
} | |
... lines 58 - 77 | |
} |
First, grab the subscription ID with $stripeSubscriptionId = $stripeEvent->data->object->subscription
:
... lines 1 - 8 | |
class WebhookController extends BaseController | |
{ | |
... lines 11 - 13 | |
public function stripeWebhookAction(Request $request) | |
{ | |
... lines 16 - 31 | |
switch ($stripeEvent->type) { | |
... lines 33 - 39 | |
case 'invoice.payment_succeeded': | |
$stripeSubscriptionId = $stripeEvent->data->object->subscription; | |
... lines 42 - 49 | |
break; | |
default: | |
// allow this - we'll have Stripe send us everything | |
// throw new \Exception('Unexpected webhook type form Stripe! '.$stripeEvent->type); | |
} | |
... lines 55 - 56 | |
} | |
... lines 58 - 77 | |
} |
Next, if there is a $stripeSubscriptionId
, then we need to load the corresponding
Subscription
from our database. Re-use $this->findSubscription()
from earlier
to do that:
... lines 1 - 8 | |
class WebhookController extends BaseController | |
{ | |
... lines 11 - 13 | |
public function stripeWebhookAction(Request $request) | |
{ | |
... lines 16 - 31 | |
switch ($stripeEvent->type) { | |
... lines 33 - 39 | |
case 'invoice.payment_succeeded': | |
$stripeSubscriptionId = $stripeEvent->data->object->subscription; | |
if ($stripeSubscriptionId) { | |
$subscription = $this->findSubscription($stripeSubscriptionId); | |
... lines 45 - 48 | |
} | |
break; | |
default: | |
// allow this - we'll have Stripe send us everything | |
// throw new \Exception('Unexpected webhook type form Stripe! '.$stripeEvent->type); | |
} | |
... lines 55 - 56 | |
} | |
... lines 58 - 77 | |
} |
Remember: the goal is to update this Subscription row to have the new billingPeriodEndsAt
.
But the event's data doesn't have that date! No problem: if we fetch a fresh, full
Subscription
object from Stripe's API, we can use its current_period_end
field.
Open up StripeClient
and add a new public function findSubscription()
with a
$stripeSubscriptionId
argument:
... lines 1 - 8 | |
class StripeClient | |
{ | |
... lines 11 - 126 | |
/** | |
* @param $stripeSubscriptionId | |
... line 129 | |
*/ | |
public function findSubscription($stripeSubscriptionId) | |
{ | |
... line 133 | |
} | |
} |
Make this return the classic \Stripe\Subscription::retrieve($stripeSubscriptionId)
:
... lines 1 - 8 | |
class StripeClient | |
{ | |
... lines 11 - 126 | |
/** | |
* @param $stripeSubscriptionId | |
* @return \Stripe\Subscription | |
*/ | |
public function findSubscription($stripeSubscriptionId) | |
{ | |
return \Stripe\Subscription::retrieve($stripeSubscriptionId); | |
} | |
} |
Cool! Back in the controller, add $stripeSubscription = $this->get('stripe_client')->findSubscription()
and pass it $stripeSubscriptionId
:
... lines 1 - 8 | |
class WebhookController extends BaseController | |
{ | |
... lines 11 - 31 | |
switch ($stripeEvent->type) { | |
... lines 33 - 39 | |
case 'invoice.payment_succeeded': | |
$stripeSubscriptionId = $stripeEvent->data->object->subscription; | |
if ($stripeSubscriptionId) { | |
$subscription = $this->findSubscription($stripeSubscriptionId); | |
$stripeSubscription = $this->get('stripe_client') | |
->findSubscription($stripeSubscriptionId); | |
... lines 47 - 48 | |
} | |
break; | |
default: | |
// allow this - we'll have Stripe send us everything | |
// throw new \Exception('Unexpected webhook type form Stripe! '.$stripeEvent->type); | |
} | |
... lines 55 - 77 | |
} |
Finally, let's update the Subscription in our database by using the data on the
Stripe subscription object. As usual, we'll add this logic to SubscriptionHelper
so we can reuse it later.
Add a new public function called handleSubscriptionPaid()
that has two arguments:
the Subscription
object that just got paid and the related \Stripe\Subscription
object that holds the updated details:
... lines 1 - 8 | |
class SubscriptionHelper | |
{ | |
... lines 11 - 80 | |
public function handleSubscriptionPaid(Subscription $subscription, \Stripe\Subscription $stripeSubscription) | |
{ | |
... lines 83 - 87 | |
} | |
} |
Then, we need to read the current_period_end
field. But wait! We totally did this
earlier in addSubscriptionToUser()
. Steal that line! Paste it here, but rename
the variable to $newPeriodEnd
:
... lines 1 - 8 | |
class SubscriptionHelper | |
{ | |
... lines 11 - 80 | |
public function handleSubscriptionPaid(Subscription $subscription, \Stripe\Subscription $stripeSubscription) | |
{ | |
$newPeriodEnd = \DateTime::createFromFormat('U', $stripeSubscription->current_period_end); | |
... lines 84 - 87 | |
} | |
} |
Now, set this billingPeriodEndsAt
field via $subscription->setBillingPeriodEnds()
:
... lines 1 - 8 | |
class SubscriptionHelper | |
{ | |
... lines 11 - 80 | |
public function handleSubscriptionPaid(Subscription $subscription, \Stripe\Subscription $stripeSubscription) | |
{ | |
$newPeriodEnd = \DateTime::createFromFormat('U', $stripeSubscription->current_period_end); | |
$subscription->setBillingPeriodEndsAt($newPeriodEnd); | |
... lines 86 - 87 | |
} | |
} |
But wait! Where's my auto-completion! Oh, that method doesn't exist yet. In Subscription
,
I'll use the "Code"->"Generate" shortcut to select "Setters" and generate this setter:
... lines 1 - 10 | |
class Subscription | |
{ | |
... lines 13 - 130 | |
public function setBillingPeriodEndsAt($billingPeriodEndsAt) | |
{ | |
$this->billingPeriodEndsAt = $billingPeriodEndsAt; | |
} | |
} |
Whoops, then update the method in SubscriptionHelper
to be setBillingPeriodEndsAt()
:
... lines 1 - 8 | |
class SubscriptionHelper | |
{ | |
... lines 11 - 80 | |
public function handleSubscriptionPaid(Subscription $subscription, \Stripe\Subscription $stripeSubscription) | |
{ | |
$newPeriodEnd = \DateTime::createFromFormat('U', $stripeSubscription->current_period_end); | |
$subscription->setBillingPeriodEndsAt($newPeriodEnd); | |
... lines 86 - 87 | |
} | |
} |
Finally, celebrate! Persist and flush the Subscription changes to the database:
... lines 1 - 8 | |
class SubscriptionHelper | |
{ | |
... lines 11 - 80 | |
public function handleSubscriptionPaid(Subscription $subscription, \Stripe\Subscription $stripeSubscription) | |
{ | |
$newPeriodEnd = \DateTime::createFromFormat('U', $stripeSubscription->current_period_end); | |
$subscription->setBillingPeriodEndsAt($newPeriodEnd); | |
$this->em->persist($subscription); | |
$this->em->flush($subscription); | |
} | |
} |
Back in your controller, call this: $subscriptionHelper->handleSubscriptionPaid()
and pass it $subscription
and $stripeSubscription
:
... lines 1 - 8 | |
class WebhookController extends BaseController | |
{ | |
... lines 11 - 13 | |
public function stripeWebhookAction(Request $request) | |
{ | |
... lines 16 - 31 | |
switch ($stripeEvent->type) { | |
... lines 33 - 39 | |
case 'invoice.payment_succeeded': | |
$stripeSubscriptionId = $stripeEvent->data->object->subscription; | |
if ($stripeSubscriptionId) { | |
$subscription = $this->findSubscription($stripeSubscriptionId); | |
$stripeSubscription = $this->get('stripe_client') | |
->findSubscription($stripeSubscriptionId); | |
$subscriptionHelper->handleSubscriptionPaid($subscription, $stripeSubscription); | |
} | |
break; | |
default: | |
// allow this - we'll have Stripe send us everything | |
// throw new \Exception('Unexpected webhook type form Stripe! '.$stripeEvent->type); | |
} | |
... lines 55 - 56 | |
} | |
... lines 58 - 77 | |
} |
I won't test this - let's call that homework for you - but now whenever a subscription
is renewed, the $billingPeriodEndsAt
will be updated.
But there's just one other small important thing you'll want to do each time a
subscription is renewed: send the user an email! Ok, we're not actually going
to code up the email-sending logic now - but it would live right here in SubscriptionHelper
.
But wait! There is one gotcha: the invoice.payment_succeeded
webhook will be triggered
when a subscription is renewed... but it will also be triggered at the moment that
the user originally buys their subscription. So if you send your user an email
that says: "thanks for renewing your subscription", then any new users will be pretty
confused.
To fix this, add a new $isRenewal
variable set to
$newPeriodEnd > $subscription->getBillingPeriodEndsAt()
:
... lines 1 - 8 | |
class SubscriptionHelper | |
{ | |
... lines 11 - 80 | |
public function handleSubscriptionPaid(Subscription $subscription, \Stripe\Subscription $stripeSubscription) | |
{ | |
$newPeriodEnd = \DateTime::createFromFormat('U', $stripeSubscription->current_period_end); | |
// you can use this to send emails to new or renewal customers | |
$isRenewal = $newPeriodEnd > $subscription->getBillingPeriodEndsAt(); | |
$subscription->setBillingPeriodEndsAt($newPeriodEnd); | |
$this->em->persist($subscription); | |
$this->em->flush($subscription); | |
} | |
} |
If this is a new subscription, then it was completed about 2 seconds ago, and we
would have already set the billingPeriodEndsAt
to the correct date. When the webhook
fires, the dates will already match. But if this is a renewal, the billingPeriodEndsAt
in the database will be for last month, and $newPeriodEnd
will be for next month.
In other words, you can use the $isRenewal
flag to send the right type of email.
"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
}
}