AppLayerBundle is a Symfony bundle that implements the application boundary of a CQRS-shaped DDD system. It carries the HTTP request through sanitization, DTO denormalization, command or query handler invocation, and optional queue dispatch, with first-class interfaces for CommandHandlerInterface and QueryHandlerInterface.
It is transport-agnostic: it works in plain Symfony controllers, under API Platform state providers and processors, in Messenger handlers, or in console commands. It depends only on symfony/serializer and the standard Symfony service container; symfony/messenger is an optional integration for async commands.
The architectural motivation and the API Platform glue are described in the article CQRS Application Layer on top of API Platform. This page is the reference for the bundle itself.
Why it exists
A typical Symfony HTTP handler conflates three concerns:
- request parsing and validation (transport),
- mapping the validated payload into a use-case input (application layer),
- orchestrating the use-case against the domain model (application/domain).
AppLayerBundle claims the middle slice. The boundary between transport and use-case is an immutable DTO. The use-case itself is expressed as a CommandHandler (mutates state, returns a result) or a QueryHandler (returns read-side data). The handler can use API Platform, Messenger, custom repositories, or anything else — the bundle imposes no constraint beyond the contract.
This shape keeps the use-case unit-testable in isolation, makes the intent of every endpoint explicit (command or query), and lets the transport layer (controllers, API Platform, RPC, CLI) stay a thin adapter.
Key features
- CQRS-shaped contracts: distinct
CommandHandlerInterface and QueryHandlerInterface, registered through separate tagged locators.
- Immutable DTO denormalization through
symfony/serializer, with built-in support for readonly constructor-promoted DTOs and property-only DTOs (no setAccessible since PHP 8.5).
- Optional request sanitization for command payloads. Queries skip sanitization by design — read-side input is not mutated.
- Synchronous and asynchronous command handling via
symfony/messenger. The MessengerQueueDispatcher is wired automatically when the package is installed; otherwise a NullQueueDispatcher is used.
- Pluggable processor pipeline (
DataProcessorInterface) for endpoints that do not carry a DTO (lookup tables, projections, derivations).
- Structured error handling through
RequestException, which wraps every denormalization, locator, and handler-resolution failure with diagnostic context.
Architecture
HTTP Request
│
▼
DtoRequestHandler
│
├── sanitize (RequestSanitizerInterface, commands only)
├── convert (RequestToDtoConverterInterface → SymfonyDtoDeserializer)
├── resolve (commandLocator | queryLocator)
├── invoke (CommandHandlerInterface.handle | QueryHandlerInterface.handle)
└── dispatch (DtoQueueDispatcherInterface, commands only, async opt-in)
Contracts
CommandHandlerInterface — tagged app_layer.command_handler. Mutates state and returns a command result (id, presenter, view DTO).
QueryHandlerInterface — tagged app_layer.query_handler. Returns read-side data without side effects.
DataProcessorInterface — tagged app_layer.data_processor. Used for endpoints without a DTO.
DtoDeserializerInterface — abstraction over the underlying denormalizer. Default: SymfonyDtoDeserializer.
RequestToDtoConverterInterface — extracts the payload from a Symfony Request and turns it into a DTO.
RequestSanitizerInterface — optional pre-DTO cleanup for commands.
Components
DtoRequestHandler — orchestrates the pipeline above. Two entry points: dispatchCommand() and dispatchQuery().
DefaultRequestToDtoConverter — JSON body or query/form merger, then DTO denormalization.
SymfonyDtoDeserializer — routes DTOs with constructors to ObjectNormalizer; handles property-only DTOs via direct reflection (no setAccessible).
DataProcessor — tagged locator for processor-only endpoints.
Dispatchers
DtoQueueDispatcherInterface — the abstract dispatch boundary.
MessengerQueueDispatcher — implemented when symfony/messenger is installed.
NullQueueDispatcher — fallback when no transport is installed.
Installation
composer require elriseio/application-layer-bundle
Requirements
- PHP 8.3 or higher with the
ctype, curl, and json extensions enabled (all three are bundled by default in standard PHP distributions).
- Symfony 7.2 or higher.
Register the bundle:
// config/bundles.php
return [
// ...
Elrise\Bundle\AppLayerBundle\AppLayerBundle::class => ['all' => true],
];
Usage
1. Define an immutable DTO
final readonly class CreateOrderCommand
{
public function __construct(
public string $customerId,
public array $items,
) {
}
}
final readonly class ListOrdersQuery
{
public function __construct(
public string $customerId,
public int $limit = 20,
) {
}
}
2. Implement a command handler
use Elrise\Bundle\AppLayerBundle\Contract\CommandHandlerInterface;
use Symfony\Component\HttpFoundation\Request;
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()];
}
}
3. Implement a query handler
use Elrise\Bundle\AppLayerBundle\Contract\QueryHandlerInterface;
use Symfony\Component\HttpFoundation\Request;
final class ListOrdersHandler implements QueryHandlerInterface
{
public function __construct(private OrderRepository $orders) {}
public function handle(Request $request, object $query): mixed
{
\assert($query instanceof ListOrdersQuery);
return $this->orders->listFor($query->customerId, $query->limit);
}
}
4. Dispatch from a controller
final class OrderController
{
public function __construct(private DtoRequestHandler $handler) {}
#[Route('/orders', methods: ['POST'])]
public function create(Request $request): JsonResponse
{
$result = $this->handler->dispatchCommand(
request: $request,
commandFqcn: CreateOrderCommand::class,
handlerFqcn: CreateOrderHandler::class,
);
return new JsonResponse($result, 201);
}
#[Route('/orders', methods: ['GET'])]
public function list(Request $request): JsonResponse
{
$items = $this->handler->dispatchQuery(
request: $request,
queryFqcn: ListOrdersQuery::class,
handlerFqcn: ListOrdersHandler::class,
);
return new JsonResponse(['items' => $items]);
}
}
To dispatch asynchronously, pass dispatchToQueue: true to dispatchCommand. The handler still runs synchronously, and the already-mutating command is also handed to the configured queue dispatcher (Messenger by default) for downstream consumers.
5. Use the processor pipeline (DTO-less endpoints)
use Elrise\Bundle\AppLayerBundle\Contract\DataProcessorInterface;
use Symfony\Component\HttpFoundation\Request;
final class OrderSummaryProcessor implements DataProcessorInterface
{
public function __construct(private SummaryService $summary) {}
public function process(Request $request): mixed
{
return $this->summary->build();
}
}
// In a controller:
$result = $this->dataProcessor->process($request, OrderSummaryProcessor::class);
API Platform's Processor and Provider interfaces map naturally onto DtoRequestHandler. The recommended pattern is to keep API Platform purely as a transport adapter and delegate to the application layer for the actual use-case.
State Provider for read endpoints
use ApiPlatform\Metadata\Get;
use ApiPlatform\State\ProviderInterface;
use Elrise\Bundle\AppLayerBundle\Handler\DtoRequestHandler;
use Symfony\Component\HttpFoundation\Request;
final class OrderListProvider implements ProviderInterface
{
public function __construct(private DtoRequestHandler $handler) {}
public function provide(Get $operation, array $uriVariables = [], array $context = []): iterable
{
$result = $this->handler->dispatchQuery(
request: Request::createFromGlobals(),
queryFqcn: ListOrdersQuery::class,
handlerFqcn: ListOrdersHandler::class,
);
return $result;
}
}
Processor for write endpoints
use ApiPlatform\Metadata\Post;
use ApiPlatform\State\ProcessorInterface;
use Elrise\Bundle\AppLayerBundle\Handler\DtoRequestHandler;
use Symfony\Component\HttpFoundation\Request;
final class CreateOrderProcessor implements ProcessorInterface
{
public function __construct(private DtoRequestHandler $handler) {}
public function process(mixed $data, Post $operation, array $uriVariables = [], array $context = []): mixed
{
return $this->handler->dispatchCommand(
request: Request::createFromGlobals(),
commandFqcn: CreateOrderCommand::class,
handlerFqcn: CreateOrderHandler::class,
);
}
}
The boundary stays explicit: API Platform owns OpenAPI, content negotiation, rate limits, and the response shape. The application layer owns the use-case. DDD aggregates, repositories, and domain services live one layer below, called only from the command/query handlers.
A worked walkthrough of this integration is in the article CQRS Application Layer on top of API Platform.
Error handling
Every denormalization, locator, and handler-resolution failure is wrapped in a single exception type 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,
);
}
The same exception type is raised for handler-resolution failures (missing FQCN, wrong interface, unregistered service). This keeps the application boundary auditable: exactly one exception class is thrown at the transport layer, and it always carries the diagnostic context needed to triage the failure.
Testing
The bundle ships with PHPUnit coverage for the orchestrator, the denormalizer, the locator, and the queue dispatcher:
composer test
Development
The project ships a project-local pre-commit hook that runs composer check (cs:check + test) so style drift and test breakage are caught locally before push. The hook is wired through core.hooksPath and only takes effect inside this checkout.
Install it once after cloning:
./scripts/install-hooks.sh
This sets core.hooksPath to ./.githooks. The hook then runs automatically before every commit; bypass it with git commit --no-verify when a commit legitimately needs to land without a re-run (for example, a composer.lock rotation triggered by a maintainer-only action).
Project status
Active. The bundle is the application layer used by production projects that pair Symfony with API Platform and Messenger; new contracts (additional sanitizers, alternative queue dispatchers) are added as the use-cases demand them.
Reference