Symfony Rapid Development with API Platform

API Platform is a stable, easily scalable, next-generation, open-source web framework (set of tools to build and consume web APIs) designed to easily create API-first projects without compromising extensibility and flexibility. API Platform is built on top of Symfony, and it benefits from its ecosystem and provides a wide range of content negotiation (GraphQL, JSON-LD…).

The server skeleton includes the famous Symfony framework and the Doctrine ORM. This scaffolding tool supports the most popular JavaScript libraries. The development environment and the deployment mechanism leverage Docker, Kubernetes, and Helm.

There are many times when we are developing a project, we waste lots of time by creating routes and endpoints with many custom controllers. This approach leads to rapid development and facilitates the development process. Now, we don’t have any controllers – they aren’t needed. In particular, it can be tedious to repeat the process.

For simple CRUD cases, it’s letting you skip all the boilerplate you would need to repeat over and over again. Essentially, the core of the API Platform handles the infrastructure part of your API. You design the input and output data structures as a set of PHP classes. Then the library uses these classes to handle transform JSON (or any other format) documents to PHP objects, back and forth. As you pointed out, it also generates the OpenAPI, Hydra, and JSON Schema specs corresponding to these classes.

The main purpose of the API Platform instantly provides a fully featured read/write API with REST operations, data validation, grouping, pagination, sorting, filtering, an authorization system (easy authentication management with JWT or OAuth), and automatically generated API documentation (Swagger/OpenAPI).

In the beginning, it was conceived for creating simple applications. With newer versions leverage the awesome features of the tool to develop complex and high-performance API-first projects. The API Platform has improved a lot for building Progressive Web and Native Mobile apps.

The API Platform is an incredible achievement in software engineering. It gives you a huge amount of functionality out of the box but is also fully extensible because it is (mostly) well-built. I’d go as far as saying it’s one of the strongest reasons to use Symfony over other frameworks today (and even as far as saying it’s one of the strongest reasons to use PHP over other ecosystems today).

Here you can look down the API Platform Documentation.

Technical Requirements

Before creating your first Symfony application with API Platform, you have to install:

  • PHP 8.1
  • Symfony 6.1
  • API Platform 3.0
  • Composer 2.2.3
  • MySQL or PostgreSQL

It’s super easy to install with docker, in our case we will create a classic composer installation with the Symfony framework.

Getting started

To discover how the framework works, we will be creating an API to manage articles with authenticated users.

Use the following commands to create your project:

composer create-project symfony/skeleton q_agency_rapid_development_app

The core library includes all necessary bundles like doctrine ORM: a powerful ORM set of PHP libraries primarily focused on database storage and object mapping.

composer require api

API Platform has The Schema Generator, a command line tool part of the API Platform framework that instantly generates a set of PHP classes from RDF vocabularies. In our case we are going to install a Symfony helper library called MakerBundle, additionally to speed up the development process. This bundle is the fastest way to generate the most common code you’ll need in a Symfony app: commands, entities, form classes, event subscribers, and more.

composer require --dev symfony/maker-bundle

Dependencies are prepared, let’s configure a database. You can find and customize this in the .env file. Just uncomment the environment variable called DATABASE_URL and set your database name, user, and password.

Now that your connection parameters are set up, use this command to create the database in your relational database management system:

php bin/console doctrine:database:create

Now, we are ready to create some endpoints with the API Platform. Or just clone the GitHub repository and follow the steps from the READMI.md file

API Platform

In this example, we will show how the API Platform works and what you can do.
The Idea of this project is a fast development with this tool. After creating your database, we are ready for development.

Our first step is creating a category entity with some properties. Here is an example of mapping using annotations.

