API Platform v3.0

In September 2022 a new version of API Platform was released. It comprised some major changes that flipside the development strategy some developers preferred to stick to. In this article we will discover the new approaches introduced in the v3. Additionally, we will put some attention to the instruments and approaches that have been removed or deprecated ever since. Some of them were quite useful, so we propose our own solution of integrating their behavior into the new system of API Platform.

Working with API Platform v3

Below you can find the main changes that will be taken into consideration in this article.

Main Changes

  • Removal of item and collection differences on operation declaration
  • ApiPlatform\Core\DataTransformer\DataTransformerInterface is deprecated and has been removed in 3.0. It is substituted with Data Providers now.
  • Subresources are now additional resources marked with an #[ApiResource] attribute

We are going to expound on every change item listed above with examples of code.

API Platform v3State Processors and State Providers

As stated in the changelog, the data transformers have been removed. They were replaced by state processors and state providers. State providers and state processors have just one function: provide and process respectively.

In a nutshell, the main purpose of state providers is to provide access to an object stored in the database. State processors, on the contrary, are needed to process incoming data and store it in a database if needed while processing http-request.

By default, the doctrine ORM state providers and processors are used by API Platform. You can configure your own providers if you want to retrieve data from another source (Elasticsearch, MongoDB, etc.) or to modify data before sending a response.

State Provider

Get item

Let us suppose we have a user entity that has fields that we want to display in a GET request. For example:

{
 	"id": integer,
 	"firstName": "string",
 	"lastName": "string",
 	"email": "string",
 	"phone": "string",
 	"createdAt": "string"
}

We have two ways of achieving this:

  • Specify normalization groups for the operation and assign the normalization groups for the properties you want to display via #[Groups()] annotation in the entity class.
  • Create a Data Transfer Object (DTO), assign the #[Groups()] to the properties of the DTO, and assign the DataProvider for the GET operation.

The first approach is perfectly documented in the official API Platform docs, so we will focus on the second one.

First, let us start with the creation of the DTO output class for this object. The code for the DTO class is shown below:

<?php

declare(strict_types=1);

namespace App\Dto\Api\User;

use DateTimeInterface;
use Symfony\Component\Serializer\Annotation\Groups;

class UserOutputDto
{
	#[Groups(['User:read'])]
	public int $id;

	#[Groups(['User:read'])]
	public string $firstname;

	#[Groups(['User:read'])]
	public ?string $lastname = null;

	#[Groups(['User:read'])]
	public ?string $email = null;

	#[Groups(['User:read'])]
	public string $phone;

	#[Groups(['User:read'])]
	public DateTimeInterface $createdAt;

	public function __construct(
    	int $id,
    	string $firstname,
    	?string $lastname,
    	?string $email,
    	string $phone,
    	DateTimeInterface $createdAt
	) {
    	$this->id = $id;
    	$this->firstname = $firstname;
    	$this->lastname = $lastname;
    	$this->email = $email;
    	$this->phone = $phone;
    	$this->createdAt = $createdAt;
	}
}

In the code above we have defined the fields we want to display for the GET request and the serialization groups for each property.

The next step is to create a DataProvider class that will allow us to retrieve a user from a database. All data providers must implement ApiPlatform\State\ProviderInterface which applies to both collection and item operations. From now on, I suggest creating a CollectionProviderInterface and ItemProviderInterface that both extend ApiPlatform\State\ProviderInterface.

The code for CollectionProviderInterface:

<?php

declare(strict_types=1);

namespace App\State\Provider;

use ApiPlatform\State\ProviderInterface;

interface CollectionProviderInterface extends ProviderInterface
{
}

And ItemProviderInterface:

<?php

declare(strict_types=1);

namespace App\State\Provider;

use ApiPlatform\State\ProviderInterface;

interface ItemProviderInterface extends ProviderInterface
{
}

After that, let us add the following configuration to the config/services.yaml file:

…
parameters:
…
services:
…
_instanceof:
    		App\State\Provider\CollectionProviderInterface:
        		bind:
            		$collectionProvider: '@api_platform.doctrine.orm.state.collection_provider'
    		App\State\Provider\ItemProviderInterface:
        		bind:
            		$itemProvider: '@api_platform.doctrine.orm.state.item_provider'
…

We have specified that data providers responsible for retrieving an item shall receive the doctrine item provider as an argument and providers responsible for retrieving a collection of items shall receive the doctrine collection provider in order to retrieve necessary data from the database.

