PHP API Symfony

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