Mathias Arlaud
Co-Founder & COO @Bakslash - Co-Founder & CTO @Synegram
@matarld
@chalas_r
@matarld
mtarld
les-tilleuls.coop
@chalas_r
chalasr
les-tilleuls.coop
@matarld
@chalas_r
@matarld
@chalas_r
@matarld
@chalas_r
Common API Platform
└──   src
      ├──   Provider
      │
      ├──   Processor
      │
      ├──   Entity
      │     └──   Book.php
      │
      └──   Repository
            └──   BookRepository.php
@matarld
@chalas_r
Hexagonal API Platform
└──   src
      ├──   Application
      │     └──   BookStore
      │           ├──   Command
      │           └──   Query
      │                 ├──   FindBookQuery.php
      │                 └──   FindBookQueryHandler.php
      ├──   Domain
      │     └──   BookStore
      │           ├──   Model
      │           │     └──   Book.php
      │           └──   Repository
      │                 └──   BookRepository.php
      └──   Infrastructure
            └──   BookStore
                  └──   DoctrineLayers and dependency rule
Models, Value objects, Events, Repositories 
Use cases, Application services, DTOs, Commands, Queries
Controllers, Databases, Caches, Vendors
@matarld
@chalas_r
Benefits
Domain integrity is preserved
Code is more testable
Technological decisions can be deferred
Domain is agnostic to the outside world
@matarld
@chalas_r
GET /books
GET /books/cheapests
PATCH /books/{id}
DELETE /books/{id}
POST /books/anonymize
POST /books/{id}/discount
[...]
Business op.
CRUD op.
namespace App\Domain\BookStore\Model;
final class Book
{
    public readonly Uuid $id;
    public function __construct(
        public string $name,
        public string $description,
        public string $author,
        public string $content,
        public int $price,
    ) {
        $this->id = Uuid::v4();
        
        Assert::positive($price);
    }
}
namespace App\Domain\BookStore\Model;
final class Book
{
    public readonly Uuid $id;
    public function __construct(
        public string $name,
        public string $description,
        public string $author,
        public string $content,
        public int $price,
    ) {
        $this->id = Uuid::v4();
        
        Assert::positive($price);
    }
}
@matarld
@chalas_r
11 warnings
7 errors
  namespace App\Domain\BookStore\Model;
  #[ApiResource(operations: [
      new Get(),
      new Post('/books/{id}/discount'),
  ])]
  final class Book
  {
      public function __construct(
          #[Groups(['book:create'])]
          #[Assert\NotBlank]
          public ?Uuid $id = null;
        
          #[Groups(['book:create', 'book:update'])]
          #[Assert\Positive]
          public ?int $price = null,
          // ...
      ) {
          $this->id = $id ?? Uuid::v4();
      }
  }@matarld
@chalas_r
namespace App\Infrastructure\BookStore\ApiPlatform\Resource;
#[ApiResource(operations: [new Get(), new Post('...')])]
final class BookResource
{
    public function __construct(
        #[ApiProperty(identifier: true)]
        #[Groups(['book:create'])]
        #[Assert\NotBlank]
        public ?Uuid $id = null;
        // ...
    ) {
        $this->id = $id ?? Uuid::v4();
    }
}namespace App\Domain\BookStore\Model;
final class Book
{
    public readonly Uuid $id;
    public function __construct(
        public string $name,
        public string $description,
        public string $author,
        public string $content,
        public int $price,
    ) {
        // ...
    }
}
@matarld
@chalas_r
Between domain and infrastructure
Domain layer
Infrastructure layer
Application layer
@matarld
@chalas_r
@matarld
@chalas_r
Use case #1
@matarld
@chalas_r
namespace App\Application\BookStore\Query;
final class FindCheapestBooksQuery implements QueryInterface
{
    public function __construct()
    {
    }
}namespace App\Application\BookStore\Query;
final class FindCheapestBooksQueryHandler implements QueryHandlerInterface
{
    public function __construct(private BookRepositoryInterface $bookRepository)
    {
    }
    public function __invoke(FindCheapestBooksQuery $query): iterable
    {
        return $this->bookRepository->withCheapests();
    }
}
@matarld
@chalas_r
#[ApiResource(
  itemOperations: [
    'get',
  ],
  collectionOperations: [
    'get' => [],
    'post' => ['input' => Dto::class, 'output' => false],
  ],
)]
final class BookResource { /* ... */ }#[ApiResource]
#[Get]
#[GetCollection]
#[Post(input: Dto::class, output: false)]
final class BookResource { /* ... */ }#[ApiResource(operations: [
    new Get(),
    new GetCollection(),
    new Post(input: Dto::class, output: false)
])]
final class BookResource { /* ... */ }API Platform 2
API Platform 3
+ PHP 8.1
@matarld
@chalas_r
namespace App\Infrastructure\Shared\ApiPlatform\Metadata;
use ApiPlatform\Metadata\Operation;
final class RestrictedOperation extends Operation
{
    public function __construct(
        string $uriTemplate,
        // ...
    ) {
        parent::__construct(
            method: self::METHOD_GET,
            uriTemplate: $uriTemplate,
            security: 'is_granted("ROLE_ADMIN")',
            stateless: true,
            // ...
        );
    }
}#[ApiResource(operations: [
    new RestrictedOperation('/foo'),
    new RestrictedOperation('/bar', output: Bar::class),
])]
final class FooResource { /* ... */ }#[ApiResource(operations: [
    new Get(
        '/foo',
        security: 'is_granted("ROLE_ADMIN")',
        stateless: true,
    ),
    new Get(
        '/bar',
        output: Bar::class,
        security: 'is_granted("ROLE_ADMIN")',
        stateless: true,
    ),
])]
final class FooResource { /* ... */ }#[ApiResource(operations: [
    // Please use the FindCheapestBooksQuery
    new Get(
        uriTemplate: '/books/cheapest',
    ),
    
])]
final class BookResource { /* ... */ }#[ApiResource(operations: [
    new QueryOperation(
      uriTemplate: '/books/cheapest',
      query: FindCheapestBooksQuery::class,
    ),
])]
final class BookResource { /* ... */ }namespace App\Infrastructure\Shared\ApiPlatform\Metadata;
use ApiPlatform\Metadata\Operation;
final class QueryOperation extends Operation
{
    public string $query;
    public function __construct(
        string $uriTemplate,
        string $query,
        // ...
    ) {
        parent::__construct(
            method: self::METHOD_GET,
            // ...
        );
        $this->query = $query;
    }
}@matarld
@chalas_r
GET /books
@matarld
@chalas_r
ChainProvider
FooProvider
                         .