Before creating our first DataProvider class I advise to create a DataTransformer class that has the main role of transforming an entity to a DTO. The code of the UserOutputGetDataTransformer class is shown below:

<?php

declare(strict_types=1);

namespace App\DataTransformer\Api\User;

use App\Dto\Api\User\UserOutputDto;
use App\Entity\User;

class UserOutputGetDataTransformer
{
	public function transform(User $user): UserOutputDto
	{
    	return new UserOutputDto(
        	$user->getId(),
        	$user->getFirstname(),
        	$user->getLastname(),
        	$user->getEmail(),
        	$user->getPhone(),
        	$user->getCreatedAt()
    	);
	}
}

And finally, let us create our first class that implements ItemProviderInterface. This class will be used to retrieve an object from the database and transform its state to a DTO that will be sent as a response.

The code of the class is shown below:

<?php

declare(strict_types=1);

namespace App\State\Provider\User;

use ApiPlatform\Exception\ItemNotFoundException;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\DataTransformer\Api\User\UserOutputGetDataTransformer;
use App\Dto\Api\User\UserOutputDto;
use App\State\Provider\ItemProviderInterface;

class UserProvider implements ItemProviderInterface
{
	public function __construct(
    	private ProviderInterface $itemProvider,
    	private UserOutputGetDataTransformer $dataTransformer
	) {
	}

	public function provide(Operation $operation, array $uriVariables = [], array $context = []): UserOutputDto
	{
    	$user = $this->itemProvider->provide($operation, $uriVariables, $context) ??
        	throw new ItemNotFoundException('Not Found');

    	return $this->dataTransformer->transform($user);
	}
}

The __construct method of the UserProvider class receives two arguments:

  • $itemProvider will be used to retrieve an item from the database.
  • $dataTransformer will be used to transform the item to the DTO.

As easy as pie, is it not?

Lastly, we must assign UserProvider to the GET method of the User class. Here we specify output: UserOutputDto::class and provider: UserProvider::class:

<?php

declare(strict_types=1);

namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
…
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;

#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: '`user`')]
#[ApiResource(
	operations: [
    	new Get(
        	uriTemplate: '/users/{id<\d+>}',
        	output: UserOutputDto::class,
        	provider: UserProvider::class,
    	),
	],
	normalizationContext: ['groups' => ['User:read']],
)]

class User implements UserInterface, PasswordAuthenticatedUserInterface
{
	#[ORM\Id]
	#[ORM\GeneratedValue]
	#[ORM\Column]
	private int $id;

	#[ORM\Column(length: 255, nullable: true, unique: true)]
	private ?string $email = null;

	#[ORM\Column(length: 255, unique: true)]
	private string $phone;

	#[ORM\Column(length: 255)]
	private string $password;

	#[ORM\Column(length: 255)]
	private string $firstname;

	#[ORM\Column(length: 255, nullable: true)]
	private ?string $lastname = null;

	#[ORM\Column(nullable: false)]
	private array $roles = ['ROLE_USER'];

	#[ORM\Column]
	private DateTimeImmutable $createdAt;

	#[ORM\Column(nullable: true)]
	private ?DateTimeImmutable $updatedAt = null;
...
}

Get collection

Moving on, let us discover the same approach for the collection operation.

Firstly, we create a UsersProvider class that implements our CollectionProviderInterface. The provider should retrieve a collection of users from the database, whereas UserOutputGetDataTransformer should transform it to the DTO. The code of the UsersProvider class is shown below:

<?php

declare(strict_types=1);

namespace App\State\Provider\User;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\DataTransformer\Api\User\UserOutputGetDataTransformer;
use App\Dto\Api\User\UserOutputDto;
use App\Entity\User;
use App\State\Provider\CollectionProviderInterface;

class UsersProvider implements CollectionProviderInterface
{
	public function __construct(
    	private ProviderInterface $collectionProvider,
    	private UserOutputGetDataTransformer $dataTransformer,
	) {
	}

	public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
	{
    	return array_map(
        	fn (User $user): UserOutputDto => $this->dataTransformer->transform($user),
        	iterator_to_array(($this->collectionProvider->provide($operation, $uriVariables, $context))->getIterator())
    	);
	}
}

The pagination to the result is already applied, so you do not have to worry about it.

The last thing to do is to assign this provider to the GetCollection operation in the User class:

…
#[ApiResource(
	operations: [
    	new Get(
        	uriTemplate: '/users/{id<\d+>}',
        	output: UserOutputDto::class,
        	provider: UserProvider::class,
    	),
	new GetCollection(
        	output: UserOutputDto::class,
        	provider: UsersProvider::class
    	),
	],
	normalizationContext: ['groups' => ['User:read']],
)]
…

