From symfony-skills
Guides creation of request/response DTOs, applies Symfony Validator constraints, and uses #[MapRequestPayload]/#[MapQueryString] for automatic deserialization and validation.
How this skill is triggered — by the user, by Claude, or both
Slash command
/symfony-skills:dto-and-validationThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
- **Request DTO** — carries input, holds validation constraints, never touches the DB.
fromEntity() factory, no constraints.readonly promoted properties; default empty collections to [], never null.// ✅ Request DTO — constraints live here
final class CreateOrderRequest
{
public function __construct(
#[Assert\NotBlank]
#[Assert\Email]
public readonly string $customerEmail = '',
/** @var CreateLineRequest[] */
#[Assert\Valid] // cascade into nested DTOs
#[Assert\Count(min: 1, minMessage: 'An order needs at least one line.')]
public readonly array $items = [],
) {}
}
final class CreateLineRequest
{
public function __construct(
#[Assert\NotBlank]
#[Assert\Uuid]
public readonly string $productId = '',
#[Assert\Positive]
public readonly int $quantity = 0,
) {}
}
// ✅ Response DTO — no constraints, has a factory
final readonly class OrderResponse
{
/** @param LineItemResponse[] $items */
public function __construct(
public string $id,
public string $status,
public array $items,
) {}
public static function fromEntity(Order $order): self
{
return new self(
$order->getId()->toRfc4122(),
$order->getStatus()->value,
array_map(LineItemResponse::fromEntity(...), $order->getItems()->toArray()),
);
}
}
#[MapRequestPayload] does deserialization + validationDon't decode JSON manually and don't call the validator yourself in the controller. The attribute handles both; on failure it throws ValidationFailedException → your exception listener returns 422 (see problem-details-rfc9457).
#[Route('/api/orders', methods: ['POST'])]
public function create(#[MapRequestPayload] CreateOrderRequest $request): JsonResponse
{
// $request is already deserialized AND validated here.
$order = $this->orderService->createOrder($request);
return $this->json(OrderResponse::fromEntity($order), Response::HTTP_CREATED);
}
// Query strings -> DTO
public function list(#[MapQueryString] OrderListQuery $query): JsonResponse { /* ... */ }
// ❌ BAD — manual decode + manual validate + manual error response in the controller
public function create(Request $request, ValidatorInterface $validator): JsonResponse
{
$dto = $this->serializer->deserialize($request->getContent(), CreateOrderRequest::class, 'json');
$errors = $validator->validate($dto);
if (count($errors) > 0) {
return $this->json(['errors' => (string) $errors], 400); // ad-hoc, wrong status
}
// ...
}
Reach for the standard constraints before writing logic:
NotBlank, NotNull, Length, Range, Positive, PositiveOrZero, Email, Uuid, Choice, Count, Valid (cascade), Type, GreaterThan, Regex, When (conditional), Unique.
#[Assert\Choice(callback: [OrderStatus::class, 'values'])]
public readonly string $status;
#[Assert\When(
expression: 'this.shippingRequired === true',
constraints: [new Assert\NotBlank()],
)]
public readonly ?string $shippingAddress = null;
For domain rules used in more than one place, write a constraint + validator rather than inlining checks.
#[\Attribute(\Attribute::TARGET_PROPERTY)]
final class UniqueCustomerEmail extends Constraint
{
public string $message = 'An account with email "{{ value }}" already exists.';
}
final class UniqueCustomerEmailValidator extends ConstraintValidator
{
public function __construct(private readonly CustomerRepository $customers) {}
public function validate(mixed $value, Constraint $constraint): void
{
if ($value === null || $value === '') {
return; // let NotBlank handle emptiness
}
if ($this->customers->existsByEmail($value)) {
$this->context->buildViolation($constraint->message)
->setParameter('{{ value }}', (string) $value)
->addViolation();
}
}
}
Real APIs rarely take a flat object. The rules below keep deep/nested JSON validating correctly.
#[Assert\Valid] must repeat at every levelValidation does not recurse automatically. Each property that holds a nested DTO (object or array of objects) needs its own #[Assert\Valid], all the way down — miss one and the constraints inside that branch silently don't run.
final class CreateOrderRequest
{
public function __construct(
#[Assert\NotBlank, Assert\Email]
public readonly string $customerEmail = '',
#[Assert\Valid] // cascade into the address object
public readonly ?AddressRequest $shippingAddress = null,
/** @var OrderLineRequest[] */
#[Assert\Valid] // cascade into EACH array element
#[Assert\Count(min: 1)]
public readonly array $items = [],
) {}
}
final class OrderLineRequest
{
public function __construct(
#[Assert\Uuid]
public readonly string $productId = '',
#[Assert\Positive]
public readonly int $quantity = 0,
/** @var DiscountRequest[] */
#[Assert\Valid] // still required two levels deep
public readonly array $discounts = [],
) {}
}
The Serializer hydrates nested DTOs from the property type hint — no manual wiring. Type the property as the DTO (or DtoType[] via the @var PHPDoc for arrays) and #[MapRequestPayload] builds the whole graph.
// JSON: { "customerEmail": "...", "shippingAddress": { "city": "..." }, "items": [ { ... } ] }
public function create(#[MapRequestPayload] CreateOrderRequest $request): JsonResponse
// $request->shippingAddress is an AddressRequest; $request->items is OrderLineRequest[]
For arrays of objects you must give the @var ItemType[] PHPDoc — the Serializer needs it to know what to hydrate each element into; without it you get an array of stdClass/arrays and the nested constraints never fire.
When a field can be one of several shapes ({"type": "card", ...} vs {"type": "paypal", ...}), use a #[DiscriminatorMap] on an abstract base so the Serializer picks the right concrete DTO.
#[DiscriminatorMap(typeProperty: 'type', mapping: [
'card' => CardPaymentRequest::class,
'paypal' => PaypalPaymentRequest::class,
])]
abstract class PaymentRequest {}
final class CardPaymentRequest extends PaymentRequest
{
public function __construct(
#[Assert\CardScheme(Assert\CardScheme::VISA)]
public readonly string $pan = '',
) {}
}
#[MapRequestPayload] isn't enough → a custom ValueResolver#[MapRequestPayload] deserializes one DTO from the body. When you need to build a DTO from several request parts (body + route params + headers), apply non-trivial pre-processing, or support a payload shape the Serializer can't express, write a ValueResolverInterface instead of decoding in the controller.
final class CreateOrderRequestResolver implements ValueResolverInterface
{
public function __construct(
private readonly SerializerInterface $serializer,
private readonly ValidatorInterface $validator,
) {}
/** @return iterable<CreateOrderRequest> */
public function resolve(Request $request, ArgumentMetadata $argument): iterable
{
if ($argument->getType() !== CreateOrderRequest::class) {
return [];
}
$dto = $this->serializer->deserialize($request->getContent(), CreateOrderRequest::class, 'json');
// Enrich from other request parts the body doesn't carry.
$dto = $dto->withTenantId($request->attributes->get('tenantId'));
$violations = $this->validator->validate($dto);
if (\count($violations) > 0) {
throw new ValidationFailedException($dto, $violations); // -> 422 via the exception listener
}
yield $dto;
}
}
// Controller stays thin — the resolver does the work, just like #[MapRequestPayload].
public function create(CreateOrderRequest $request): JsonResponse { /* ... */ }
Throw ValidationFailedException (not an ad-hoc response) so your problem-details listener still produces consistent 422 output. Reserve custom resolvers for cases the attribute genuinely can't handle — don't reimplement #[MapRequestPayload].
Deeply nested JSON is a denial-of-service vector. Cap depth at the edge (json_decode($json, depth: 32) semantics) or via the Serializer's json_decode_options/max_depth context, and keep payloads shallow by design.
OpenAPI / contract-first: if you generate or hand-maintain an OpenAPI schema, keep the DTO and the schema in lockstep so the documented contract matches what's actually validated. With API Platform the schema is generated from the resource/DTO automatically — see the
api-platform-resourcesskill.
Keep mapping out of controllers. Construct the entity through its factory/behavior methods (see domain-driven-design), passing scalar values from the DTO. Don't blindly hydrate an entity from request data — that's how mass-assignment bugs happen.
// In the service
$order = Order::create($request->customerEmail);
foreach ($request->items as $line) {
$order->addItem(Uuid::fromString($line->productId), $line->quantity);
}
$validator->validate() by hand in the controller — use #[MapRequestPayload].400 for validation errors with ad-hoc JSON — let the exception listener produce 422 problem+json.#[Assert\Valid] on nested DTO arrays — nested constraints won't run without it.#[Assert\Valid] at the top level but not on deeper nested DTOs — cascade doesn't recurse; repeat it at every level.@var ItemType[] PHPDoc on an array property — the Serializer hydrates stdClass/arrays and nested validation never fires.if ($data['type'] === 'card') branching for polymorphic payloads — use #[DiscriminatorMap] and concrete DTO subclasses.ValueResolverInterface instead.ValidationFailedException so the problem-details listener produces a consistent 422.if (empty(...)) checks in the service for things constraints already cover.readonly with sensible defaults.npx claudepluginhub fatonh/symfony-skills --plugin symfony-skillsGenerates immutable DTO classes for PHP 8.4 apps, including request/response objects for API boundaries, layer-specific data transfer, serialization methods, and unit tests.
Designs Value Objects for domain concepts and DTOs for data transfer with readonly immutability and validation in Symfony projects.
Defines DTO classes with class-validator decorators for validating NestJS request payloads, including nested objects, partial updates, and Swagger integration.