CQRS Application Layer on top of API Platform
Why HTTP handlers should not be domain use-cases, and how to keep the use-case boundary explicit when API Platform and CQRS share the same surface.
Why HTTP handlers should not be domain use-cases, and how to keep the use-case boundary explicit when API Platform and CQRS share the same surface.
The default shape of a Symfony controller treats the HTTP request as the use-case. It asks the framework for the request body, validates the input with attributes, mutates a few entities through a repository, and returns a JSON view. It works until the same use-case has to be exposed through a different transport — a CLI command, a Messenger handler, a webhook — and the business logic has to be untangled from the HTTP plumbing one more time.
This article walks through a small architectural shift I made in a few projects, and the Symfony bundle that captures it: AppLayerBundle. The pattern itself is not new — it is the Application Layer from the DDD tradition described by Evans in Domain-Driven Design and Vernon in Implementing Domain-Driven Design; Vernon, in turn, describes how that layer naturally accommodates the CQRS separation of commands and queries. What is worth talking about is the specific glue with API Platform, and the boundary choices that keep the use-case honest without making every endpoint pay for ceremony it does not need.
Every inbound request to a Symfony application carries three concerns, regardless of the framework on top:
Most Symfony code I see collapses all three into the controller. The handler is the use-case. The repository call is the domain. The JSON serializer is the boundary. When the same use-case is needed elsewhere — say, from a Messenger consumer or a CLI tool — the controller logic has to be lifted back out, often with subtle behavioral drift.
DDD proposes a fix: introduce an Application Layer that owns the boundary between transport and domain. The Application Layer exposes a use-case API as a set of commands and queries. Each command and query is a small, testable object that takes a request, runs the use-case, and returns a result. The transport layer becomes a thin adapter that converts HTTP into the application API.
CQRS — Command Query Responsibility Segregation — is often sold as a high-scale separation of read and write paths. That is not what is being asked of it here. The relevant idea is the much simpler one: operations that mutate state are different from operations that read it, and the code should say so.
A command handler takes a command and a request context, runs the use-case, and returns a result. A query handler takes a query, runs a read, and returns the data. The two have different operational profiles:
By giving each side its own interface and its own tagged locator, the framework can treat them differently without sprinkling conditional logic through the codebase.
API Platform is excellent at the transport layer. It generates OpenAPI, negotiates content types, exposes operations as state providers and processors, and integrates with security and rate limits. What it does not do is define a standalone use-case API. Providers and processors remain transport adapters: they can be shared across operations, but the project still has to define its command/query boundary and handler contracts separately.
The clean separation is to keep API Platform as the transport, and route each operation to a command or query in the Application Layer. The state provider becomes:
final class OrderListProvider implements ProviderInterface
{
public function __construct(private DtoRequestHandler $handler) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
{
$result = $this->handler->dispatchQuery(
request: Request::createFromGlobals(),
queryFqcn: ListOrdersQuery::class,
handlerFqcn: ListOrdersHandler::class,
);
return $result;
}
}
The processor for a write operation is the same shape, but calls dispatchCommand. API Platform still owns OpenAPI, content negotiation, and security. The Application Layer owns the use-case. The domain is reachable only through the Application Layer.
A CQRS-shaped Application Layer needs three things:
CommandHandlerInterface and QueryHandlerInterface are tagged differently so the container can route and the framework can apply different operational rules.The three pieces fit into a layer cake that looks like this. Each layer talks only to the one directly below it — the application layer is the seam between transport and domain.
┌─────────────────────────────────────────────────────────────────┐
│ Transport │
│ HTTP · OpenAPI · content negotiation · rate limits · auth │
│ API Platform · Symfony controller · CLI · Messenger worker │
└─────────────────────────────────────────────────────────────────┘
│ request ──▶ DTO FQCN + handler FQCN
▼
┌─────────────────────────────────────────────────────────────────┐
│ Application Layer (AppLayerBundle) │
│ sanitize ─▶ denormalize ─▶ resolve ─▶ invoke ─▶ dispatch │
│ CommandHandlerInterface · QueryHandlerInterface │
└─────────────────────────────────────────────────────────────────┘
│ command / query
▼
┌─────────────────────────────────────────────────────────────────┐
│ Domain │
│ Aggregates · repositories · domain services · invariants │
│ Reachable only through the application layer │
└─────────────────────────────────────────────────────────────────┘
Here is the minimal shape I have settled on. It is intentionally small — every additional responsibility (caching, idempotency keys, transaction boundaries) is an explicit opt-in.
final readonly class CreateOrderCommand
{
public function __construct(
public string $customerId,
public array $items,
) {}
}
final class CreateOrderHandler implements CommandHandlerInterface
{
public function __construct(private OrderRepository $orders) {}
public function handle(Request $request, object $command): mixed
{
\assert($command instanceof CreateOrderCommand);
$order = $this->orders->create($command);
return ['id' => $order->id()];
}
}
The orchestrator is the boundary. In Symfony terms, it is the entry point that a controller — or, in the case of API Platform, a processor — calls into.
AppLayerBundle (github.com/elriseio/application-layer-bundle) implements this core CQRS path. It is not a framework on top of API Platform; it is a small pipeline between the transport and the use-case:
HTTP Request
│
▼
┌──────────────────┐
│ dispatchCommand │ dispatchQuery (no sanitize, no queue)
└────────┬─────────┘
│
┌────────▼─────────┐ commands only
│ sanitize │ via RequestSanitizerInterface
└────────┬─────────┘
│
┌────────▼─────────┐
│ convert ─▶ DTO │ via SymfonyDtoDeserializer
└────────┬─────────┘
│
┌────────▼─────────┐
│ resolve handler │ tagged locator
│ │ (command_handler | query_handler)
└────────┬─────────┘
│
┌────────▼─────────┐
│ handler.handle │ returns result (id, view, data)
└────────┬─────────┘
│
┌────────▼─────────┐ commands + dispatchToQueue = true
│ dispatch │ via MessengerQueueDispatcher
└──────────────────┘
symfony/serializer. Readonly constructor-promoted DTOs are supported natively. Property-only DTOs are handled through direct reflection, without relying on ReflectionProperty::setAccessible() (deprecated in PHP 8.5).symfony/messenger is installed; otherwise a null dispatcher is used.For endpoints without a DTO, the bundle has a separate DataProcessor; it does not change the command/query path described above.
There is no automatic retry, no caching, no event sourcing, no aggregate factory. Those concerns belong above the application boundary, not inside it. Anything that every command needs — validation, authorization, idempotency — is better expressed as middleware on the use-case or as a decorator on the handler.
One of the more important details is how the bundle reports failures. A denormalization error is not a programmer mistake — it is a runtime condition caused by an upstream system or a misbehaving client. Input parsing, denormalization, and handler-resolution failures are normalized into a RequestException with structured context:
try {
$object = $this->serializer->denormalize($data, $type, null, $context);
} catch (\Throwable $e) {
throw new RequestException(
sprintf('Failed to denormalize DTO "%s": %s', $type, $e->getMessage()),
['type' => $type, 'data_keys' => array_keys($data)],
0,
$e,
);
}
Handler resolution failures — when the FQCN passed in does not implement the expected interface, or the service is not registered — produce the same exception with the same shape. Exceptions from the use-case itself or from infrastructure are intentionally left untouched: the transport layer catches RequestException alongside domain and infrastructure exceptions, not instead of them.
Async dispatch is the place where most Application Layer implementations overreach. The temptation is to assume that every command will eventually need to be queued, and to bake queue semantics into the orchestrator. That is a mistake: the synchronous path is the default, and queueing is an opt-in for the cases where it actually pays off.
The bundle keeps queueing as an explicit per-call choice:
$result = $this->handler->dispatchCommand(
request: $request,
commandFqcn: CreateOrderCommand::class,
handlerFqcn: CreateOrderHandler::class,
dispatchToQueue: true,
);
When dispatchToQueue is true, the handler runs synchronously so the HTTP response can still carry a meaningful result, and the command is then handed to the configured dispatcher. This keeps downstream dispatch optional, but it is not delivery confirmation: without Messenger, the null dispatcher simply skips the queue step.
The first version of the bundle used a single RequestHandlerInterface for both commands and queries. That looked DRY in the spec, but it collapsed exactly the distinction the Application Layer was supposed to preserve. A handler that both reads and writes state is a service, not a use-case. Splitting the contract was a 30-line change that paid back every time the operational rules had to diverge.
The second lesson was about DTO denormalization. The first property-only path relied on setAccessible(true). Since PHP 8.1 that call has no effect on property access, and PHP 8.5 deprecates it. The current path assigns values through direct reflection; constructor DTOs still go through ObjectNormalizer, and both branches share the same error handling.
The third lesson was about compiler passes. The first cut had a custom DataProcessorPass building the processor locator alongside declarative configuration. Removing the pass eliminated the procedural duplication and left the wiring expressed through a tagged locator.
The Application Layer pattern is not free. Every command and query is a class. Every handler is a class. The overhead pays off when the project has more than a handful of non-trivial use-cases, when the same logic has to be exposed through more than one transport, or when the team needs the explicit command/query split for operational reasons.
For a four-endpoint CRUD over a single resource, a plain controller is fine. The ceremony of a command, a handler, an interface, and a tag is not justified by the simplicity of the use-case. The bundle is not meant to be the default — it is meant to be there when the default stops being enough.
The code referenced in this article is at github.com/elriseio/application-layer-bundle. The bundle is published under MIT, targets PHP 8.3+ and Symfony 7.2+, and keeps Messenger as an optional integration. PHPUnit covers the orchestrator, the denormalizer, the locator, and the queue dispatcher. The README walks through the same patterns covered here, with examples for plain controllers and API Platform processors. The package is also available on Packagist: elriseio/application-layer-bundle.
The full project reference — contracts, components, dispatchers, API Platform glue, error handling, and Messenger integration — lives on the project page: application-layer-bundle.