First part of the plan: We need to provide a REST API! In the previous part I explained what we wanted to achieve and how we accomplished that. Now let’s get started with some code. We are going to build a small SPA that will showcase all the different parts we saw in the previous articles. You can find the example used in these articles in this repository. We are going to use some of Symfony’s components to accomplish our goal. Please refer to the composer file to see them in detail.
- Autor
- Michael Zangerle
- Datum
- 2. März 2021
- Lesedauer
- 7 Minuten
Structure
Let’s get started with the structure of every module. Instead of putting all controllers in one directory, all repositories in another and so on we try to keep related things together. In most cases you are not working on all repositories or all controllers at once but on a new feature for the customer module for example. This is also especially helpful as through the previously shown architecture we are going to create quite a few classes and it’s a bit cumbersome to search for them or navigate there if they are all in the same directory and the list is really long.
That said, the structure of our example looks like the following. Of course, not every directory exists in every module, but that’s a good foundation to start.
└── src
├── Customer
│ ├── Controller
│ │ └── CustomerController.php
│ ├── Message
│ │ ├── Command
│ │ │ ├── ActivateCustomerCommand.php
│ │ │ └── ...
│ │ ├── CommandHandler
│ │ │ ├── ActivateCustomerCommandHandler.php
│ │ │ └── ...
│ │ ├── Event
│ │ │ ├── CustomerActivatedEvent.php
│ │ │ └── ...
│ │ ├── EventHandler
│ │ │ ├── CustomerActivatedEventHandler.php
│ │ │ └── ...
│ │ ├── Query
│ │ │ ├── ...
│ │ │ └── GetCustomersQuery.php
│ │ ├── QueryHandler
│ │ │ ├── GetCustomerQueryHandler.php
│ │ │ └── ...
│ │ └── Response
│ │ ├── CustomerResponse.php
│ │ └── ...
│ ├── Repository
│ │ ├── CustomerDoctrineRepository.php
│ │ └── CustomerRepositoryInterface.php
│ └── Security
│ └── CustomerVoter.php
One could now start a debate on the whole naming thing, but that’s not the goal of the article so I will only add one more thing: Currently we are quite happy with this structure and it makes it easy to find everything. Files and classes which belong together have that reflected in their names. But please name and structure the things the way it makes the most sense for you and your team - we might change it as well in the future.
Controller
As mentioned in the previous article, we want to use normal controllers with Symfony Routing and JSON responses. Other request/response formats are neither required in our case nor supported by this approach. Just a simple JSON API.
That’s way our controllers look similar to the customer controller found here:
<?php
namespace App\Customer\Controller;
// ...
/**
* @Route("/customers")
*/
final class CustomerController extends AbstractController
{
// ...
/**
* @Route("", methods={"GET"}, requirements={"id"="\d+"})
*/
public function cgetAction(GetCustomersQuery $query): Response
{
// ...
}
/**
* @Route("/{id}", methods={"GET"}, requirements={"id"="\d+"})
*/
public function getAction(GetCustomerQuery $query): Response
{
// ...
}
/**
* @Route("/{id}/activate", methods={"POST"}, requirements={"id"="\d+"})
*/
public function activateAction(ActivateCustomerCommand $command): Response
{
// ...
}
/**
* @Route("/{id}/deactivate", methods={"POST"}, requirements={"id"="\d+"})
*/
public function deactivateAction(DeactivateCustomerCommand $command): Response
{
// ...
}
/**
* @Route("/{id}/changeType", methods={"POST"}, requirements={"id"="\d+"})
*/
public function changeTypeAction(ChangeCustomerTypeCommand $command): Response
{
// ...
}
}
Now here we have multiple things going on:
- after adding this controller to the routing.yaml it will generate the required routes for us
- instead of getting the request in an action we already expect an object for the command or query passed on as parameter to the action
REST
As mentioned before we won’t try to be 100% REST compliant just for the sake of REST. Therefore we don’t have only classic resources but also actions like /activate, /deactivate and /changeType. The actions could also be seen as a resource of commands where a consumer can add new elements to. So it’s somewhat compliant with REST again.
Those endpoints are very specific and consequently we know what the intent of the client was from the beginning. It saves us from parsing generic update requests and guessing what the consumer of the API really wanted to do. Unless we really just want to update the whole entity, but that’s possible as well with this approach and depends on the use case.
Let’s have a look at the actions content
Every action should be very small. There should not be the need to put much logic in here. Also we want to keep the possibility to trigger commands as well as queries from the command line and don’t want to duplicate code without necessity. What should remain in such an action? There is no definitive answer to this question, but in our case we usually have …
- a call to the voter
- passing the command / query to the message bus
- returning a response
… as the only content inside a controller action. This means we usually have three lines of code in a controller action which keeps them very small. This also ensures that they only work as an entry point to the application. Here is an example:
<?php
namespace App\Customer\Controller;
// ...
/**
* @Route("/customers")
*/
final class CustomerController extends AbstractController
{
use ControllerResponseTrait;
private MessageBusInterface $queryBus;
private MessageBusInterface $commandBus;
public function __construct(
MessageBusInterface $queryBus,
MessageBusInterface $commandBus,
SerializerInterface $serializer
) {
$this->queryBus = $queryBus;
$this->commandBus = $commandBus;
$this->serializer = $serializer;
}
/**
* @Route("", methods={"GET"}, requirements={"id"="\d+"})
*/
public function cgetAction(GetCustomersQuery $query): Response
{
$this->isGranted(CustomerVoter::LIST);
$envelope = $this->queryBus->dispatch($query);
return $this->createJsonResponseFromEnvelope($envelope);
}
//…
}
The trait is used to unwrap the response from the message bus, pass it on to the serializer (including some serialize options) and create a JSON response. That’s it.
Next steps
As we now have the routing and controller setup. Let’s have a look at these command and query objects automatically passed into our controller actions in part 3.