<?php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use App\Repository\CategoryRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[
    ORM\Entity(repositoryClass: CategoryRepository::class),
    ORM\Table(name: 'categories'),
]
#[ApiResource]
class Category
{
    #[
        ORM\Column(name: 'id', type: Types::INTEGER, nullable: false),
        ORM\Id,
        ORM\GeneratedValue(strategy: 'IDENTITY'),
        Groups(['category:get']),
    ]
    private ?int $id = null;
    #[
        ORM\Column(name: 'name', type: Types::STRING, length: 255, nullable: false),
        Assert\NotBlank(message: 'category.validation.name.cannot_be_blank', groups: ['category:create']),
        Assert\Type(Types::STRING, groups: ['category:create']),
        Groups(['category:get', 'category:update', 'category:create']),
    ]
    private ?string $name = null;
    public function getId(): ?int
    {
        return $this->id;
    }
    public function getName(): ?string
    {
        return $this->name;
    }
    public function setName(string $name): self
    {
        $this->name = $name;
        return $this;
    }
}

Create a categoryRepository.php file and add an extended ServiceEntityRepository service for doctrine like this:

<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Category;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class CategoryRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Category::class);
    }
}

You can create a class standalone following doctrine documentation or copy an article.php file below and generate a migration by comparing your current database to your mapping information:

php bin/console doctrine:migrations:diff

So your next job after the entity is to make a migration to the database with the command:

php bin/console make:migration

You won’t believe it with adding #[ApiResource] annotation, we did the whole job. CRUD operations are ready for this class. When the ApiPlatform\Metadata\ApiResource annotation is applied to an entity class, the following built-in CRUD operations are automatically enabled. API Platform automatically registers typical CRUD operations and describes them in the exposed documentation (Hydra and Swagger). It also creates and registers routes corresponding to these operations in the Symfony routing system (if it is available)
It’s an amazing and really fast way to create endpoints, isn’t it?

The next thing we’re going to do is add the article.php class with the many-to-one category relation from the existing Category.php file. This relationship is necessary to check how to manage relations with the API Platform.

