If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
When the user fills out this form, our JavaScript sends that info to Stripe, Stripe then sends back a token, we add the token as a hidden field in the form and then submit it. Both the checkout form and update card form will work like this. But, we need to submit the update card form to a new endpoint, that'll update the card, but not charge the user.
Open ProfileController
and add a new endpoint: public function updateCreditCardAction()
.
Give it the URL /profile/card/update
and a fancy name: account_update_credit_card
.
Add the @Method("POST")
to be extra cool:
... lines 1 - 7 | |
use Symfony\Component\HttpFoundation\Request; | |
... lines 9 - 12 | |
class ProfileController extends BaseController | |
{ | |
... lines 15 - 68 | |
/** | |
* @Route("/profile/card/update", name="account_update_credit_card") | |
* @Method("POST") | |
*/ | |
public function updateCreditCardAction(Request $request) | |
{ | |
... lines 75 - 90 | |
} | |
} |
With this in place, we need to update the form to submit here. Check out _cardForm.html.twig
.
Hmm, the action
attribute is empty:
<form action="" method="POST" class="js-checkout-form checkout-form"> | |
... lines 2 - 66 | |
</form> |
That's because we want the checkout form to submit right back to /checkout
. But this won't
work for the update card form: it should submit to a different URL.
Instead, render a new variable called formAction
and pipe that to the default
filter with empty quotes:
<form action="{{ formAction|default('') }}" method="POST" class="js-checkout-form checkout-form"> | |
... lines 2 - 66 | |
</form> |
Now we can override this! In account.html.twig
, add another variable to the include: formAction
set to path()
and the new route name:
... lines 1 - 18 | |
{% block body %} | |
<div class="nav-space"> | |
<div class="container"> | |
<div class="row"> | |
... lines 23 - 84 | |
<div class="col-xs-6"> | |
<div class="js-update-card-wrapper" style="display: none;"> | |
<h2>Update Credit Card</h2> | |
{{ include('order/_cardForm.html.twig', { | |
buttonText: 'Update Card', | |
formAction: path('account_update_credit_card') | |
}) }} | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
{% endblock %} | |
... lines 98 - 99 |
Refresh and check out the source. Ok, the form action is ready!
Let's get to work in ProfileController
! But actually... this will be very similar
to our checkout logic, so let's go steal code! Copy the line that fetches the
stripeToken
POST parameter and then head back to ProfileController
. Make
sure you have a Request
argument and the Request
use statement:
... lines 1 - 7 | |
use Symfony\Component\HttpFoundation\Request; | |
... lines 9 - 12 | |
class ProfileController extends BaseController | |
{ | |
... lines 15 - 72 | |
public function updateCreditCardAction(Request $request) | |
{ | |
... lines 75 - 90 | |
} | |
} |
Then, first, paste that line to fetch the token. Second, fetch the current user
object with $user = $this->getUser()
. And third, we need to make an API request
to stripe that updates the Customer and attaches the token as their new card.
That means we'll be using the StripeClient
. Fetch it first with
$stripeClient = $this->get('stripe_client')
:
... lines 1 - 12 | |
class ProfileController extends BaseController | |
{ | |
... lines 15 - 72 | |
public function updateCreditCardAction(Request $request) | |
{ | |
$token = $request->request->get('stripeToken'); | |
$user = $this->getUser(); | |
$stripeClient = $this->get('stripe_client'); | |
... lines 79 - 90 | |
} | |
} |
Here's the awesome part: open StripeClient
. We already have a method called
updateCustomerCard()
:
... lines 1 - 8 | |
class StripeClient | |
{ | |
... lines 11 - 33 | |
public function updateCustomerCard(User $user, $paymentToken) | |
{ | |
$customer = \Stripe\Customer::retrieve($user->getStripeCustomerId()); | |
$customer->source = $paymentToken; | |
$customer->save(); | |
return $customer; | |
} | |
... lines 43 - 116 | |
} |
We pass the User
object and the submitted payment token and it sets it on
the Customer
and saves.
Victory for code organization! In the controller, just say $stripeClient->updateCustomerCard()
and pass it $user
and $token
:
... lines 1 - 12 | |
class ProfileController extends BaseController | |
{ | |
... lines 15 - 72 | |
public function updateCreditCardAction(Request $request) | |
{ | |
... lines 75 - 77 | |
$stripeClient = $this->get('stripe_client'); | |
$stripeCustomer = $stripeClient->updateCustomerCard( | |
$user, | |
$token | |
); | |
... lines 83 - 90 | |
} | |
} |
That takes care of things inside Stripe.
Now, what about our database? Do we store any information about the credit card?
Actually, we do! In the User
class, we store cardLast4
and cardBrand
. With
the new card, this stuff probably changed!
But wait, we've got this handled too guys! Open SubscriptionHelper
and check out
the handy updateCardDetails()
method:
... lines 1 - 8 | |
class SubscriptionHelper | |
{ | |
... lines 11 - 64 | |
public function updateCardDetails(User $user, \Stripe\Customer $stripeCustomer) | |
{ | |
$cardDetails = $stripeCustomer->sources->data[0]; | |
$user->setCardBrand($cardDetails->brand); | |
$user->setCardLast4($cardDetails->last4); | |
$this->em->persist($user); | |
$this->em->flush($user); | |
} | |
} |
Just pass it the User
and \Stripe\Customer
and it'll make sure those fields are set.
In ProfileController
, call this: $this->get('subscription_helper')->updateCardDetails()
passing $user
and $stripeCustomer
... which doesn't exist yet. Fortunately,
updateCustomerCard()
returns that, so create that variable on that line:
... lines 1 - 12 | |
class ProfileController extends BaseController | |
{ | |
... lines 15 - 72 | |
public function updateCreditCardAction(Request $request) | |
{ | |
... lines 75 - 78 | |
$stripeCustomer = $stripeClient->updateCustomerCard( | |
$user, | |
$token | |
); | |
// save card details! | |
$this->get('subscription_helper') | |
->updateCardDetails($user, $stripeCustomer); | |
... lines 87 - 90 | |
} | |
} |
That's it! Add a success message so that everyone feels happy and joyful, and redirect back to the profile page:
... lines 1 - 12 | |
class ProfileController extends BaseController | |
{ | |
... lines 15 - 72 | |
public function updateCreditCardAction(Request $request) | |
{ | |
... lines 75 - 83 | |
// save card details! | |
$this->get('subscription_helper') | |
->updateCardDetails($user, $stripeCustomer); | |
$this->addFlash('success', 'Card updated!'); | |
return $this->redirectToRoute('profile_account'); | |
} | |
} |
Time to try it! Refresh and put in the fake credit card info. But use a different expiration: 10/25. Hit "Update Card". Ok, it looks like it worked. Refresh the Customer page in Stripe. The expiration was 10/20 and now it's 10/25. Card update successful!
But, we still need to handle one more case: when the card update fails.
"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
}
}