State Processor

Create item

Let us say we want to create a new User via POST method.

The input for the POST method shall look like this:

{
    "firstname": "string",
    "lastname": "string",
    "email": "string",
    "phone": "string"
}

First, let us start with the creation of the DTO input class UserInputPostDto:

<?php

declare(strict_types=1);

namespace App\Dto\Api\User;

use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;

class UserInputPostDto
{
	#[Groups(['User:write'])]
	#[Assert\NotBlank()]
	public string $firstname;

	#[Groups(['User:write'])]
	#[Assert\NotBlank(allowNull: true)]
	public ?string $lastname = null;

	#[Groups(['User:write'])]
	#[Assert\NotBlank(allowNull: true)]
	public ?string $email = null;

	#[Groups(['User:write'])]
	#[Assert\NotBlank()]
	public string $phone;

	#[Groups(['User:write'])]
	#[Assert\NotBlank()]
	public string $password;
}

The code above contains the fields that should be received as an input JSON, validation constraints and serialization groups for writing.

Additionally, let us create a UserInputPostDataTransformer class that will transform the incoming data from a request body into a new class object:

<?php

declare(strict_types=1);

namespace App\DataTransformer\Api\User;

use ApiPlatform\Validator\ValidatorInterface;
use App\Dto\Api\User\UserInputPostDto;
use App\Entity\User;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;

class UserInputPostDataTransformer
{
	public function __construct(
    	private ValidatorInterface $validator,
    	private UserPasswordHasherInterface $hasher
	) {
	}

	public function transform(UserInputPostDto $data): User
	{
    	$this->validator->validate($data);

    	return new User(
        	$data->email,
        	$data->phone,
        	$data->firstname,
        	$data->lastname,
        	$data->password,
        	$this->hasher
    	);
	}
}

As regards state processors, all data processors must implement ApiPlatform\State\ProcessorInterface. For the further convenience, let us create PersistProcessorInterface with the following code:

<?php

declare(strict_types=1);

namespace App\State\Processor;

use ApiPlatform\State\ProcessorInterface;

interface PersistProcessorInterface extends ProcessorInterface
{
}

Our goal is to make all the custom Processors implement this interface.

Let us add the following configuration to the config/services.yaml:

…
services:
…
	App\State\Processor\PersistProcessorInterface:
        	bind:
            	$persistProcessor: '@api_platform.doctrine.orm.state.persist_processor'
…

Then, let us create a PostUserProcessor class that implements PersistProcessorInterface. The code of the class is shown below:

<?php

declare(strict_types=1);

namespace App\State\Processor\User;

use ApiPlatform\Doctrine\Common\State\PersistProcessor;
use ApiPlatform\Metadata\Operation;
use App\DataTransformer\Api\User\UserInputPostDataTransformer;
use App\DataTransformer\Api\User\UserOutputGetDataTransformer;
use App\Dto\Api\User\UserInputPostDto;
use App\Dto\Api\User\UserOutputDto;
use App\State\Processor\PersistProcessorInterface;

class PostUserProcessor implements PersistProcessorInterface
{
	public function __construct(
    	private UserInputPostDataTransformer $postDataTransformer,
    	private UserOutputGetDataTransformer $getDataTransformer,
    	private PersistProcessor $persistProcessor,
	) {
	}

	/**
 	* @param UserInputPostDto $data
 	*/
	public function process($data, Operation $operation, array $uriVariables = [], array $context = []): UserOutputDto
	{
    	$user = $this->postDataTransformer->transform($data);

    	$this->persistProcessor->process($user, $operation, $uriVariables, $context);

    	return $this->getDataTransformer->transform($user);
	}
}

The __construct method receives three arguments:

  • $postDataTransformer is used to transform incoming data from the input DTO to a new User object.
  • $getDataTransformer is used to transform the created User object to the output DTO representation.
  • $persistProcessor is used to persist the created User object to the database.

First, we transform the incoming data to a new class object, then we persist the created object to the database, and lastly we return the output.

The last step is to configure the operation in the #[ApiResource()] attribute of the User class with the parameters:

  • input: UserInputPostDto::class
  • processor: PostUserProcessor::class
  • uriTemplate: /register