<?php
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Repository\ArticleRepository;
use App\State\ArticleCreateProcessor;
use App\State\ArticleProvider;
use App\State\ArticleResetAllDiscountsProvider;
use App\State\ArticleSetDiscountsProcessor;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[
    ORM\Entity(repositoryClass: ArticleRepository::class),
    ORM\Table(name: 'articles'),
    UniqueEntity(fields: ['name'], message: 'article.validation.name.already_in_use'),
    ORM\UniqueConstraint(name: 'name_UNIQUE', columns: ['name']),
]
#[ApiResource(
    operations: [
        new Post(normalizationContext: ['groups' => ['article:get', 'article:category', 'category:get']],
            denormalizationContext: ['groups' => ['article:create']], processor: ArticleCreateProcessor::class),
        new Put('articles/set-all-discounts', openapiContext: ['summary' => 'Set all discounts for specific category' ],
            denormalizationContext: ['groups' => ['article:discount:put']], processor: ArticleSetDiscountsProcessor::class),
        new Get('articles/highest-price', openapiContext: ['summary' => 'Retrieve highest price article'],
            provider: ArticleProvider::class),
        new Get('articles/reset-all-discounts', openapiContext: ['summary' => 'Reset discounts to null'],
            provider: ArticleResetAllDiscountsProvider::class),
        new Get(normalizationContext: ['groups' => ['article:get', 'article:category', 'category:get']]),
        new GetCollection(),
        new Delete()
    ],
)]
#[ApiFilter(SearchFilter::class, properties: ['name' => 'partial'])]
class Article
{
    public const DISCOUNT_DEFAULT_VALUE = null;
    public const DEFAULT_CURRENCY = '$';
    #[
        ORM\Column(name: 'id', type: Types::INTEGER, nullable: false),
        ORM\Id,
        ORM\GeneratedValue(strategy: 'IDENTITY'),
        Groups(['article:get']),
    ]
    private ?int $id = null;
    #[
        ORM\Column(name: 'name', type: Types::STRING, length: 255, nullable: false),
        Assert\NotBlank(message: 'article.validation.name.cannot_be_blank', groups: ['article:create']),
        Assert\Type(Types::STRING, groups: ['article:create']),
        Groups(['article:get', 'article:update', 'article:create']),
    ]
    private ?string $name = null;
    #[
        ORM\Column(name: 'description', type: Types::STRING, length: 255, nullable: false),
        Assert\NotBlank(message: 'article.validation.description.cannot_be_blank', groups: ['article:create']),
        Assert\Type(Types::STRING, groups: ['article:create']),
        Groups(['article:get', 'article:update', 'article:create']),
    ]
    private ?string $description = null;
    #[
        ORM\Column(name: 'price', type: Types::INTEGER, nullable: false),
        Assert\NotNull(
            message: 'article.validation.price.cannot_be_null',
            groups: ['article:create', 'article:update', 'article:get']
        ),
        Assert\Range(
            notInRangeMessage: 'Price value must be between {{ min }} and {{ max }}',
            min: 1,
            max: 10000
        ),
        Assert\NotBlank(message: 'price.validation.value.cannot_be_blank', groups: ['price:create']),
        Assert\Type(Types::INTEGER, groups: ['article:create']),
        Groups(['article:get', 'article:update', 'article:create']),
    ]
    private int $price;
    #[
        ORM\Column(name: 'discount_percentage', type: Types::INTEGER, nullable: true),
        Assert\NotBlank(message: 'article.validation.discount_percentage.cannot_be_blank', groups: ['price:create']),
        Assert\Range(
            notInRangeMessage: 'You must input value between {{ min }} and {{ max }} percent',
            min: 5,
            max: 50
        ),
        Groups(['article:get', 'article:update', 'article:discount:put',]),
    ]
    private ?int $discountPercentage = null;
    private ?int $discountPrice = null;
    #[
        ORM\Column(name: 'currency', type: Types::STRING, length: 10),
        Assert\NotNull(
            message: 'insurance_type.validation.english_label.cannot_be_null',
            groups: ['article:create', 'article:update', 'article:get']
        ),
        Groups(['article:get']),
    ]
    private string $currency;
    #[
        ORM\ManyToOne(targetEntity: Category::class, cascade: ['persist']),
        ORM\JoinColumn(name: 'category_id', referencedColumnName: 'id', nullable: false),
        Groups(['article:category', 'article:create', 'article:discount:put'])
    ]
    private Category $category;
    public function getId(): ?int
    {
        return $this->id;
    }
    public function getName(): ?string
    {
        return $this->name;
    }
    public function setName(string $name): self
    {
        $this->name = $name;
        return $this;
    }
    public function getDescription(): ?string
    {
        return $this->description;
    }
    public function setDescription(string $description): self
    {
        $this->description = $description;
        return $this;
    }
    public function getPrice(): int
    {
        return $this->price;
    }
    public function setPrice(int $price): void
    {
        $this->price = $price;
    }
    public function getDiscountPercentage(): ?int
    {
        return $this->discountPercentage;
    }
    public function setDiscountPercentage(?int $discountPercentage): self
    {
        $this->discountPercentage = $discountPercentage;
        return $this;
    }
    public function getDiscountPrice(): ?int
    {
        $this->discountPrice = $this->discountPercentage !== null ? (1 - ($this->discountPercentage / 100)) * $this->price : null;
        return $this->discountPrice;
    }
    public function setDiscountPrice(?float $discountPrice): void
    {
        $this->discountPrice = $discountPrice;
    }
    public function getCategory(): ?Category
    {
        return $this->category;
    }
    public function setCategory(Category $category): self
    {
        $this->category = $category;
        return $this;
    }
    public function getCurrency(): string
    {
        return $this->currency;
    }
    public function setCurrency(string $currency): self
    {
        $this->currency = $currency;
        return $this;
    }
}

