Locastic Dec 15

Customizing the new Sylius & ApiPlatform integration

4 min read –

In the last few years, ApiPlatform has become the standard in the Symfony ecosystem, and since version 1.8, Sylius decided to use it instead of FOSRestBundle. With this change, Sylius should become much easier to adapt and improve, so let’s see a few easy ways to add our own functionalities.

Prerequisites

Let’s assume we have set up a sylius-standard project (version 1.8).

1. Extending the existing Api

The simplest task is when we want to add new properties to existing entities. To accomplish this, we have to:

  1. add properties with getters and setters
  2. add Doctrine annotations and execute database migrations
  3. add ApiPlatform annotations with the serialization groups

Suppose we want to add property “note” to a Product, the final result would look like this:

<?php
// src/Entity/Product/Product.php
namespace App\Entity\Product;
...
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
...
class Product extends BaseProduct
...
/**
 * @var string
 *
 * @ORM\Column
 * @Groups({"product:read", "product:create", "product:update"})
 */
 private $note;

 public function getNote(): string
 {
     return $this->note;
 }
 
 public function setNote(string $note): void
 {
     $this->note = $note;
 }
 ...

With the serialization groups, we define in what normalization and denormalization contexts the entity will be used.

Sylius keeps entities decoupled from the ApiPlatform configuration and existing configuration files can be found under vendor/sylius/sylius/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/.

In case we want to override serialization groups of an existing entity, we need to copy desired configuration from that location to the config/serializer (serializer folder probably doesn’t exist so we must make it first). After that, all the changes made to the copied file will be applied in the resulting Api.

One of many great things of ApiPlatform is that it takes care of documentation, so by navigating to http://localhost:8000/new-api/ we can see the Openapi(Swagger) documentation of our Api and by expanding any Product endpoint, we can see that our property can already be set and retrieved.

Also, we can expose with Api Platform every new entity we define, just by tagging it as an ApiResource. This entity may or may not be Sylius resource, it doesn’t matter.
Everything ApiPlatform provides is available for usage, just like in regular ApiPlatform aplications.

2. Creating custom actions using Command pattern

We often need to implement some more complex actions.

For example, let’s suppose we want to create an endpoint for quick checkout. It should work in a way that one click on the chosen product would add it to cart and make a checkout.

In order to implement this, there is just a little more job to do.

2.1. Define route configuration

First, we need to enable overrides of ApiPlatform configuration. In folder config create a new file api_platform.yml with:

api_platform:
    mapping:
        paths:
            - '%kernel.project_dir%/vendor/sylius/sylius/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources'
            - '%kernel.project_dir%/src/Entity'
            - '%kernel.project_dir%/config/api_platform'

Next, we have to decide what configuration file we need to override.

Since the result of our action will be a new Order, we should copy the file vendor/sylius/sylius/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/Order.xml

and place it to config/api_platform (probably another new folder).

By the defined configuration, every file placed in this folder will override the one of Sylius.

Second step is to configure the endpoint – define its path, acceptable input data and serialization groups. In our case, the user will have to provide only the product code.
In the “Order.xml”, we will define new POST operation (because the result is a new Order) which accepts new DTO, following this concept of the ApiPlatform: https://api-platform.com/docs/core/dto/#specifying-an-input-or-an-output-data-representation.

So the result would be something like this:

<collectionOperation name="shop_instant_purchase">
  <attribute name="method">POST</attribute>
  <attribute name="path">/shop/instant-purchase</attribute>
  <attribute name="messenger">input</attribute>
    <attribute name="input">App\Message\InstantPurchaseMessage</attribute>
  <attribute name="denormalization_context">
    <attribute name="groups">order:create</attribute>
  </attribute> 
</collectionOperation>

Both operation name and path are arbitrary, but make sure they are different from all the existing ones.

2.2. Create Message DTO

We will create a DTO in Message folder because that follows Symfony convention for messages consumable by Symfony Messenger Handler .

<?php

declare(strict_types=1);

namespace App\Message;

class InstantPurchaseMessage
{
    private string $productCode;

    public function __construct(string $productCode)
    {
        $this->productCode = $productCode;
    }

    public function getProductCode(): string
    {
        return $this->productCode;
    }
}

As we set InstantPurchaseMessage as the input value of our custom action, we can already see that new route accepts one string parameter named “product_code”. Mind that if we need, we can define serialization groups on properties in our input DTO to show or hide some fields. By skipping this, Symfony serializes every property.

2.3. Create Message Handler

Next step is to create a class that would consume this message.

Every class implementing Symfony\Component\Messenger\Handler\MessageHandlerInterface is invoked by creation of corresponding class.

We have to create a class named exactly like DTO with the suffix Handler and implement this interface. In this class we will add our custom code, everything we want to be done after the message is received.

<?php

declare(strict_types=1);

namespace App\Handler;

use App\Entity\Order\Order;
use App\Message\InstantPurchaseMessage;
...
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;

class InstantPurchaseMessageHandler implements MessageHandlerInterface
{
    public function __invoke(InstantPurchaseMessage $message): Order
    {
        return $this->orderService->createOrder($message);
    }
...
}

Order creation is a specific Sylius story, so we want take it into details, but it is important that __invoke method returns the object which we want to show in the Response.

What we just made is called Command pattern and it is a very handy way of adding new functionalities to our project. This same pattern is used for the existing Sylius api actions.

So, to sum it up, to add our custom action, we needed to:

  1. enable bundle overrides
  2. register custom operation with desired input(new DTO)
  3. create DTO
  4. create handler
  5. write custom logic

And that’s it, with these few tips how to override specific parts of Sylius definitions for ApiPlatform, we can utilize everything ApiPlatform gives us, and also, this way of creating custom operations will probably be the recommended way of adding custom functionality since the core of Sylius uses the same pattern.