From symfony-skills
Guides writing hand-rolled REST controllers in Symfony (without API Platform) with routing attributes, HTTP status codes, request payload mapping, serialization groups, pagination, and versioning.
How this skill is triggered — by the user, by Claude, or both
Slash command
/symfony-skills:rest-controller-conventionsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
- One `final` class per resource, `#[Route]` prefix on the class.
final class per resource, #[Route] prefix on the class.layered-architecture).#[MapRequestPayload] and query params with #[MapQueryString] into DTOs.JsonResponse via $this->json(...) with explicit status codes.#[Route('/api/orders')]
final class OrderController extends AbstractController
{
public function __construct(private readonly OrderService $orders) {}
#[Route('', methods: ['GET'])]
public function list(#[MapQueryString] OrderListQuery $query): JsonResponse
{
$page = $this->orders->list($query);
return $this->json($page, context: ['groups' => ['order:read']]);
}
#[Route('/{id}', methods: ['GET'])]
public function show(Uuid $id): JsonResponse
{
return $this->json(
$this->orders->get($id),
context: ['groups' => ['order:read', 'order:detail']],
);
}
#[Route('', methods: ['POST'])]
public function create(#[MapRequestPayload] CreateOrderRequest $request): JsonResponse
{
$order = $this->orders->createOrder($request);
return $this->json(
OrderResponse::fromEntity($order),
Response::HTTP_CREATED,
['Location' => $this->generateUrl('order_show', ['id' => $order->getId()])],
['groups' => ['order:read']],
);
}
#[Route('/{id}', methods: ['DELETE'])]
public function delete(Uuid $id): JsonResponse
{
$this->orders->cancel($id);
return new JsonResponse(null, Response::HTTP_NO_CONTENT);
}
}
| Situation | Status |
|---|---|
| Successful GET | 200 OK |
| Resource created | 201 Created + Location header |
| Accepted, processed async | 202 Accepted |
| Successful DELETE / no body | 204 No Content |
| Validation failed | 422 Unprocessable Entity |
| Malformed JSON | 400 Bad Request |
| Unauthenticated | 401 Unauthorized |
| Authenticated but forbidden | 403 Forbidden |
| Not found | 404 Not Found |
| Conflict (duplicate, version) | 409 Conflict |
Don't return 200 for everything. Map domain exceptions to status codes in an exception listener (see problem-details-rfc9457).
Never serialize a Doctrine entity straight to the client without scoping. Either map to a response DTO or apply #[Groups] and pass the group in the serialization context.
// On the entity / DTO
#[Groups(['order:read'])]
private string $customerEmail;
#[Groups(['order:detail'])] // only on the detail endpoint
private array $internalNotes;
#[MapRequestPayload] deserializes and validates the DTO (via the Validator). On constraint failure it throws automatically → your exception listener turns it into a 422.
public function create(#[MapRequestPayload] CreateOrderRequest $request): JsonResponse
Validate query params the same way with #[MapQueryString]. See dto-and-validation.
Return a stable envelope, never a bare array, so you can add metadata without a breaking change:
{
"data": [ ... ],
"page": 1,
"perPage": 20,
"total": 137
}
return $this->json(new PaginatedResponse($items, $page, $perPage, $total), context: ['groups' => ['order:read']]);
Cap perPage server-side. For deep pages, prefer keyset pagination (see doctrine-query-optimization).
/orders, /orders/{id}, /orders/{id}/lines./orders POST, not /createOrder).#[Route('/{id}', requirements: ['id' => Requirement::UUID])].$this->json($entity) without groups — scope with #[Groups] or map to a DTO; otherwise lazy associations serialize and leak fields.200 for created/deleted resources — use 201 (+ Location) and 204.json_decode($request->getContent())) — use #[MapRequestPayload] + DTO./getOrders, /createOrder) — use nouns + HTTP methods.NotFoundException in the controller — let the exception listener map it to 404.{id} to UUID to avoid route collisions.npx claudepluginhub fatonh/symfony-skills --plugin symfony-skillsGuides creation of request/response DTOs, applies Symfony Validator constraints, and uses #[MapRequestPayload]/#[MapQueryString] for automatic deserialization and validation.
Designs and evolves API Platform serialization with groups, #[Context], IRI links, and custom context builders. Useful when aligning serialization, validation, and security behavior.
Creates or modifies API Platform resources with DTOs and Object Mapper. Use when adding endpoints, exposing entities over HTTP, defining input/output DTOs, or configuring nested sub-resources.