This is where the problem comes in. How to add a setter automatically and store records in the database? With an API Platform code definition like from a category.php file, it’s impossible. But don’t worry, just migrate this relationship.

To be fully prepared create ArticleRepository.php that interacts with a DB with methods containing your query logic can then be stored in this class.

<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Article;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ArticleRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Article::class);
    }
    public function getHighestPriceArticle(): mixed
    {
        $q = $this->createQueryBuilder('a')
            ->select('a, MAX(a.price) AS HIDDEN max_score')
            ->groupBy('a.id')
            ->setMaxResults(1)
        ;
        return $q->getQuery()->getOneOrNullResult();
    }
    public function save(Article $article): void
    {
        $this->_em->persist($article);
        $this->_em->flush();
    }
}

Then articleService.php where the business logic lives

<?php
namespace App\Service;
use App\Entity\Article;
use App\Entity\Category;
use App\Repository\ArticleRepository;
class ArticleService
{
    private ArticleRepository $repository;
    public function __construct(
        ArticleRepository $repository
    ) {
        $this->repository = $repository;
    }
    public function getArticlesByCategoryId(Category $category): array
    {
        return $this->repository->findBy([
            'category' => $category,
        ]);
    }
    public function updateDiscountsByCategory(Category $category, int $discountPercentage): array
    {
        $articles = $this->getArticlesByCategoryId($category);
        /** @var ?Article $article */
        foreach ($articles as $article) {
            $article->setDiscountPercentage($discountPercentage);
            $this->repository->save($article);
        }
        return $articles;
    }
    public function resetAllDiscounts(): void
    {
        $articles = $this->repository->findAll();
        /** @var ?Article $article */
        foreach ($articles as $article) {
            $article->setDiscountPercentage(Article::DISCOUNT_DEFAULT_VALUE);
            $this->repository->save($article);
        }
    }
}

In the next examples, we are talking about STATES for manipulating and retrieving data with our logic. API Platform makes it easy to create custom state providers and processors.

State processor

To mutate the application states during POST, PUT, PATCH, or DELETE operations, API Platform uses classes called state processors that provide ‘out of the box’ custom business logic and computations to trigger before or after persistence (ex: mail, call to an external API…).

If no operation is specified, all default CRUD operations are automatically registered. It is also possible – and recommended for large projects – to define operations explicitly. Keep in mind that once you explicitly set up an operation, the automatically registered CRUD will no longer be. If you declare even one operation manually, such as #[GET], you must declare the others manually as well if you need them.