DoctrineProvider
ElasticProvider
GraphqlProvider
GET /books
@matarld
@chalas_r
ChainProvider
Our query providers
QueryProvider
CrudQueryProvider
DoctrineProvider
GET /books/cheapest
Whatever query bus
CheapestBooksProvider
FindCheapestBooksQueryHandler
#[ApiResource(operations: [
    new QueryOperation(
        uriTemplate: '/books/cheapest',
        query: FindCheapestBooksQuery::class,
    ),
])]
final class BookResource { /* ... */ }@matarld
@chalas_r
namespace App\Infrastructure\BookStore\ApiPlatform\State\Provider;
final class CheapestBooksProvider implements ProviderInterface
{
    public function __construct(
        private QueryBusInterface $queryBus,
    ) {
    }
    public function provide(...): object|array|null
    {
        $books = $this->queryBus->ask(new FindCheapestBooksQuery());
        
        return array_map(fn ($b) => BookResource::fromModel($b), $books);
    }
    public function supports(...): bool
    {
        return $context['operation'] instanceof QueryOperation
          && FindCheapestBooksQuery::class === $context['operation']->query;
    }
}
Handles the specific FindCheapestBooksQuery
@matarld
@chalas_r
@matarld
@chalas_r
Use case #2
@matarld
@chalas_r
namespace App\Application\BookStore\Command;
final class DiscountBookCommand implements CommandInterface
{
    public function __construct(
        public readonly Uuid $id,
        public readonly int $amount,
    ) {
    }
}
namespace App\Application\BookStore\Command;
final class DiscountBookCommandHandler implements CommandHandlerInterface
{
    public function __construct(private BookRepositoryInterface $bookRepository)
    {
    }
    public function __invoke(DiscountBookCommand $command): void
    {
        // my super complex logic
    }
}
#[ApiResource(operations: [
    // Please convert create a DiscountBookCommand 
    // from a DiscountBookPayload
    // and use that command
    new Post(
        uriTemplate: '/books/{id}/discount',
        input: DiscountBookPayload::class,
    ),
    
])]
final class BookResource { /* ... */ }#[ApiResource(operations: [
    new CommandOperation(
        uriTemplate: '/books/{id}/discount',
        input: DiscountBookPayload::class,
        command: DiscountBookCommand::class,
    ),
])]
final class BookResource { /* ... */ }namespace App\Infrastructure\Shared\ApiPlatform\Metadata;
final class CommandOperation extends Operation
{
    public string $command;
    public function __construct(
        string $uriTemplate,
        string $command,
        ?string $input = null,
        // ...
    ) {
        parent::__construct(
            method: self::METHOD_POST,
            input: $input ?? $command,
            // ...
        );
        $this->command = $command;
    }
}@matarld
@chalas_r
POST /books
ChainProcessor
CommandProcessor
CrudCommandProcessor
Our command processors
DoctrineProcessor
@matarld
@chalas_r
{ "amount": 100 }
POST /books/{id}/discount
DiscountBookProcessor
Whatever command bus
DiscountBookCommandHandler
DeserializeListener
DiscountBookCommandDataTransformer
DiscountBookPayload
DiscountBookCommand
@matarld
@chalas_r
POST /books/{id}
{ "amount": 100 }
namespace App\Infrastructure\BookStore\ApiPlatform\DataTransformer;
final class DiscountBookCommandDataTransformer implements DataTransformerInterface
{
    public function transform(...): DiscountBookCommand
    {
        return new DiscountBookCommand(
            $context['identifiers_values']['id'],
            $object->amount,
        );
    }
    public function supportsTransformation(...): bool
    {
        return $context['operation'] instanceof CommandOperation
            && DiscountBookCommand::class === $context['operation']->command
            && DiscountBookPayload::class === $context['input']['class'];
    }
}POST /books/{id}
{ "amount": 100 }
@matarld
@chalas_r
namespace App\Infrastructure\BookStore\ApiPlatform\State\Processor;
final class DiscountBookProcessor implements ProcessorInterface
{
    public function __construct(
        private CommandBusInterface $commandBus,
    ) {
    }
    public function process(...): void
    {
        $this->commandBus->dispatch($data);
    }
    public function supports(...): bool
    {
        return $data instanceof DiscountBookCommand;
    }
}
Handles the specific DiscountBookCommand
@matarld
@chalas_r
#[ApiResource(operations: [
    new GetCollection(),
    new Get(),
    new Post(),
    new Put(),
    new Patch(),
    new Delete(),
])]
final class BookResource { /* ... */ }#[ApiResource]
final class BookResource { /* ... */ }@matarld
@chalas_r
Retrieve a single book
Retrieve a book collection
Handle filters and pagination
namespace App\Infrastructure\BookStore\ApiPlatform\State\Provider;
final class BookCrudProvider implements ProviderInterface
{
    public function provide(...): object|array|null
    {
        if (!$context['operation']->isCollection()) {
            $book = $this->queryBus->ask(new FindBookQuery($identifiers['id']));
            return BookResource::fromModel($book);
        }
        $author = $context['filters']['author'] ?? null;
        $offset = $this->pagination->getPage(...);
        $limit = $this->pagination->getLimit(...);
        
        $models = $this->queryBus->ask(new FindBooksQuery($author, $offset, $limit));
        
        $resources = [];
        foreach ($models as $model) { $resources[] = BookResource::fromModel($model); }
        
        return new Paginator($resources, $models->currentPage(), ...);
    }
    public function supports(...): bool { return BookResource::class === $resourceClass; }
}
@matarld
@chalas_r
Update a book
Delete a book
Create a book
namespace App\Infrastructure\BookStore\ApiPlatform\State\Processor;
final class BookCrudProcessor implements ProcessorInterface
{
    public function process(...): ?Book
    {
        if ($context['operation']->isDelete()) {
            $this->commandBus->dispatch(new DeleteBookCommand(...));
            return null;
        }
        $command = !isset($identifiers['id'])
            ? new CreateBookCommand(...)
            : new UpdateBookCommand(...)
        ;
        $book = $this->commandBus->dispatch($command);
        
        return BookResource::fromModel($book);
    }
    
