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
└── Doctrine
Layers 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