…
#[ApiResource(
	operations: [
new Get(
        	uriTemplate: '/users/{id<\d+>}',
        	output: UserOutputDto::class,
        	provider: UserProvider::class,
    	),
	new GetCollection(
        	output: UserOutputDto::class,
        	provider: UsersProvider::class
    	),
new Post(
        	uriTemplate: '/register',
        	input: UserInputPostDto::class,
        	processor: PostUserProcessor::class,
    	),
	],
	normalizationContext: ['groups' => ['User:read']],
	denormalizationContext: ['groups' => ['User:write']]
)]
…

Update item

Initialize DTO for PATCH method

Sometimes when you update an entity you may want to change only particular fields of the entity and not affect other fields. In the previous versions of API Platform you could use a DataTransformerInitializerInterface that allowed you to initialize a DTO with necessary pre-initialized fields.

However, the DataTransformers along with DataTransformerInitializerInterface have been deprecated and are no longer present in API Platform v3. At present, we have not been able to ascertain the appropriate alternative for DataTransformerInitializerInterface, so we suggest you our personal workaround for the issue.

Let us start with the creation of the PersistProcessorInitializerInterface that implements the previously created PersistProcessorInterface. The interface contains one method initialize and has the following structure:

interface PersistProcessorInitializerInterface extends PersistProcessorInterface
{
	public function initialize(
mixed $data,
string $class,
string $format = null,
array $context = []
): object;
}

After that, let us create a decorator class that decorates API Platform item normalizer. Our goal is to modify the denormalization process. In order to achieve this, we must make our own implementation of the denormalize method and preserve the logic of the decorated class if needed. The following actions must be done for the implementation:

  • Preserve the native logic of the decorated class if needed.
  • Supply PATCH state providers with an interface that has initialize method

Below you can behold the implementation of the statements above:

<?php

declare(strict_types=1);

namespace App\ApiPlatform\Decorator;

use ApiPlatform\Metadata\Patch;
use ApiPlatform\Serializer\AbstractItemNormalizer;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\SerializerAwareInterface;
use Symfony\Component\Serializer\SerializerAwareTrait;

class InitializerDecorator implements DenormalizerInterface, SerializerAwareInterface
{
	use SerializerAwareTrait;

	public function __construct(
    	private AbstractItemNormalizer $decoratedNormalizer,
    	private iterable $stateProcessors
	) {
	}

	public function denormalize(mixed $data, string $class, string $format = null, array $context = []): mixed
	{
    	$this->decoratedNormalizer->setSerializer($this->serializer);

    	if (!($operation = $context['operation']) instanceof Patch || !$operation->getInput()) {
        	return $this->decoratedNormalizer->denormalize($data, $class, $format, $context);
    	}

    	foreach ($this->stateProcessors as $stateProcessor) {
        	if ($stateProcessor::class === $operation->getProcessor()) {
            	$initializedObject = $stateProcessor->initialize($data, $class, $format, $context);

            	foreach ($data as $inputField => $inputValue) {
                	if (property_exists($initializedObject, $inputField)) {
                    	try {
                        	$initializedObject->$inputField = $inputValue;
                    	} catch (\TypeError $error) {
                        	throw new UnprocessableEntityHttpException('The field "' . $inputField . '" was not expected');
                    	}
                	}
            	}

            	return $initializedObject;
        	}
    	}

    	return $this->decoratedNormalizer->denormalize($data, $class, $format, $context);
	}

	public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
	{
    	return $this->decoratedNormalizer->supportsDenormalization($data, $type, $format);
	}
}

Lastly, we need to add the configuration to the config/services.yaml file to specify that our class decorates API Platform item normalizer. To do this add the following code to your config/services.yaml file:

App\ApiPlatform\Decorator\InitializerDecorator:
    	decorates: 'api_platform.serializer.normalizer.item'
    	arguments:
        	- '@.inner'
        	- !tagged app.denormalize_initializer
    	public: false

Thenceforward, every time you want to initialize a DTO before the processing, you should make your state processor class implement PersistProcessorInitializerInterface that provides your own initialization logic via initialize method.

Update entity

The updating mechanism for the entity falls for the same pattern as the solutions above.

First, let us create a UserInputPatchDto class that should contain all the fields that can be modified in our entity:

<?php

declare(strict_types=1);

namespace App\Dto\Api\User;

use Symfony\Component\Serializer\Annotation\Groups;

class UserInputPatchDto
{
	#[Groups(['User:write'])]
	public ?string $firstname = null;

	#[Groups(['User:write'])]
	public ?string $lastname = null;

	#[Groups(['User:write'])]
	public ?string $password = null;
}

