A runnable Symfony application that demonstrates the CQRS-shaped DDD application-layer pattern in the smallest possible code surface. The repository is the canonical "how to adopt" example: it shows the architectural shape, the boundaries between layers, the contract each layer implements, and the practical mechanics of adding a new use-case end-to-end.
demo_application_layer is a one-bounded-context Symfony 7.2 /
PHP 8.3 application that codifies a single architectural pattern:
the application layer is the seam between transport and domain.
HTTP controllers, console commands, and any future transport
(API Platform, RPC, Messenger) stay thin adapters; the use-case
lives in a CommandHandler or QueryHandler that takes an
immutable DTO and returns a view DTO. The pattern is supported by
the elriseio/application-layer-bundle package, which provides
the contracts, the tagged locators, the request-to-DTO pipeline,
and the optional queue dispatcher. This repository is the working
example: the rule, the code shape, and the boundary.
| Aspect | Value |
|---|---|
| Pattern | CQRS-shaped DDD with an explicit Application Layer |
| Demo goal | "How to adopt" — concrete code, not theory |
| Language / framework | PHP 8.3 / Symfony 7.2 |
| Bounded context | Tariff (one aggregate, six use-cases) |
| Persistence | In-memory repository (the pattern is persistence-agnostic) |
| Transports wired | HTTP (JSON) + Symfony Console, both converging on the same handler |
| Tooling | PHPUnit 11 (4 testsuites), PHPStan 6, php-cs-fixer (@Symfony + @PHP82Migration) |
| Maturity | Demo, drafted. Code lives in src/, tests not yet written |
make up # build & start php-fpm + nginx + postgres
make sh # open a shell in the PHP container
> php bin/console list tariff
# HTTP
curl -sS -X POST http://localhost:8080/api/tariffs \
-H 'Content-Type: application/json' \
-d '{"code":"BASIC","name":"Basic plan","pricePerUnitCents":990,"currency":"EUR"}'
curl -sS http://localhost:8080/api/tariffs
curl -sS http://localhost:8080/api/tariffs/BASIC
curl -sS -X PATCH http://localhost:8080/api/tariffs/1/price \
-H 'Content-Type: application/json' \
-d '{"newPricePerUnitCents":1290,"currency":"EUR"}'
curl -sS -X POST http://localhost:8080/api/tariffs/1/activate
curl -sS -X POST http://localhost:8080/api/tariffs/1/deactivate
# Console
> php bin/console tariff:create --code=BASIC --name="Basic plan" --price-per-unit-cents=990 --currency=EUR
> php bin/console tariff:list
make down # stop containers
make clean # stop + delete volumesThree commitments:
- Transport does not call the domain. Controllers and console commands translate payloads into immutable DTOs and forward them. They never touch a repository, an entity, or a service.
- The use-case is a handler. Every endpoint maps to one
handler that implements
CommandHandlerInterface(mutates state, returns a result) orQueryHandlerInterface(returns read-side data, no side effects). - The boundary is an immutable DTO.
final readonly classwith constructor-promoted properties. No setters, no mutable arrays.
- Use-cases are unit-testable in isolation — a handler is one dependency, dispatch a DTO, assert the result.
- Intent is explicit — "this endpoint mutates" vs "this endpoint reads" is visible at the handler's contract.
- Transport is replaceable — moving an endpoint from HTTP to Messenger is a new adapter that calls the same handler with the same DTO.
- The bundle is replaceable — the contracts are public, the pipeline is small; the bundle provides the locators, conversion, and the optional queue dispatcher.
Interface ──► Application ──► Domain ◄── Infrastructure
(HTTP, CLI) (DTO + handlers) (model + VO) (repo impl)
- Domain (
src/Domain/).Tariffaggregate,Money/Currencyvalue objects,TariffRepositoryInterfacecontract. Zero framework dependencies. - Application (
src/Application/). Immutable DTOs (Command / Query), handlers,TariffViewprojection. No state. - Infrastructure (
src/Infrastructure/).InMemoryTariffRepository. The only stateful component, behind the repository interface. - Interface (
src/Interface/).TariffControllerfor HTTP,CreateTariffConsoleCommand/ListTariffsConsoleCommandfor console. Both routes converge on the same handler.
HTTP request or console input
│
▼
Controller / ConsoleCommand ← builds the Request, declares the DTO + handler
│
▼
DtoRequestHandler ← the seam (provided by the bundle)
│
├── sanitize (commands only; optional)
├── convert (Request → immutable DTO via Symfony Serializer)
├── resolve (tagged locator: app_layer.command_handler | app_layer.query_handler)
├── invoke (handler->handle(Request, DTO))
└── dispatch (optional queue dispatch, commands only)
| Use-case | Type | HTTP | Console |
|---|---|---|---|
| Create a tariff | Command | POST /api/tariffs |
tariff:create |
| Update price | Command | PATCH /api/tariffs/{id}/price |
— |
| Activate | Command | POST /api/tariffs/{id}/activate |
— |
| Deactivate | Command | POST /api/tariffs/{id}/deactivate |
— |
| Find by code | Query | GET /api/tariffs/{code} |
— |
| List active | Query | GET /api/tariffs |
tariff:list |
Touching one file per layer. Order matters.
1. DTO (src/Application/Command/):
final readonly class RenameTariffCommand
{
public function __construct(
public int $tariffId,
public string $newName,
) {
}
}2. Handler (src/Application/Handler/Command/):
final readonly class RenameTariffHandler implements CommandHandlerInterface
{
public function __construct(
private TariffRepositoryInterface $repository,
) {
}
public function handle(Request $request, object $command): TariffView
{
\assert($command instanceof RenameTariffCommand);
$tariff = $this->repository->findById($command->tariffId)
?? throw new \DomainException(sprintf('Tariff with ID "%d" was not found.', $command->tariffId));
if ($command->newName === '') {
throw new \InvalidArgumentException('Tariff name cannot be empty.');
}
$this->repository->save($tariff);
return TariffView::fromEntity($tariff);
}
}3. Tag (config/services.yaml):
App\Application\Handler\Command\RenameTariffHandler:
tags: ['app_layer.command_handler']4. HTTP route (src/Interface/Http/TariffController.php):
#[Route('/{id}/rename', methods: ['POST'], requirements: ['id' => '\d+'])]
public function rename(int $id, Request $request): JsonResponse
{
$request = $this->withTariffId($request, $id);
try {
$view = $this->dtoRequestHandler->dispatchCommand(
request: $request,
commandFqcn: RenameTariffCommand::class,
handlerFqcn: RenameTariffHandler::class,
);
} catch (RequestException $e) {
return new JsonResponse(['error' => $e->getMessage(), 'details' => $e->getDetails()], 400);
} catch (\DomainException $e) {
return new JsonResponse(['error' => $e->getMessage()], 404);
}
\assert($view instanceof TariffView);
return new JsonResponse($view, 200);
}5. (Optional) Console command in src/Interface/Console/ — same
shape as CreateTariffConsoleCommand.
6. Run the gate:
make checkNo new dependencies, no new abstractions, no new files beyond the layer that owns the change.
| Command | What it does |
|---|---|
make up / make down / make clean |
Stack lifecycle |
make sh / make logs / make restart |
Container access |
make test |
4-testsuite PHPUnit run |
make stan |
PHPStan |
make cs / make cs-fix |
php-cs-fixer (dry run / apply) |
make check |
cs:check + stan + test (full gate) |
composer install
APP_ENV=dev APP_SECRET=local php -S 127.0.0.1:8000 -t public- Domain getters are noun-style (
code(),pricePerUnit(),isActive()); mutators are verbs (changePrice(),activate(),deactivate()). - Handlers are
final readonly classinsrc/Application/Handler/{Command,Query}/. - DTOs are
final readonly classwith constructor-promoted properties and zero behavior. - Controllers are thin. Build the
Request, delegate toDtoRequestHandler, translate the result. Domain exceptions map to404,RequestExceptionto400. - Identity is repository-only.
Tariff::assignId()is not part of the public domain API.
.php-cs-fixer.phpruns@Symfony,@Symfony:risky,@PHP82Migration.phpstan.neonis level 6 acrosssrc/andtests/.composer checkruns the canonical local gate.
| Path | Contents |
|---|---|
src/Domain/ |
Tariff aggregate, Money / Currency value objects, TariffRepositoryInterface |
src/Application/Command/ |
4 commands |
src/Application/Query/ |
2 queries |
src/Application/View/ |
TariffView projection |
src/Application/Handler/Command/ |
4 command handlers |
src/Application/Handler/Query/ |
2 query handlers |
src/Infrastructure/Repository/ |
InMemoryTariffRepository |
src/Interface/Http/ |
TariffController |
src/Interface/Console/ |
CreateTariffConsoleCommand, ListTariffsConsoleCommand |
src/Kernel.php |
Symfony kernel; registers AppLayerProcessorExtension |
config/bundles.php |
AppLayerBundle + FrameworkBundle + dev tooling |
config/services.yaml |
Autowire + handler tags + repo alias |
config/packages/framework.yaml |
Framework configuration |
config/routes/attributes.yaml |
Attribute-based route loader |
config/packages.test/ |
Test-only doctrine + messenger |
config/packages.unused/ |
Disabled Symfony bundles (kept for sibling demos; do not enable without a reason) |
docker/php/ |
PHP 8.3 FPM Dockerfile + php.ini + www.conf |
docker/nginx/ |
nginx vhost |
compose.yaml |
php + nginx + postgres stack |
bin/console / public/index.php |
Console / HTTP entry points |
phpunit.xml.dist |
4 testsuites (Unit, Functional, Integration, StaticAnalysis) |
phpstan.neon |
PHPStan level 6 |
.php-cs-fixer.php |
@Symfony + @Symfony:risky + @PHP82Migration |
src/Domain/Model/Tariff.php— aggregate root (private constructor, explicit mutations, ID assigned by repository only).src/Application/Handler/Command/CreateTariffHandler.php— canonical handler shape.src/Interface/Http/TariffController.php— thin-controller principle.vendor/elriseio/application-layer-bundle/src/Handler/DtoRequestHandler.php— the seam.
phpunit.xml.dist declares four testsuites; tests/ currently
holds only bootstrap.php. Tests are not yet written.
| Testsuite | Intended scope |
|---|---|
Unit |
Pure domain tests (Tariff invariants, Money arithmetic) |
Functional |
Single-handler tests in isolation against the in-memory repo |
Integration |
Boot the kernel, hit HTTP / console, assert end-to-end payloads |
StaticAnalysis |
PHPUnit-level assertions over the codebase |
make test # vendor/bin/phpunit
make stan # PHPStan
make check # cs:check + stan + testTests run under APP_ENV=test (framework.test: true,
DATABASE_URL=sqlite:///:memory:). beStrictAboutOutputDuringTests,
failOnRisky, failOnWarning are all on.
This is a demo. Out of scope:
- No tests yet. Adding a
Unittest forTariff::createand aFunctionaltest forCreateTariffHandlerwould be the cheapest first step. - No DBAL persistence.
InMemoryTariffRepositoryis the only concrete repo; state is lost on every container restart. - No API Platform state provider / processor pattern. That has
its own demo; the integration is a
Provider/Processorthat delegates to the same handler. - No PATCH / Placeholder sentinel pattern. The PATCH endpoint here uses a flat DTO with explicit fields.
- No queue dispatch. The bundle's
dispatchToQueue: truepath is not exercised; the optional Messenger integration is wired but unused. - No CSRF / session / security. The HTTP API is open.
MIT. The bundle it depends on (elriseio/application-layer-bundle)
is also MIT, by AlexK <alexk@elrise.io>.
Contributions follow the standard Symfony flow: open a feature
branch, run make check locally, submit a PR. New use-cases follow
the established template (DTO + handler + optional tariff: console
command + HTTP route). New handlers MUST be tagged with the matching
app_layer.* locator tag in config/services.yaml. Do not introduce
a new transport without first reading the boundary rules in section D.