    public function supports(...): bool
    {
        return BookResource::class === $context['operation']->getClass();
    }
}@matarld
@chalas_r
@matarld
@chalas_r
└──   Domain
    └──   BookStore
        ├──   Model
        │   └──   Book.php
        └──   Repository
            └──   BookRepositoryInterface.php└──   Application
    └──   BookStore
        ├──   Command
        │   ├──   CreateBookCommand.php
        │   ├──   CreateBookCommandHandler.php
        │   ├──   DiscountBookCommand.php
        │   ├──   DiscountBookCommandHandler.php
        │   └── ...
        └──   Query
            ├──   FindBookQuery.php
            ├──   FindBookQueryHandler.php
            ├──   FindCheapestBooksQuery.php
            ├──   FindCheapestBooksQueryHandler.php
            └── ...└─   Infrastructure
   ├─   BookStore
   │  └─   ApiPlatform
   │     ├─   DataTransformer
   │     │  └─   DiscountBookCommandDataTransformer.php
   │     ├─   Payload
   │     │  └─   DiscountBookPayload.php
   │     ├─   Resource
   │     │  └─   BookResource.php
   │     └─   State
   │        ├─   Processor
   │        │  ├─   BookCrudProcessor.php
   │        │  └─   DiscountBookProcessor.php
   │        └─   Provider
   │           ├─   BookCrudProvider.php
   │           └─   CheapestBooksProvider.php
   └─   Shared
      ├─   ApiPlatform
      │  └─   Metadata
      │     ├─   CommandOperation.php
      │     └─   QueryOperation.php
      └─   Symfony@matarld
@chalas_r
#[ApiResource(operations: [
    // Queries
    new QueryOperation('/books/{id}/overview', query: FindBookQuery::class),
    new QueryOperation('/books/cheapest', query: FindCheapestBooksQuery::class, output: BookOverviewView::class),
    // Commands
    new CommandOperation('/books/anonymize', AnonymizeBooksCommand::class),
    new CommandOperation('/books/discount', command: DiscountBooksCommand::class, output: false, status: 202),
    new CommandOperation('/books/{id}/discount', command: DiscountBookCommand::class, input: DiscountBookPayload::class),
    // CRUD
    new GetCollection(),
    new Get(),
    new Post(),
    new Put(),
    new Patch(),
    new Delete(),
])]
final class BookResource { /* ... */ }@matarld
@chalas_r
@matarld
@chalas_r
By Mathias Arlaud