| name | ddd-endpoint-specialist |
| description | Create REST API controllers, DTOs, route attributes, error handling, and OpenAPI documentation in the mgamadeus/ddd framework. Use when creating or modifying API endpoints, request/response DTOs, or controller logic. |
| metadata | {"author":"mgamadeus","version":"1.0.0","framework":"mgamadeus/ddd"} |
DDD Endpoint Specialist
Controllers, DTOs, routing, error handling, and OpenAPI documentation within the DDD Core framework (mgamadeus/ddd).
When to Use
- Creating or modifying REST API controllers
- Creating request/response DTOs
- Configuring route attributes and OpenAPI documentation
- Implementing CRUD endpoint patterns
- Understanding error handling and controller conventions
Namespace
Framework: DDD\Presentation\Base\Controller -- base controllers and DTOs.
Application: App\Presentation\Api\{Audience}\{Domain}\Controller -- audience-specific endpoints.
API Audiences
The framework provides the base layer. Consuming applications define audience-specific controllers:
| Audience | Path | Base Controller | Auth |
|---|
| Admin | /api/admin/* | AdminController | Full access (ROLE_ADMIN) |
| Client | /api/client/* | ClientController | JWT/Bearer |
| Public | /api/public/* | PublicController | None |
| Batch | /api/batch/* | HttpController (direct) | Varies |
Framework Directory Structure
src/Presentation/
+-- Base/
| +-- Controller/
| | +-- BaseController.php # Foundation
| | +-- HttpController.php # HTTP-aware (extend this)
| | +-- DocumentationController.php # OpenAPI docs
| +-- Dtos/
| | +-- RequestDto.php # Base request DTO
| | +-- RestResponseDto.php # Base REST response DTO
| | +-- HtmlResponseDto.php, RedirectResponseDto.php, ExcelResponseDto.php,
| | +-- PDFResponseDto.php, ZipResponseDto.php, FileResponseDto.php, ImageResponseDto.php
| +-- OpenApi/Attributes/ # Summary, Tag, Parameter, etc.
| +-- Router/Routes/ # Get, Post, Patch, Update, Delete, Route
Controller Template
<?php
declare(strict_types=1);
namespace App\Presentation\Api\{Audience}\{Domain}\Controller;
use App\Domain\{Domain}\Entities\{Resource}\{Resource};
use App\Domain\{Domain}\Entities\{Resource}\{ResourcePlural};
use App\Domain\{Domain}\Services\{ResourcePlural}Service;
use App\Presentation\Api\{Audience}\Base\{Audience}Controller;
use DDD\Presentation\Base\OpenApi\Attributes\{Summary, Tag};
use DDD\Presentation\Base\Router\Routes\{Delete, Get, Patch, Post, Route, Update};
#[Route('/path/to/resource')]
#[Tag(group: '{DomainName}', name: '{ResourceName}', description: '{ResourceName} operations')]
class {Resource}Controller extends {Audience}Controller
{
}
Route & OpenAPI Attributes
#[Route('/domain/resources')]
#[Tag(group: 'Common', name: 'Resources', description: 'Resource related Endpoints')]
#[Get('/list')]
#[Get('/{resourceId}')]
#[Post('/create')]
#[Patch('/{resourceId}')]
#[Update]
#[Delete('/{resourceId}')]
#[Summary('Resource Details')]
#[Get('/{accessCodeOrId}', requirements: ['accessCodeOrId' => '[^/]+'])]
#[LogRequest(logTemplate: LogRequest::LOG_TEMPLATE_LOG_ALL)]
use DDD\Symfony\EventListeners\RequestCacheSubscriber\RequestCache;
#[RequestCache(ttl: 300, considerCurrentAuthAccountForCacheKey: true)]
OpenAPI Documentation Attributes
Beyond #[Summary] and #[Tag], the framework provides:
use DDD\Presentation\Base\OpenApi\Attributes\{Info, SecurityScheme, Server, Ignore, Required, Enum};
#[Info(title: 'My API', version: '1.0')]
#[SecurityScheme(securityScheme: 'Bearer', type: 'http', scheme: 'bearer', bearerFormat: 'JWT')]
#[Server(url: 'https://api.example.com')]
#[Ignore]
#[Required]
#[Enum('ACTIVE', 'INACTIVE')]
CRUD Operations
Rules for all methods:
- Always set
$service->throwErrors = true; before any service call
- Use pass-by-reference (
&$requestDto) for DTOs with DtoQueryOptionsTrait
- Services are auto-injected via Symfony DI
- Every method MUST have complete PHPDoc:
@param, @return, and @throws
@throws propagate from service methods — if the service throws BadRequestException, the controller method must declare it too
- Standard
@throws for methods calling service find/update/delete: BadRequestException, InternalErrorException, InvalidArgumentException, ReflectionException, plus ORMException/OptimisticLockException for update, plus NonUniqueResultException for delete
- Controller class MUST have a class-level PHPDoc describing its purpose
Class-Level PHPDoc
#[Route('/domain/resources')]
#[Tag(group: 'Domain', name: 'Resources', description: 'Resource operations')]
#[LogRequest(logTemplate: LogRequest::LOG_TEMPLATE_LOG_400_500)]
class AdminResourcesController extends AdminController
List (GET /list)
#[Get('/list')]
#[Summary('Resources List')]
public function list(
ResourcesGetRequestDto &$requestDto,
ResourcesService $resourcesService
): ResourcesGetResponseDto {
ResourcePlural::getDefaultQueryOptions()->setQueryOptionsFromRequestDto($requestDto);
$resourcesService->throwErrors = true;
$responseDto = new ResourcesGetResponseDto();
$responseDto->resources = $resourcesService->findAll();
$responseDto->resources->expand();
return $responseDto;
}
Get (GET /{id})
#[Get('/{resourceId}')]
#[Summary('Resource Details')]
public function get(
ResourceGetRequestDto &$requestDto,
ResourcesService $resourcesService
): ResourceGetResponseDto {
$resourcesService->throwErrors = true;
Resource::getDefaultQueryOptions()->setQueryOptionsFromRequestDto($requestDto);
$resource = $resourcesService->find($requestDto->resourceId);
$resource->expand();
$responseDto = new ResourceGetResponseDto();
$responseDto->resource = $resource;
return $responseDto;
}
Create (POST /create)
#[Post('/create')]
#[Summary('Resource Creation')]
public function create(
ResourceCreateRequestDto &$requestDto,
ResourcesService $resourcesService
): ResourceGetResponseDto {
$resourcesService->throwErrors = true;
$responseDto = new ResourceGetResponseDto();
$responseDto->resource = $resourcesService->create($requestDto->resource);
return $responseDto;
}
Update (PUT -- upsert pattern)
#[Update]
#[Summary('Resource Update')]
public function update(
ResourceUpdateRequestDto $requestDto,
ResourcesService $resourcesService
): ResourceGetResponseDto {
$resourcesService->throwErrors = true;
$resourceSent = $requestDto->resource;
if (isset($resourceSent->id) && $resource = $resourcesService->find($resourceSent->id)) {
$resource->overwritePropertiesFromOtherObject($resourceSent);
} else {
$resource = $resourceSent;
}
$responseDto = new ResourceGetResponseDto();
$responseDto->resource = $resourcesService->update($resource);
return $responseDto;
}
Delete (DELETE /{id})
#[Delete('/{resourceId}')]
#[Summary('Resource Deletion')]
public function delete(
ResourceGetRequestDto $requestDto,
ResourcesService $resourcesService
): DeleteResponseDto {
$resourcesService->throwErrors = true;
$resource = $resourcesService->find($requestDto->resourceId);
$resource->delete();
return new DeleteResponseDto();
}
DTO Patterns
Path Parameter Request (with QueryOptions)
use DDD\Presentation\Base\QueryOptions\{DtoQueryOptions, DtoQueryOptionsTrait};
#[DtoQueryOptions(baseEntity: Resource::class)]
class ResourceGetRequestDto extends RequestDto
{
use DtoQueryOptionsTrait;
#[Parameter(in: Parameter::PATH, required: true)]
public int|string $resourceId;
}
Single-entity GET DTOs MUST include #[DtoQueryOptions] + DtoQueryOptionsTrait for $select/$expand support.
Use &$requestDto (pass-by-reference) in the controller signature.
The referenced entity class MUST have use QueryOptionsTrait; (see ddd-query-options-specialist).
QueryOptions Request (for list endpoints)
#[DtoQueryOptions(baseEntity: ResourcePlural::class)]
class ResourcesGetRequestDto extends RequestDto
{
use DtoQueryOptionsTrait;
}
Body Payload Request
class ResourceUpdateRequestDto extends RequestDto
{
#[Parameter(in: Parameter::BODY, required: true)]
public Resource $resource;
}
Response DTOs
CRITICAL: Response DTOs MUST extend RestResponseDto -- NEVER ResponseDto.
use DDD\Presentation\Base\Dtos\RestResponseDto;
use DDD\Presentation\Base\OpenApi\Attributes\Parameter;
class ResourceGetResponseDto extends RestResponseDto
{
#[Parameter(in: Parameter::RESPONSE, required: true)]
public Resource $resource;
}
class ResourcesGetResponseDto extends RestResponseDto
{
#[Parameter(in: Parameter::RESPONSE, required: true)]
public ResourcePlural $resources;
}
Rules:
- Entity properties are non-nullable and have no default value
- Always include
#[Parameter(in: Parameter::RESPONSE, required: true)]
- Always include PHPDoc
@var type annotation
- Typed arrays: use the BRACKET form
@var T[] (@var string[], @var Foo[]) — NEVER the generic @var array<T>. The autodocumenter reads an array's element type from the @var docblock; array<T> mis-generates the schema (items.type: array + the <T> leaks into the description) → a hard Gemini/Google-AI-Studio 400 INVALID_ARGUMENT on any MCP-exposed DTO. Maps (array<K,V>) aren't supported either — model them as a typed VO/Entity. See "How DTOs & Entities Become the API Schema" below for the full mechanism.
Specialized Response DTOs
Beyond RestResponseDto (JSON), the framework provides response types for different content:
| DTO Class | Content Type | Use Case |
|---|
RestResponseDto | application/json | Standard REST API responses |
ExcelResponseDto | .xlsx | Excel file downloads (ExcelResponseDto::fromExcelDocument($doc)) |
PDFResponseDto | application/pdf | PDF downloads (PDFResponseDto::fromPDFDocument($doc)) |
ImageResponseDto | image/jpeg (configurable) | Image serving with long-term cache |
ZipResponseDto | application/zip | ZIP archive downloads |
FileResponseDto | Any MIME type | Generic file downloads |
HtmlResponseDto | text/html | HTML page rendering |
RedirectResponseDto | 302 redirect | HTTP redirects |
public function export(ResourcesService $resourcesService): ExcelResponseDto
{
$resources = $resourcesService->findAll();
$excelDocument = $resourcesService->exportToExcel($resources);
return ExcelResponseDto::fromExcelDocument($excelDocument);
}
For file uploads in request DTOs, use FileSetsDtoTrait with #[Parameter(in: Parameter::FILES)].
How DTOs & Entities Become the API Schema (Autodocumentation)
You do not hand-write OpenAPI/JSON-Schema — it is generated by reflecting your classes. Document::buildDocumentation() walks every route; for each request/response body it builds a Schema; Schema::buildSchema() emits one SchemaProperty per public property; and SchemaProperty maps that property's PHP type + its @var docblock + its attributes to a schema fragment, recursing into nested classes via Components::addSchemaForClass() (→ a $ref). The MCP tool-schema documenter and TypeScript SDK emitters consume the same reflected properties + docblocks, so the rules below govern every generated surface, not just OpenAPI.
Entities, entity-DTOs and request/response DTOs are documented by the SAME code path — Schema::buildSchema() reflects whatever class it is handed (its own comment: "we document entities, entityDtos and request / response DTO classes in the same way"). So every rule here applies equally to a DTO property and to an entity property a response embeds. The entity-author view of the same rules is in ddd-entity-specialist → "Entity Properties Feed the API Schema".
Type → schema mapping (SchemaProperty)
| PHP property | Emitted schema |
|---|
int / string / bool / float | {type: integer/string/boolean/number} (+ default if the property has one) |
?T / T|null | the T schema + nullable: true |
T1|T2 (union) | oneOf: […] |
DateTime / Date | {type: string, format: date-time | date} (NEVER native \DateTime) |
a backed/pure enum | {type, enum: […]} + per-case descriptions |
| a nested Entity / VO / EntitySet class | {$ref: …} and that class is recursively documented |
array (a list) | {type: array, items: …} — the element type comes from the @var docblock (see below) |
mixed | no type (unconstrained) |
object | rejected (TypeDefinitionMissingOrWrong) — use a typed class or array |
| (no type declared) | rejected — every documented property MUST be typed |
⚠️ Array element typing — @var T[], NEVER @var array<T> (load-bearing)
A list property's PHP type is just array; it carries no element type. The documenter recovers it from the docblock via ReflectionArrayType::getArrayType() / ReflectionDocComment::getPropertyTypes(), whose type-token regex char-class ([0-9a-zA-Z|_{}\\[]]) contains no < or > — so it understands the bracket form T[] but NOT the generic form array<T> (which captures only array and leaks the <T> into the description):
public array $keywords;
public array $keywords;
Always use T[]. A list of objects → /** @var SomeClass[] */ → items: {$ref: …} (and SomeClass is documented). An unspecified array[] element degrades to a generic items: {} — avoid it. (When a string[] is even the right shape vs. a ValueObject/ObjectSet, see AGENTS.md "Arrays Are Not a Substitute for ValueObjects".)
Description & examples
The property description is the @var docblock text after the type token — so @var string[] Keyword names → description Keyword names; a malformed @var array<string> … leaks the unparsed <string> into the description. @example lines become OpenAPI examples.
Attribute effects on the documented property
| Attribute | Effect |
|---|
#[Required] / #[NotNull] / #[Parameter(required: true)] | adds the property to the schema's required list |
#[Choice(choices: […])] | enum: […] + an "Allowed Values" block from the class's constant descriptions |
#[Enum('A','B')] | restricts the value set in the spec |
#[Length(min, max)] | minLength / maxLength |
#[ClassName] | the objectType discriminator → single-value enum + required |
#[Ignore] | property omitted from the schema entirely |
#[Parameter(in: PATH/QUERY/BODY/RESPONSE/FILES)] | which surface the property documents; a request-DTO body schema skips PATH/QUERY props (RESPONSE documents all) |
#[OverwritePropertyName('x')] | renames the output key (see ddd-serializer-specialist) |
Generic boilerplate is emitted ONCE per surface, not per parameter
To keep agent/MCP tool schemas small, the endpoint-independent grammar is factored into one info.description block instead of repeated on every parameter:
- QueryOptions grammar (
filters/orderBy/expand/select syntax) → QueryOptionsSyntax::getSyntaxDocumentation() once; each param keeps only its endpoint-specific allowed-property list. See ddd-query-options-specialist.
- Shared request params (
outputFormat/noCache/skip/top/skiptoken) → mark with #[SharedRequestParameter]; the full doc is emitted once via SharedRequestParametersSyntax, the per-param description shrinks to a pointer.
- Narrow which QueryOptions families a DTO advertises with
#[DtoQueryOptions(expose: [DtoQueryOptions::FILTERS, …])] — documentation-only (properties stay hydrated/validated/writable). ONLY narrow a family the repo genuinely does not apply: realistic for Argus-backed reads, never a DB-backed read (Doctrine always applies select/expand/orderBy).
Schema generation ≠ runtime serialization
This is about the schema (the API contract). The response bytes are produced by a separate path — SerializerTrait (toObject/toJSON) with its own rules (hiding, TOON, aliases). Consequence: a property hidden via #[HideProperty] still appears in the schema unless it is also #[Ignore]-d (hidden from output, but documented). See ddd-serializer-specialist.
Error Handling
Exception Classes
use DDD\Infrastructure\Exceptions\BadRequestException;
use DDD\Infrastructure\Exceptions\UnauthorizedException;
use DDD\Infrastructure\Exceptions\ForbiddenException;
use DDD\Infrastructure\Exceptions\NotFoundException;
use DDD\Infrastructure\Exceptions\MethodNotAllowedException;
use DDD\Infrastructure\Exceptions\InternalErrorException;
throwErrors = true auto-throws NotFoundException from find(), BadRequestException for validation.
DTO validation is handled automatically by the framework before the controller method executes.
Advanced Patterns
Current User Context
#[Get('/me')]
#[Summary('Current Account Details')]
public function me(
MyAccountGetRequestDto $requestDto,
AccountsService $accountsService
): AccountGetResponseDto {
$accountsService->throwErrors = true;
$account = AuthService::instance()->getAccount();
Account::getDefaultQueryOptions()->setQueryOptionsFromRequestDto($requestDto);
$account->expand();
$responseDto = new AccountGetResponseDto();
$responseDto->account = $account;
return $responseDto;
}
Custom Action Endpoint
#[Get('/{resourceId}/recalculate')]
#[Summary('Resource Recalculate Metrics')]
public function recalculateMetrics(
ResourceGetRequestDto $requestDto,
ResourcesService $resourcesService
): ResourceMetricsResponseDto {
$resourcesService->throwErrors = true;
$resource = $resourcesService->find($requestDto->resourceId);
$metrics = $resource->recalculateMetrics();
$resource->update();
$responseDto = new ResourceMetricsResponseDto();
$responseDto->metrics = $metrics;
return $responseDto;
}
Nested Resource Endpoint
#[Get('/{resourceId}/subresources')]
#[Summary('Resource SubResources List')]
public function getSubResources(
ResourceGetRequestDto $resourceRequestDto,
SubResourcesGetRequestDto $subResourcesRequestDto,
ResourcesService $resourcesService,
SubResourcesService $subResourcesService
): SubResourcesGetResponseDto {
$resourcesService->throwErrors = true;
$subResourcesService->throwErrors = true;
$resource = $resourcesService->find($resourceRequestDto->resourceId);
SubResources::getDefaultQueryOptions()->setQueryOptionsFromRequestDto($subResourcesRequestDto);
$responseDto = new SubResourcesGetResponseDto();
$responseDto->subResources = $subResourcesService->findByResource($resource);
return $responseDto;
}
Controller Conventions
Admin Controllers -- No Role Checks Needed
Admin API routes are behind Symfony security with ROLE_ADMIN. Never check for admin roles inside admin controllers.
DDD Framework DTO Merging
The framework merges all DTOs from a single request. Multiple DTOs in one controller method are all populated from the same HTTP request.
public function reply(
TicketGetRequestDto $ticketDto, // Path param: ticketId
ReplyRequestDto $requestDto, // Body param: body
TicketsService $ticketsService,
): MessageGetResponseDto { }
Controllers Are Thin
Controllers should ONLY: load entities from path parameters, delegate to services, build response DTOs.
Controllers should NEVER: contain business logic, implement complex resolution, create domain entities.
Pass Objects Not IDs
$ticket = $ticketsService->find($requestDto->ticketId);
$message = $messagesService->sendReply($ticket, $requestDto->body);
$message = $messagesService->sendReply($requestDto->ticketId, $requestDto->body);
Critical Rules
- NEVER use
private -- always protected. The private keyword destroys extensibility. This is a DDD framework -- every class may be extended. No exceptions.
- NEVER extend
ResponseDto for REST responses -- always extend RestResponseDto
- ALWAYS
declare(strict_types=1) in every file
- ALWAYS set
$service->throwErrors = true; before service calls
Cross-Reference
- Filtering / sorting / pagination /
$select / $expand on request DTOs — see ddd-query-options-specialist.
- Response / DTO serialization (hiding fields, renaming output keys,
RestResponseDto → JSON) — see ddd-serializer-specialist.
- DTO ↔ entity (the entity and entity-set classes behind
#[DtoQueryOptions(baseEntity: ...)], expand(), overwritePropertiesFromOtherObject) — see ddd-entity-specialist.
- Endpoint authorization / audiences (read/update/delete rights applied to the entities a controller returns) — see
ddd-rights-specialist.
- Dispatching async work from a controller (queue a Messenger message instead of doing slow work in the request) — see
ddd-message-handler-specialist.
Checklist