user@elrise.io:~/application-layer-bundle
· [active]

application-layer-bundle — CQRS-shaped Application Layer for Symfony

→ репозиторий

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:

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:

The bundle does not:

Key features

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

Components

Dispatchers

Architectural decisions

The bundle captures a small set of decisions instead of becoming a full CQRS framework:

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

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.

Reference