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.
Consolidated Symfony conventions (Symfony 6.4 / 7.x): attribute routing, controllers as services, autowiring/DI, Form types, Validation constraints, Voters for authorization, the Serializer/DTO contract, and Messenger. Apply when writing or reviewing Symfony backend code. Activated automatically by symfony-plugin/stack.md as a convention skill for the development phase. Apply when: writing or reviewing Symfony controllers, services, forms, validators, voters, and serialization. Do NOT use this skill for: - PHP language idioms (strict_types, enums, readonly) — see php-foundation:php-conventions. - Doctrine queries / entities — see symfony-plugin:doctrine-patterns. - Testing — injected by the QA phase (WebTestCase/KernelTestCase) + php-foundation:php-testing.
Designs and evolves API Platform serialization with groups, #[Context], IRI links, and custom context builders. Useful when aligning serialization, validation, and security behavior.