I had to create an API REST for my client. Nothing could be simpler with API Platform and Symfony.
For this project the stack is as follows:
Everything is managed with Docker. Here is an example of my docker-compose
The following example is based on a simple resource called MobileDevice.
The goal is to have a “get” method that returns the desired element and a “post” method to add an element.
Step 1: We create the entity that represents the resource
<?php namespace AppBundle\Entity; use Doctrine\ORM\Mapping as ORM; /** * Class MobileDevice * * @ORM\Entity(repositoryClass="AppBundle\Repository\MobileDeviceRepository") */ class MobileDevice { /** * @var int * * @ORM\Id() * @ORM\GeneratedValue(strategy="AUTO") * @ORM\Column(type="integer") */ private $id; /** * @var string * @ORM\Column(type="string") */ private $deviceId; /** * @var \DateTime * @ORM\Column(type="datetime") */ private $installationDate; /** * @var string * @ORM\Column(type="string") */ private $userAgent; /** * @return int */ public function getId(): int { return $this->id; } /** * @return string */ public function getDeviceId(): string { return $this->deviceId; } /** * @param string $deviceId */ public function setDeviceId(string $deviceId) { $this->deviceId = $deviceId; } /** * @return \DateTime */ public function getInstallationDate(): \DateTime { return $this->installationDate; } /** * @param \DateTime $installationDate */ public function setInstallationDate(\DateTime $installationDate) { $this->installationDate = $installationDate; } /** * @return string */ public function getUserAgent(): string { return $this->userAgent; } /** * @param string $userAgent */ public function setUserAgent(string $userAgent) { $this->userAgent = $userAgent; } }
Step 2: We create the DTO (Data Transform Object) which will represent the real resource mapped by API Platform
<?php namespace AppBundle\Dto; use ApiPlatform\Core\Annotation\ApiProperty; use Symfony\Component\Validator\Constraints as Assert; class MobileDevice { /** * @var string * * @ApiProperty(identifier=true) * @Assert\NotBlank() */ private $deviceId; /** * @var \DateTime * @Assert\NotBlank() */ private $installationDate; /** * @return string */ public function getDeviceId(): string { return $this->deviceId; } /** * @param string $deviceId */ public function setDeviceId(string $deviceId) { $this->deviceId = $deviceId; } /** * @return \DateTime */ public function getInstallationDate(): \DateTime { return $this->installationDate; } /** * @param \DateTime $installationDate */ public function setInstallationDate(\DateTime $installationDate) { $this->installationDate = $installationDate; } }
Step 3: API Platform mapping config file
'AppBundle\Dto\MobileDevice': itemOperations: get: method: 'GET' path: '/mobile/device/{id}' collectionOperations: post: method: 'POST' path: '/mobile/device'
At this point we can begin to implement our CQRS pattern.
For those who do not know what CQRS is, we invite you to take a look at these interesting articles:
French language:
- CQRS PATTERN – Eleven Labs
- CQRS ARCHITECTURE PARTIE 1 – Octo Blog
- CQRS ARCHITECTURE PARTIE 2 – Octo Blog
- A la rencontre d’une architecture méconnue : CQRS – Clever Blog
English language:
- CQRS Documents, Greg Young
- CQRS Journey, Dominic Betts, Julián Domínguez, Grigori Melnik, Fernando Simonazzi, Mani Subramanian
- Clarified CQRS, Udi Dahan
- CQRS and Event Sourcing – Code on the Beach 2014, Greg Young
Step 5: Creating Query and QueryHandler objects that are in charge of reading
<?php namespace AppBundle\Query; class GetMobileDeviceQuery { /** @var string */ private $deviceId; /** * MobileDevice constructor. * @param string $deviceId */ public function __construct(string $deviceId) { $this->deviceId = $deviceId; } /** * @return string */ public function getDeviceId(): string { return $this->deviceId; } }
<?php namespace AppBundle\Query; use AppBundle\Repository\MobileDeviceRepository; use Doctrine\ORM\EntityNotFoundException; class GetMobileDeviceQueryHandler { /** @var MobileDeviceRepository */ private $mobileDeviceRepository; /** * MobileDeviceHandler constructor. * * @param MobileDeviceRepository $mobileDeviceRepository */ public function __construct(MobileDeviceRepository $mobileDeviceRepository) { $this->mobileDeviceRepository = $mobileDeviceRepository; } /** * @param GetMobileDeviceQuery $mobileDeviceQuery * @return \AppBundle\Entity\MobileDevice|null * @throws EntityNotFoundException */ public function handle(GetMobileDeviceQuery $mobileDeviceQuery) { /** @var \AppBundle\Entity\MobileDevice|null $mobileDevice */ $mobileDevice = $this->mobileDeviceRepository->findOneBy(['deviceId' => $mobileDeviceQuery->getDeviceId()]); if (!$mobileDevice) { throw new EntityNotFoundException('Device not found'); } return $mobileDevice; } }
Step 6: Creating Command and CommandHandler objects that are in charge of writing
<?php namespace AppBundle\Command; class AddMobileDevice { /** * @var string */ private $deviceId; /** * @var \DateTime */ private $installationDate; /** * @var string */ private $userAgent; /** * MobileDevice constructor. * @param string $deviceId * @param \DateTime $installationDate */ public function __construct(string $deviceId, \DateTime $installationDate, string $userAgent) { $this->deviceId = $deviceId; $this->installationDate = $installationDate; $this->userAgent = $userAgent; } /** * @return string */ public function getDeviceId(): string { return $this->deviceId; } /** * @return \DateTime */ public function getInstallationDate(): \DateTime { return $this->installationDate; } /** * @return string */ public function getUserAgent(): string { return $this->userAgent; } }
<?php namespace AppBundle\Command; use AppBundle\Repository\MobileDeviceRepository; use AppBundle\Entity\MobileDevice; class AddMobileDeviceHandler { /** @var MobileDeviceRepository */ private $mobileDeviceRepository; /** * MobileDeviceHandler constructor. * * @param MobileDeviceRepository $mobileDeviceRepository */ public function __construct(MobileDeviceRepository $mobileDeviceRepository) { $this->mobileDeviceRepository = $mobileDeviceRepository; } public function handle(AddMobileDevice $command) { $deviceMobile = new MobileDevice(); $deviceMobile->setDeviceId($command->getDeviceId()); $deviceMobile->setInstallationDate($command->getInstallationDate()); $deviceMobile->setUserAgent($command->getUserAgent()); $this->mobileDeviceRepository->save($deviceMobile); } }
Step 7: Creating Data Provider for retrieve resource
<?php namespace AppBundle\DataProvider; use ApiPlatform\Core\DataProvider\ItemDataProviderInterface; use ApiPlatform\Core\Exception\ResourceClassNotSupportedException; use AppBundle\Dto\MobileDevice; use AppBundle\Query\GetMobileDeviceQueryHandler; use AppBundle\Query\GetMobileDeviceQuery; class MobileDeviceDataProvider implements ItemDataProviderInterface { /** @var GetMobileDeviceQueryHandler */ private $getMobileDeviceQueryHandler; /** * MobileDeviceWriteSubscriber constructor. * * @param GetMobileDeviceQueryHandler $getMobileDeviceQueryHandler */ public function __construct(GetMobileDeviceQueryHandler $getMobileDeviceQueryHandler) { $this->getMobileDeviceQueryHandler = $getMobileDeviceQueryHandler; } /** * @param string $resourceClass * @param string|null $operationName * * @return bool */ public function supports(string $resourceClass, string $operationName = null): bool { return MobileDevice::class === $resourceClass; } /** * @param string $resourceClass * @param int|string $id * @param string|null $operationName * @param array $context * @return MobileDevice * * @throws ResourceClassNotSupportedException */ public function getItem(string $resourceClass, $id, string $operationName = null, array $context = []) { if (!$this->supports($resourceClass, $operationName)) { throw new ResourceClassNotSupportedException(); } /** @var \AppBundle\Entity\MobileDevice $mobileDevice */ $mobileDevice = $this->getMobileDeviceQueryHandler->handle(new GetMobileDeviceQuery($id)); $dtoDeviceMobile = new MobileDevice(); $dtoDeviceMobile->setDeviceId($mobileDevice->getDeviceId()); $dtoDeviceMobile->setInstallationDate($mobileDevice->getInstallationDate()); return $dtoDeviceMobile; } }
Step 8: Creating Event Subscriber for add resource
<?php namespace AppBundle\EventListener\Api; use AppBundle\Dto\MobileDevice; use AppBundle\Command\AddMobileDevice as AddMobileDeviceCommand; use AppBundle\Command\AddMobileDeviceHandler; use ApiPlatform\Core\EventListener\EventPriorities; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpKernel\Event\GetResponseForControllerResultEvent; use Symfony\Component\HttpKernel\KernelEvents; use Symfony\Component\HttpFoundation\Request; class MobileDeviceWriteSubscriber implements EventSubscriberInterface { /** @var AddMobileDeviceHandler */ private $addMobileDeviceHandler; /** * MobileDeviceWriteSubscriber constructor. * * @param AddMobileDeviceHandler $addMobileDeviceHandler */ public function __construct(AddMobileDeviceHandler $addMobileDeviceHandler) { $this->addMobileDeviceHandler = $addMobileDeviceHandler; } /** * @return array */ public static function getSubscribedEvents() { return [ KernelEvents::VIEW => [ [ 'write', EventPriorities::PRE_WRITE, ], ], ]; } /** * @param GetResponseForControllerResultEvent $event */ public function write(GetResponseForControllerResultEvent $event) { $request = $event->getRequest(); $dtoDeviceMobile = $event->getControllerResult(); $method = $event->getRequest()->getMethod(); if (!$dtoDeviceMobile instanceof MobileDevice || Request::METHOD_POST !== $method) { return; } $mobileDeviceCommand = new AddMobileDeviceCommand( $dtoDeviceMobile->getDeviceId(), $dtoDeviceMobile->getInstallationDate(), $request->headers->get('User-Agent') ); $this->addMobileDeviceHandler->handle($mobileDeviceCommand); } }
RESULTS POST
RESULTS GET
Next Steps
- Create a CommandBus
- Create a QueryBus
- Create a LocatorHandler
- Separate the read and the write data storage
References:
What are the benefits of a command and handler for the query side (GetMobileDeviceQuery and GetMobileDeviceQueryHandler)? Would a simple repository suffice?
LikeLike
Hi Ian. More infos here: http://cqrs.nu/Faq/command-handlers.
LikeLike
Thanks, I’m familiar with command handlers, I was asking, why would you use it for the read side. It makes sense to me on the write side, but for reading I’m not sure I see the benefit?
LikeLike
Actually you are not obliged to use a QueryHandler, but for a question of uniformity and understanding of the pattern I decided to use it.
LikeLike
Hey, in which file did you place the mapping config?
LikeLike
is there a repository with example ? don’t know where to put the step 3 ^^
LikeLike
Hi, i have not a public repository, sorry. But for the step 3 it’s easy, because it’s the classic configuration of Api Platform:
https://api-platform.com/docs/core/operations
LikeLike
oh ! sorry ^^ i use more often symfony than api-platform, thanks for the answer
LikeLike
Except it’s not: under “api_platform”. Available options are “allo
w_plain_identifiers”, “collection”, “default_operation_path_resolver”, “description”, “doctrine”, ”
doctrine_mongodb_odm”, “eager_loading”, “elasticsearch”, “enable_docs”, “enable_entrypoint”, “enabl
e_fos_user”, “enable_nelmio_api_doc”, “enable_profiler”, “enable_re_doc”, “enable_swagger”, “enable
_swagger_ui”, “error_formats”, “exception_to_status”, “formats”, “graphql”, “http_cache”, “mapping”
, “mercure”, “messenger”, “name_converter”, “oauth”, “patch_formats”, “path_segment_name_generator”
, “resource_class_directories”, “show_webby”, “swagger”, “title”, “validator”, “version”.
LikeLike
Hi, this is a very helpful article.
I would like to know if there is a reason why you used an Event Subscriber instead of Data Persisters?… or in the other way: why did you use Data Providers instead of Controller Actions?
LikeLike
Hi German, i follow the api-platform documentation: https://api-platform.com/docs/core/data-providers/
LikeLike
[…] Today i want to show you how to use The Messanger Component of Symfony. Very useful when your project implements the CQRS pattern. […]
LikeLike