Consume PayPal API
The idea is to set up a dummy order, consume the PayPal API and enable the user to purchase the order. Subsequently, the PayPal response shall be caught and processed into a success or failure message to the user.
Prerequisites
In order to run the app on your machine make sure you possess the following prerequisites:
- PayPal sandbox account with a `client_id` and a `client_secret`
- A sandbox buyer account to handle the dummy payment
Installation
Follow these steps to make the app run on your local machine:
// 1. clone the repo
git@github.com:jchristlieb/paypal_api.git
// 2. install dependencies
composer install
// 3. create .env.local and fill in db_user, db_password,
// db_name, PAYPAL_CLIENT_ID, PAYPAL_CLIENT_SECRET
cp .env .env.local
// 4. create database
bin/console doctrine:database:create
// 5. execute migrations
bin/console doctrine:migrations:migrate
// 6. create dummy data
bin/console doctrine:fixtures:load
// 7. start server
bin/console server:start
I divided the development into three steps which you may follow to reproduce the app from scratch.
1. Project set up
// use symfony website-skeleton to set the ground
composer create-project symfony/website-skeleton paypal_api
// start web server
bin/locale server:start
Create entities
We want to fill our form with a dummy order, therefore we need to create a simplified Product
with the properties name, price, and sku and an Order
with the property subtotal and products (ManyToMany relation to Product).
// symfony guides you through entity creation, just run
bin/console make:entity
ATTENTION: Because order belongs to the reserved SQL keywords. We need to make use of doctrines annotations reference and change the table name to e.g. 'orders' within *src/Entity/Order.php*.
namespace App\Entity;
...
/**
* @ORM\Table(name="orders")
*/
class Order
Set up database connection
Copy your .env
file to an .env.local
because symfony commits your .env
by default and you shouldn't display credentials to public repositories.
// configure your user, password and database name
DATABASE_URL=mysql://db_user:db_password@127.0.0.1:3306/db_name
// create database
bin/console doctrine:database:create
Create and execute migrations
The make:migrations
command creates a migration file with SQL statements that create tables from your entities and columns from the properties. The doctrine:migrations:migrate
command executes those statements and if no errors occur create the respective tables in your database.
// make migrations
bin/console make:migrations
// execute migrations
bin/console doctrine:migrations:migrate
Create dummy data
We use the DataFixturesBundle to create a couple of dummy data for our fake orders.
// require bundle
composer require --dev doctrine/doctrine-fixtures-bundle
The bundle creates a src/DataFixtures/AppFixtures.php
. Now we could go fancy: Make use the great PHP-Faker library. Run comprehensive loops with dynamic, random relationships among all objects that have defined relationships. However, I spare the expense and simply create 3 products and 3 orders. I attach 1-2 products to each order.
// src/DataFixtures/AppFixtures.php
public function load(ObjectManager $manager)
{
$product1 = new Product();
$product1->setName('Lego Box Small');
$product1->setPrice(29);
$product1->setSku('SKU-001');
$manager->persist($product1);
[...]
$order1 = new Order();
$order1->addProduct($product1);
$order1->addProduct($product2);
$order1->setSubtotal(
$product1->getPrice() + $product2->getPrice());
$manager->persist($order1);
[...]
$manager->flush();
}
// load the fixtures
bin/console doctrine:fixtures:load
With a look at your database you should find the dummy data and we are ready to create a simple purchase form.
2. Create a purchase form
Define route and controller
We are going to use the index route for the purchase form and fetch the needed data through the index()
method of the DefaultController.
// config/routes.yaml
index:
path: /
controller: App\Controller\DefaultController::index
Let us pick a random order, fetch the associated products and hand over the data to the responsible view base.html.twig
.
public function index()
{
$randomOrder = rand(1,3);
$repository = $this->getDoctrine()->getRepository(Order::class);
$order = $repository->find($randomOrder);
$products = $order->getProducts();
return $this->render('base.html.twig', [
'order' => $order,
'products' => $products,
]);
}
Display purchase form
To make it quick, we require bootsstrapCDN and build a simple purchase form that displays the selected items and the subtotal, tax and total amounts of the order.
3. Integrate PayPal service
Configure API context
Here we come to the heart of the application. We build it on top of PayPal's REST API SDK for PHP. Thus, make sure you require the corresponding package.
// require the REST API SDK
composer require paypal/rest-api-sdk-php
First of all, let us set up a src/Service/PayPalService.php
. This is the class we use to implement the configuration and required methods.
We need to set up a constructor()
that loads the $ApiContext
required to interact with PayPal API. Here you are going to set your Client_ID
and SECRET
. Both values can be found within the settings of your sandbox application from PayPal.
In addition, the setConfig()
method enables you to configure the context. In this case I use the default sandbox configuration.
// src/Service/PayPalService.php
namespace App\Service;
use PayPal\Auth\OAuthTokenCredential;
use PayPal\Rest\ApiContext;
class PayPalService
{
/**
* @var ApiContext
*/
private $apiContext;
public function __construct()
{
$apiContext = new ApiContext(
new OAuthTokenCredential(
getenv('PAYPAL_CLIENT_ID'),
getenv('PAYPAL_SECRET')
)
);
$apiContext->setConfig([
'mode' => 'sandbox',
'log.LogEnabled' => true,
'log.FileName' => '../PayPal.log',
'log.LogLevel' => 'DEBUG',
'cache.enabled' => true,
]);
$this->apiContext = $apiContext;
}
}
Create payment from order
The key question: What do we need to do to create a payment for our dummy order? Most likely, we need a createPaymentFromOrder()
method ;-). Further, the CreatePaymentUsingPayPal.php seems to be a good indicator for getting the job done.
Basically, we need an URL where we can redirect our buyer to, to process the payment. We receive this so-called ApprovalLink from the payment
object. This object has a couple of dependencies like payer()
, redirectUrls()
, and transaction()
. Let us create all those objects, one after the other.
public function createPaymentFromOrder(array $order)
{
$payer = new Payer();
$payer->setPaymentMethod("paypal");
$itemList = new ItemList();
foreach ($order['order']['items'] as $item) {
$orderItem = new Item();
$orderItem->setName($item["name"])
->setCurrency("EUR")
->setQuantity(1)
->setSKU($item["sku"])
->setPrice($item["price"]);
$itemList->addItem($orderItem);
}
$details = new Details();
$details->setTax($order['order']["tax"])
->setSubtotal($order['order']["subtotal"]);
$amount = new Amount();
$amount->setCurrency("EUR")
->setTotal($order['order']["total"])
->setDetails($details);
$transaction = new Transaction();
$transaction->setAmount($amount)
->setItemList($itemList)
->setInvoiceNumber(uniqid());
$baseUrl = "http://localhost:8000";
$redirectUrls = new RedirectUrls();
$redirectUrls->setReturnUrl("$baseUrl/payment/success")
->setCancelUrl("$baseUrl/payment/failure");
$payment = new Payment();
$payment->setIntent("sale")
->setPayer($payer)
->setRedirectUrls($redirectUrls)
->setTransactions(array($transaction));
try {
$payment->create($this->apiContext);
} catch (\Exception $exception) {
var_dump($exception->getMessage());
exit(1);
}
return $payment->getApprovalLink();
}
ATTENTION: I hard coded the $baseUrl
. This is likely a reason for failure in another environment. It is probably a better idea to use global $_Server
variables to make this more dynamic.
Trigger the payment
Ok, lets check if this works. Make sure, the purchase button on our order form hits a post request to
/payment route. This way our payment()
method gets triggered.
// config/routes.yaml
paypal:
path: /payment
controller: App\Controller\DefaultController::payment
methods: [POST]
The payment()
requires a PayPalService instance and catches the $order
through PHP global $_REQUEST
variable. That is everything we need to do to trigger our createPaymentFromOrder()
method. The method returns the ApprovalLink. We make use of symfony's RedirectResponse and feed this instance with the mentioned ApprovalLink.
public function payment(PayPalService $payPalService)
{
$order = $_REQUEST;
$approvalLink = $payPalService->createPaymentFromOrder($order);
return new RedirectResponse($approvalLink);
}
Catch the payment response
Finally, we could become fancy again and make some creative views for a successful and unsuccessful payment. However, to test whether it works it may be enough to establish routes for the defined returnUrl()
and cancelUrl()
and return either a success or a failure response.
// config/routes.yaml
success:
path: /payment/success
controller: App\Controller\DefaultController::success
failure:
path: /payment/failure
controller: App\Controller\DefaultController::failure
// src/Controller/DefaultController
[...]
public function success()
{
return new Response("success");
}
public function failure()
{
return new Response("failure");
}
3. Discussion
First, thanks for reading this article. These are my first steps with Symfony components and the PayPal API. Moreover, I didn't write any tests for this project yet. Hence, the code quality may be consumed with caution ;-).
Particulary, I liked the idea of Symfony bundles to quickly add features to an application. This way you stay agile. Anyway, the syntax for fetching data, writing migrations and running bin/console
commands are not as intuitive as I am used to in Laravel projects.
Initially, I wanted to use the API Platform to consume the PayPal REST API. However, by using PayPal's REST API SDK I did not see a necessity for this anymore.
Another idea was to use API Platform Schema Component for creating the Order entity. It builds your objects on bulletproof and SEO friendly schema.org structure. A great appoach in general. And I was looking forward to implement it. The way I tried to configure it, did not work out well and I was not able to quickly create a simple Order
object. Some of the properties of a schema.org/order are mandatory, some need other types as dependencies. Thus, in the end you have much more objects as needed for my purpose.
If you have any questions or feedback feel free to get in touch.
4. Further resources
- GitHub repository of the discussed project PayPal API
- PayPal's PHP REST API software development kid
- PayPal sandbox testing guide
- Niels van der Molen: How to build a Symfony 4 API from scratch
- Documentation of Symfony's DoctrineFixtureBundles