Overview
AppLayerBundle is a Symfony bundle that implements the Application Layer boundary in a CQRS-shaped DDD system. A request flows through sanitize → denormalize DTO → resolve handler → invoke → dispatch; the split between the two sides of the boundary is captured by the dedicated CommandHandlerInterface and QueryHandlerInterface.
The bundle is transport-agnostic: it works the same way in plain Symfony controllers, in API Platform state providers and processors, in Messenger handlers, and in console commands. It depends only on symfony/serializer and the standard Symfony service container; symfony/messenger is an optional integration for asynchronous commands.
Who this is for:
- Symfony architects separating HTTP transport, application layer, and domain in a CQRS-shaped system;
- teams whose handler-controller has grown to 200+ lines and needs an explicit boundary between request parsing, the use-case, and domain orchestration;
- API Platform projects where an endpoint maps to a use-case (Command/Query) rather than directly to an entity — the bundle lets API Platform keep its transport role and takes over handler-resolution and DTO denormalization.
This page is the bundle reference: contracts, components, dispatchers, usage scenarios. The architectural motivation and the API Platform glue are in the note CQRS Application Layer on top of API Platform.
Quick start
Minimum scenario: one command DTO, one handler, one call from a controller.
composer require elriseio/application-layer-bundle
// config/bundles.php
return [
Elrise\Bundle\AppLayerBundle\AppLayerBundle::class => ['all' => true],
];
final readonly class CreateOrderCommand
{
public function __construct(
public string $customerId,
public array $items,
) {
}
}
use Elrise\Bundle\AppLayerBundle\Contract\CommandHandlerInterface;
use Elrise\Bundle\AppLayerBundle\Handler\DtoRequestHandler;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\JsonResponse;
final class CreateOrderHandler implements CommandHandlerInterface
{
public function __construct(private OrderRepository $orders) {}
public function handle(Request $request, object $command): mixed
{
\assert($command instanceof CreateOrderCommand);
return ['id' => $this->orders->create($command)->id()];
}
}
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);
}
}
Full contract set, all five usage steps, API Platform integration, error handling, and tests are below.
What it is and what it is not
The bundle does:
- claim the application slice in the HTTP → DTO → handler → domain flow, keeping the boundary explicit through
CommandHandlerInterfaceandQueryHandlerInterface; - denormalize payloads into immutable DTOs via
symfony/serializer(with support for both readonly constructor-promoted and property-only DTOs, nosetAccessible); - optionally sanitize the request for commands (queries skip sanitization by design — read-side input is never mutated);
- dispatch commands synchronously or through
symfony/messenger(MessengerQueueDispatcherwires in automatically when the package is installed;NullQueueDispatcheris the fallback); - ship a pluggable processor pipeline (
DataProcessorInterface) for DTO-less endpoints; - wrap every denormalization, locator, and handler-resolution failure in a single
RequestExceptionwith structured context.
The bundle does not:
- replace API Platform, Doctrine, or Messenger — those are transport, persistence, and async dispatch respectively;
- handle authentication, rate limits, OpenAPI descriptions, or content negotiation — that responsibility stays with the transport;
- provide a CQRS framework — only the interfaces and the pipeline; everything else stays on the consumer side;
- solve idempotency, transaction management, or domain events at the aggregate level — that is the domain layer's concern, one level below;
- dispatch queries through a queue — queries are read-side by design and have no async semantics.
Key features
- CQRS contracts: separate
CommandHandlerInterfaceandQueryHandlerInterface, registered through different tagged locators. - Immutable DTO denormalization via
symfony/serializer— readonly constructor-promoted DTOs and property-only DTOs are both supported (withoutsetAccessible, which is relevant for PHP 8.5+). - Optional request sanitization for commands. Query operations skip sanitization by design — read-side input is never mutated.
- Synchronous and asynchronous command handling through
symfony/messenger.MessengerQueueDispatcherwires in automatically when the package is installed; otherwiseNullQueueDispatcheris used. - Extensible processor pipeline (
DataProcessorInterface) for DTO-less endpoints (lookup tables, projections, computed views). - Structured error handling via
RequestException— a single exception type with diagnostic context that wraps every denormalization, locator, and handler-resolution failure.
Pipeline 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— taggedapp_layer.command_handler. Mutates state and returns a command result (id, presenter, view DTO).QueryHandlerInterface— taggedapp_layer.query_handler. Returns read-side data without side effects.DataProcessorInterface— taggedapp_layer.data_processor. Used for endpoints without a DTO.DtoDeserializerInterface— abstraction over the underlying denormalizer. Default:SymfonyDtoDeserializer.RequestToDtoConverterInterface— extracts the payload from a SymfonyRequestand turns it into a DTO.RequestSanitizerInterface— optional pre-DTO cleanup for commands.
Components
DtoRequestHandler— orchestrates the pipeline above. Two entry points:dispatchCommand()anddispatchQuery().DefaultRequestToDtoConverter— JSON body or query/form merger, then DTO denormalization.SymfonyDtoDeserializer— routes DTOs with constructors toObjectNormalizer; handles property-only DTOs via direct reflection (nosetAccessible).DataProcessor— tagged locator for processor-only endpoints.
Dispatchers
DtoQueueDispatcherInterface— the abstract dispatch boundary.MessengerQueueDispatcher— implemented whensymfony/messengeris installed.NullQueueDispatcher— fallback when no transport is installed.
Architectural decisions
The bundle captures a small set of decisions instead of becoming a full CQRS framework:
- Commands and queries have separate interfaces and tagged locators. The write-side/read-side distinction remains part of the contract rather than a code-review convention.
- The Application Layer accepts transport input and passes a shaped use-case into the domain. Aggregates, repositories, transactions, and domain events remain the consumer application's responsibility.
- Synchronous invocation is the default. Queuing is an explicit choice for a command and does not change the handler's primary contract.
- Input and handler-resolution failures are normalised to
RequestExceptionat the request boundary. Use-case and infrastructure failures are not hidden behind that type. - DTO-less endpoints have a separate
DataProcessor, but it does not blur the command/query contract.
The architectural motivation and the API Platform boundary are covered in the note CQRS Application Layer on top of API Platform. This page documents the implementation: API, wiring, code, and checks.
Installation and requirements
composer require elriseio/application-layer-bundle
Requirements
- PHP 8.3 or higher with the
ctype,curl, andjsonextensions enabled (all three are bundled by default in standard PHP distributions; they are listed incomposer.jsonrequire for runtime-declaration clarity). - Symfony 7.2 or higher.
Register the bundle:
// config/bundles.php
return [
// ...
Elrise\Bundle\AppLayerBundle\AppLayerBundle::class => ['all' => true],
];
Usage
Usage comes down to five steps: define an immutable DTO, implement a command/query handler, call the dispatcher from a controller. For DTO-less endpoints, wire the processor pipeline instead.
DTO (immutable input)
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,
) {
}
}
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()];
}
}
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);
}
}
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.
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 integration
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 note 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
Get hands-on
The snippets above show the bundle API, but the architecture is easier to see in a running application. In dummy-market-agent, the Application Layer, CQRS, API Platform, and DBAL-based persistence are assembled in one reference Symfony service.
A demo and an example of bundle implementation or usage can be viewed in elriseio/demo_application_layer — the application is available in RU and EN.
Development
For contributors. This section is not relevant for consumers of the bundle — it documents the local workflow for those who commit to the repo.
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).
composer install does not auto-install the hook on purpose: CI must not be polluted by git config calls, and the operator may prefer their own tooling (Lefthook, Husky) over the bundled bash hook.
Project status
Active, in development (1.x-dev, last commit 2026-07-19). The bundle is the application layer used by production Symfony projects that pair with API Platform and Messenger; new contracts (additional sanitizers, alternative queue dispatchers) are added as the use-cases demand them.
- Version:
1.x-dev(seecomposer.json::extra.branch-alias). - PHP: 8.3+ (with
ext-ctype,ext-curl,ext-json). - Symfony: 7.2+.
- Runtime dependencies:
symfony/serializer,symfony/validator,symfony/property-access,symfony/http-kernel, and others (full list incomposer.json). - Optional:
symfony/messengerfor async command dispatch. - Tests:
composer test— PHPUnit coverage for the orchestrator, denormalizer, locator, and queue dispatcher. - Lint:
composer cs:check(php-cs-fixer,friendsofphp/php-cs-fixer ^3.74). - CHANGELOG: see
CHANGELOG.mdin the repository.
Reference
- github.com/elriseio/application-layer-bundle — repository, MIT.
- packagist.org/packages/elriseio/application-layer-bundle — Packagist.
- CQRS Application Layer on top of API Platform — note that walks through the architectural motivation and the API Platform glue.