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: Email User on Subscription Renewal

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

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.

Receive all Webhook Types

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
}

Inspecting the invoice.payment_succeeded Webhook

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!

Handling invoice.payment_succeeded

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
}

Updating the Subscription in the Database

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.

Sending an Email on Renewal

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.

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