State processors receive an instance of the class marked as an API resource (usually using the #[ApiResource] attribute). This instance contains data submitted by the client during the deserialization process.
A state processor using Doctrine ORM is included with the library and is enabled by default. It is allowed to persist and delete objects that are also mapped as Doctrine entities. A project can include as many state processors as needed.

Our first processor should be applied to create an article endpoint. Without them, we can’t add some custom logic because we need to set a default currency to the article before persisting.

If you want to execute custom business logic before or after persistence, this can be achieved by decorating the built-in state processors. Even with service autowiring and autoconfiguration enabled, you must still configure the decoration:

Creating a Custom State Processor with MakerBundle with the name ArticleCreateProcessor

bin/console make:state-processor

The logic of state processors is the responsibility of application developers and is out of the API Platform’s scope. Open the file and modify the code like this:

<?php
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Article;
class ArticleCreateProcessor implements ProcessorInterface
{
    private ProcessorInterface $decorated;
    public function __construct(ProcessorInterface $decorated)
    {
        $this->decorated = $decorated;
    }
    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Article
    {
        if (!$data instanceof Article) {
            throw new \InvalidArgumentException('Invalid object type');
        }
        $data->setCurrency(Article::DEFAULT_CURRENCY);
        return $this->decorated->process($data, $operation, $uriVariables, $context);
    }
}

We will call the behavior of the built-in ArticleCrateProcessor service in the API Platform registered endpoint in the POST method with a preprocessor magic method. As you can see in the article file, we added ArticleCreateProcessor, normalizationContext (post-process objects created from the payload sent in the HTTP request body), and denormalizationContext (customize the resource sent to the client) with specific groups.

Just another example of a state processor is updating discounts of multiple articles with our definition of URI template and a group of properties necessary for our service.

<?php
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Dto\ArticleSetDiscountsDto;
use App\Dto\Authentication\RequestAuh0PasswordResetDTO;
use App\Entity\Article;
use App\Entity\Category;
use App\Entity\Questionnaire\UserQuestionnaire;
use App\Exception\ApiValidationException;
use App\Service\ArticleService;
use Doctrine\Common\Collections\ArrayCollection;
use Symfony\Component\HttpFoundation\Exception\BadRequestException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
class ArticleSetDiscountsProcessor implements ProcessorInterface
{
    public function __construct(
        private readonly ArticleService $articleService,
    ) {
    }
    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ArrayCollection
    {
        if (!$data instanceof Article) {
            throw new \Exception('Invalid object type');
        }
        /** @var ?Category $category */
        $category = $data->getCategory();
        $articles = $this->articleService->updateDiscountsByCategory($category, $data->getDiscountPercentage());
        return new ArrayCollection($articles);
    }
}

State provider

To retrieve data exposed by the API, the API Platform uses classes called state providers. A state provider using Doctrine ORM to retrieve data from a database, These state providers natively support paged collections and filters.
We added SearchFilter in the article class, you will see it applied to our GetCollection() endpoint automatically. For more information, check Filters – API Platform documentation. Let’s retrieve the article with the highest price value with our custom logic:

<?php
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Article;
use App\Repository\ArticleRepository;
class ArticleProvider implements ProviderInterface
{
    private ArticleRepository $articleRepository;
    public function __construct(
        ArticleRepository $articleRepository,
    ) {
        $this->articleRepository = $articleRepository;
    }
    public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
    {
        /** @var ?Article $article */
        $article = $this->articleRepository->getHighestPriceArticle();
        return $article;
    }
}

Usually, for persisting data, we create processor states. Usually, clients send a request body for manipulating data But it doesn’t necessarily mean that we are not able to do it ‘out of the box’ with providers using the GET method.
We will reset discounts to null for all articles:

<?php
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Service\ArticleService;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
class ArticleResetAllDiscountsProvider implements ProviderInterface
{
    private ArticleService $service;
    public function __construct(
        ArticleService $service,
    ) {
        $this->service = $service;
    }
    public function provide(Operation $operation, array $uriVariables = [], array $context = []): JsonResponse
    {
        $this->service->resetAllDiscounts();
        return new JsonResponse(data: ['Articles discount reset successfully'], status: Response::HTTP_OK);
    }
}

To the end, I will leave a challenge for the reader to add a new bool property name like active (default true) and implement a state processor for the existing Delete() endpoint, and instead of deleting the article, you will set active to false and persist it.

Conclusion

API Platform is a powerful tool for creating rapid applications where you don’t care about pagination, filtering, annotations, and creating custom classes for simple endpoints. API Platform generates it all for you. At first, it’s a little complicated to use this, but with long-term use, you will have a crush on this library.
I consider you find it worthwhile to understand the underlying details before delving into the many additional tools that provide. The point is to understand the concept of API Platform (the difficulty of debugging when something doesn’t work out as wished because of something in its internal magic) and the most important thing of all is separating business from a persistence layer for reusable coding that will not cause trouble.

After this blog, you won’t be without an API Platform anymore. I hope this article will help you improve your skills, and in the future, you will use this awesome tool for creating apps.
You can find and clone extended project source code on the GitHub repository with examples of Data Transfer Objects (DTO), operations naming, enumeration, and more complex code.

Thanks for reading!


Leave a Reply

Your email address will not be published. Required fields are marked *