From symfony-skills
Defines API Platform 4 resources using DTOs, state providers/processors, serialization groups, filters, and pagination. Prefer DTO resources over exposing Doctrine entities directly.
How this skill is triggered — by the user, by Claude, or both
Slash command
/symfony-skills:api-platform-resourcesThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Exposing a Doctrine entity directly as `#[ApiResource]` couples your public API to your schema and leaks persistence concerns. For anything beyond a trivial CRUD prototype, expose a **DTO resource** backed by a **state provider** (read) and **state processor** (write).
Exposing a Doctrine entity directly as #[ApiResource] couples your public API to your schema and leaks persistence concerns. For anything beyond a trivial CRUD prototype, expose a DTO resource backed by a state provider (read) and state processor (write).
// ✅ GOOD — a resource DTO, decoupled from the entity
#[ApiResource(
shortName: 'Order',
operations: [
new Get(provider: OrderItemProvider::class),
new GetCollection(provider: OrderCollectionProvider::class),
new Post(processor: CreateOrderProcessor::class, input: CreateOrderInput::class),
],
normalizationContext: ['groups' => ['order:read']],
)]
final class OrderResource
{
public function __construct(
#[ApiProperty(identifier: true)]
#[Groups(['order:read'])]
public string $id,
#[Groups(['order:read'])]
public string $status,
/** @var LineItemResource[] */
#[Groups(['order:read'])]
public array $items = [],
) {}
}
// ❌ BAD — Doctrine entity as the API resource: schema is now your public contract
#[ApiResource]
#[ORM\Entity]
class Order { /* every column auto-exposed, lazy associations serialized, N+1 */ }
If you do expose entities (small internal apps), always scope fields with serialization groups and disable the operations you don't want.
A processor receives the input DTO and performs the use case — usually by delegating to an application service / Messenger bus.
// ✅ GOOD
/** @implements ProcessorInterface<CreateOrderInput, OrderResource> */
final class CreateOrderProcessor implements ProcessorInterface
{
public function __construct(private readonly OrderService $orders) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): OrderResource
{
$order = $this->orders->createOrder($data); // $data is the validated CreateOrderInput
return OrderResource::fromEntity($order);
}
}
// ✅ GOOD — provider returns the read model, not the entity
/** @implements ProviderInterface<OrderResource> */
final class OrderItemProvider implements ProviderInterface
{
public function __construct(private readonly OrderRepository $orders) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?OrderResource
{
$order = $this->orders->find(Uuid::fromString($uriVariables['id']));
return $order ? OrderResource::fromEntity($order) : null;
}
}
Use the built-in Pagination in collection providers; don't return unbounded arrays.
Put Symfony Validator constraints on the input DTO. API Platform validates automatically before the processor runs and returns RFC 9457 problem+json on failure.
final class CreateOrderInput
{
#[Assert\NotBlank]
#[Assert\Email]
public string $customerEmail;
/** @var CreateLineInput[] */
#[Assert\Valid]
#[Assert\Count(min: 1)]
public array $items = [];
}
security:new Post(security: "is_granted('ROLE_USER')"),
new Delete(security: "is_granted('ORDER_DELETE', object)"), // voter on the object
#[ApiResource(
operations: [new GetCollection()],
paginationItemsPerPage: 20,
paginationMaximumItemsPerPage: 100,
)]
#[ApiFilter(SearchFilter::class, properties: ['status' => 'exact', 'customerEmail' => 'partial'])]
#[ApiFilter(OrderFilter::class, properties: ['createdAt' => 'DESC'])]
final class OrderResource { /* ... */ }
For large datasets, see doctrine-query-optimization — switch collection providers to keyset pagination.
order:read / order:write groups stable.problem-details-rfc9457). Throw domain exceptions mapped to status codes.#[ApiResource] on a Doctrine entity — prefer a DTO resource with provider/processor for non-trivial APIs.#[Groups] — every property gets exposed, including sensitive ones.findAll() from a collection provider — keep pagination on.@ApiResource) — use PHP 8 attributes and the v4 metadata classes (ApiPlatform\Metadata\*).npx claudepluginhub fatonh/symfony-skills --plugin symfony-skillsConfigure API Platform v4 resources with explicit operations, pagination, and typed OpenAPI for clean, versioned REST/GraphQL APIs.
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.
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.