Then, let us create a UserInputPatchDataTransformer class that handles the incoming data and modifies the target object:

<?php

declare(strict_types=1);

namespace App\DataTransformer\Api\User;

use ApiPlatform\Validator\ValidatorInterface;
use App\Dto\Api\User\UserInputPatchDto;
use App\Entity\User;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;

class UserInputPatchDataTransformer
{
	public function __construct(
    	private ValidatorInterface $validator,
    	private UserPasswordHasherInterface $hasher,
	) {
	}

	public function transform(UserInputPatchDto $data, User $user): User
	{
    	$this->validator->validate($data);

    	$user->setFirstname($data->firstname);
    	$user->setLastname($data->lastname);

    	return $user;
	}
}

The final step is to create a PatchUserProcessor class that implements our PersistProcessorInitializerInterface. The code of the class is shown below:

<?php

declare(strict_types=1);

namespace App\State\Processor\User;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\DataTransformer\Api\User\UserInputPatchDataTransformer;
use App\DataTransformer\Api\User\UserOutputGetDataTransformer;
use App\Dto\Api\User\UserInputPatchDto;
use App\Repository\UserRepository;
use App\State\Processor\PersistProcessorInitializerInterface;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;

class PatchUserProcessor implements PersistProcessorInitializerInterface
{
	public function __construct(
    	private ProcessorInterface $persistProcessor,
    	private UserRepository $userRepository,
    	private UserInputPatchDataTransformer $patchDataTransformer,
    	private UserOutputGetDataTransformer $getDataTransformer,
	) {
	}

	/**
 	* @param UserInputPatchDto $data
 	*/
	public function process($data, Operation $operation, array $uriVariables = [], array $context = []): array|object|null
	{
    	$user = $this->userRepository->find($uriVariables['id']);

    	$user = $this->patchDataTransformer->transform($data, $user);

    	$this->persistProcessor->process($user, $operation, $uriVariables, $context);

    	return $this->getDataTransformer->transform($user);
	}

	public function initialize(mixed $data, string $class, ?string $format = null, array $context = []): object
	{
    	$user = $context[AbstractNormalizer::OBJECT_TO_POPULATE];

    	$dto = new UserInputPatchDto();
    	$dto->firstname = $user->getFirstname();
    	$dto->lastname = $user->getLastname();

    	return $dto;
	}
}

We created the initialize function that allows us to prefill the DTO with existing data. After the initialization the DTO is passed to the process function where the target object should be updated.

The final step is to add the necessary configuration for the PATCH method to the #[ApiResource()] attribute of the User class:

#[ApiResource(
    operations: [
   	 new Get(
   		 uriTemplate: '/users/{id<\d+>}',
   		 output: UserOutputDto::class,
   		 provider: UserProvider::class,
   	 ),
new GetCollection(
   		 output: UserOutputDto::class,
   		 provider: UsersProvider::class
   	 ),
    new Post(
   		 uriTemplate: '/register',
   		 input: UserInputPostDto::class,
   		 processor: PostUserProcessor::class,
   	 ),
    new Patch(
   		 uriTemplate: '/users/{id<\d+>}',
   		 input: UserInputPatchDto::class,
   		 processor: PatchUserProcessor::class,
   		 output: UserOutputDto::class,
   		 security: 'object === user or is_granted("ROLE_ADMIN")',
   	 ),
],
    normalizationContext: ['groups' => ['User:read']],
    denormalizationContext: ['groups' => ['User:write']]
)]

We have specified the uriTemplate for the PATCH method, as well as the following parameters: input, processor, output, and security. The security parameter in this case is used to restrict access so that either the administrator or the current authorized user has access to the object in case he wants to edit his own data.

Subresources

A Subresource is another way of declaring a resource that usually involves a more complex URI. For example, we have entities such as a user and an appointment. Each user can have multiple appointments (OneToMany). 

The Appointment entity has the following structure:

class Appointment
{
…
#[ORM\Id]
	#[ORM\GeneratedValue]
	#[ORM\Column]
	private int $id;

	#[ORM\ManyToOne(inversedBy: 'appointments')]
	private User $user;

	#[ORM\Column(length: 255)]
	private string $title;

	#[ORM\Column(type: Types::TEXT, nullable: true)]
	private ?string $description = null;

	#[ORM\Column]
	private DateTimeImmutable $schedule;

	#[ORM\Column]
	private float $price;

	#[ORM\Column(length: 255)]
	private string $status;

