- CQRS application layer for API Platform β the architectural rationale behind this bundle: separating the transport, application, and domain layers in a CQRS-shaped Symfony system and mapping the boundary onto API Platform state providers and processors.
- Application Layer Bundle project page β overview, supported features, installation notes, and release notes.
- demo_application_layer β a runnable reference implementation of the bundle, showing how DTOs, command/query handlers, controllers, and the API Platform state provider/processor wiring fit together in a real Symfony application.
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 β optional queue
dispatch, with first-class interfaces for CommandHandlerInterface and
QueryHandlerInterface.
The bundle 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, with
symfony/messenger as an optional integration for async commands.
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.
- CQRS-shaped contracts: distinct
CommandHandlerInterfaceandQueryHandlerInterface, registered through separate tagged locators. - Immutable DTO denormalization through
symfony/serializer, with built-in support for readonly constructor-promoted DTOs and property-only DTOs (nosetAccessiblesince 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. TheMessengerQueueDispatcheris wired automatically when the package is installed; otherwise aNullQueueDispatcheris used. - Pluggable processor pipeline (
DataProcessorInterface) for endpoints that don't carry a DTO (lookup tables, projections, etc.). - Structured error handling through
RequestException, which wraps every denormalization, locator, and handler-resolution failure with diagnostic context.
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)
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.
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.
DtoQueueDispatcherInterfaceβ the abstract dispatch boundary.MessengerQueueDispatcherβ implemented whensymfony/messengeris installed.NullQueueDispatcherβ fallback when no transport is installed.
composer require elriseio/application-layer-bundle- 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.jsonrequirefor runtime-declaration clarity). - Symfony 7.2 or higher
Register the 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,
) {
}
}
final readonly class ListOrdersQuery
{
public function __construct(
public string $customerId,
public int $limit = 20,
) {
}
}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()];
}
}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);
}
}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.
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 the API
Platform entity/state purely as a transport adapter and delegate to
the application layer for the actual use-case.
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;
}
}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.
The bundle ships with PHPUnit coverage for the pipeline. Run:
composer testSee CHANGELOG.md for release history.
The bundle ships with 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, so it only takes effect inside this checkout.
Install the hook once after cloning:
./scripts/install-hooks.shThis 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.
MIT. See LICENSE.