	#[ORM\Column]
	private DateTimeImmutable $createdAt;

	#[ORM\Column(nullable: true)]
	private ?DateTimeImmutable $updatedAt = null;
…
}

Firstly, let us keep following our development scenario and create an output DTO for the Appointment class. The code of the AppointmentOutputDto class is shown below:

<?php

declare(strict_types=1);

namespace App\Dto\Api\Appointment;

use App\Entity\Appointment;
use DateTimeImmutable;
use Symfony\Component\Serializer\Annotation\Groups;

class AppointmentOutputDto
{
	#[Groups(['Appointment:read'])]
	public int $id;

	#[Groups(['Appointment:read'])]
	public string $title;

	#[Groups(['Appointment:read'])]
	public ?string $description;

	#[Groups(['Appointment:read'])]
	public DateTimeImmutable $schedule;

	#[Groups(['Appointment:read'])]
	public float $price;

	#[Groups(['Appointment:read'])]
	public string $status;

	#[Groups(['Appointment:read'])]
	public DateTimeImmutable $createdAt;

	#[Groups(['Appointment:read'])]
	public ?DateTimeImmutable $updatedAt;

	public function __construct(
    	int $id,
    	string $title,
    	?string $description,
    	DateTimeImmutable $schedule,
    	float $price,
    	string $status,
    	DateTimeImmutable $createdAt,
    	?DateTimeImmutable $updatedAt,
	) {
    	$this->id = $id;
    	$this->title = $title;
    	$this->description = $description;
    	$this->schedule = $schedule;
    	$this->price = $price;
    	$this->status = $status;
    	$this->createdAt = $createdAt;
    	$this->updatedAt = $updatedAt;
	}
}

Additionally, let us create an AppointmentOutputDataTransformer that will transform an Appointment instance into the DTO. The code is shown below:

<?php

declare(strict_types=1);

namespace App\DataTransformer\Api\Appointment;

use App\Dto\Api\Appointment\AppointmentOutputDto;
use App\Entity\Appointment;

class AppointmentOutputDataTransformer
{
	public function transform(Appointment $appointment): AppointmentOutputDto
	{
    	return new AppointmentOutputDto(
        	$appointment->getId(),
        	$appointment->getTitle(),
        	$appointment->getDescription(),
        	$appointment->getSchedule(),
        	$appointment->getPrice(),
        	$appointment->getStatus(),
        	$appointment->getCreatedAt(),
        	$appointment->getUpdatedAt(),
    	);
	}
}

Next, create a data provider in order to retrieve objects from the database:

<?php

declare(strict_types=1);

namespace App\State\Provider\Appointment;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\DataTransformer\Api\Appointment\AppointmentOutputDataTransformer;
use App\Dto\Api\Appointment\AppointmentOutputDto;
use App\Entity\Appointment;
use App\State\Provider\CollectionProviderInterface;

class AppointmentsProvider implements CollectionProviderInterface
{
	public function __construct(
    	private ProviderInterface $collectionProvider,
    	private AppointmentOutputDataTransformer $dataTransformer,
	) {
	}

	public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
	{
    	return array_map(
        	fn (Appointment $user): AppointmentOutputDto => $this->dataTransformer->transform($user),
        	iterator_to_array(($this->collectionProvider->provide($operation, $uriVariables, $context))->getIterator())
    	);
	}
}

Lastly, we need to add the following attribute to the Appointment class:

#[ApiResource(
	uriTemplate: '/users/{userId<\d+>}/appointments',
	uriVariables: [
    	'userId' => new Link(fromClass: User::class, toProperty: 'user'),
	],
	operations: [new GetCollection()],
	normalizationContext: ['groups' => ['Appointment:read']],
	output: AppointmentOutputDto::class,
	provider: AppointmentsProvider::class,
)]

In operations we specify the request type GetCollection.

You can also configure the config/security.yaml file and allow or deny access by URI specified in the class annotation. Moreover, It is possible to specify inside the operation:

   operations: [ new Get(security: "is_granted('ROLE_ADMIN')")]

Note: If you restrict access to, for example, the entity User, then you can still get that user's appointments via /users/{userId}/appointments/{appointmentId}.

API Platform cannot create URI longer than two entities. For example, API platform cannot create a path consisting of 3 or more entities:

/users/{userId}/appointments/{appointmentId}/media_objects/{objectId}.

Conclusion

We have covered the main changes in the new version of API Platform and discovered new instruments and the way they work. The overall development strategy remains similar in most respects as in the previous version of the API Platform.