--- name: symfony:api-platform-dto-resources description: Create API Platform resources using pure DTOs without Doctrine entities, enabling clean API design decoupled from database schema --- # API Platform DTO Resources Use plain PHP classes (DTOs) as API resources instead of Doctrine entities. This approach provides complete separation between your API contract and database schema. ## Why DTO Resources? - **API-First Design** - Design your API independently from database - **No Doctrine Coupling** - Works with any data source (cache, external APIs, files) - **Clean Contracts** - Input and output shapes match API documentation exactly - **Versioning** - Easily maintain multiple API versions with different DTOs - **Security** - No accidental exposure of entity internals ## Basic DTO Resource ### Define the DTO ```php */ final class ProductResourceProvider implements ProviderInterface { public function __construct( private ProductRepository $repository, ) {} public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null { if ($operation instanceof CollectionOperationInterface) { return $this->provideCollection($context); } return $this->provideItem($uriVariables['id']); } private function provideCollection(array $context): array { $products = $this->repository->findAll(); return array_map( fn($product) => $this->toResource($product), $products ); } private function provideItem(int $id): ?ProductResource { $product = $this->repository->find($id); return $product ? $this->toResource($product) : null; } private function toResource(object $product): ProductResource { return new ProductResource( id: $product->getId(), name: $product->getName(), description: $product->getDescription(), priceInCents: $product->getPriceInCents(), stock: $product->getStock(), formattedPrice: sprintf('$%.2f', $product->getPriceInCents() / 100), inStock: $product->getStock() > 0, createdAt: $product->getCreatedAt(), ); } } ``` ### State Processor ```php */ final class ProductResourceProcessor implements ProcessorInterface { public function __construct( private EntityManagerInterface $em, private ProductRepository $repository, ) {} public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ?ProductResource { /** @var ProductResource $data */ if ($operation instanceof DeleteOperationInterface) { $product = $this->repository->find($uriVariables['id']); if ($product) { $this->em->remove($product); $this->em->flush(); } return null; } // Update existing or create new $product = isset($uriVariables['id']) ? $this->repository->find($uriVariables['id']) : new Product(); $product->setName($data->name); $product->setDescription($data->description); $product->setPriceInCents($data->priceInCents); $product->setStock($data->stock ?? 0); $this->em->persist($product); $this->em->flush(); return new ProductResource( id: $product->getId(), name: $product->getName(), description: $product->getDescription(), priceInCents: $product->getPriceInCents(), stock: $product->getStock(), formattedPrice: sprintf('$%.2f', $product->getPriceInCents() / 100), inStock: $product->getStock() > 0, createdAt: $product->getCreatedAt(), ); } } ``` ## Separate Input/Output DTOs For more control, use different DTOs for input and output: ### Output DTO ```php */ final class ProductProvider implements ProviderInterface { public function __construct( private ProductRepository $repository, private Pagination $pagination, ) {} public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|iterable|null { if (!$operation instanceof CollectionOperationInterface) { $product = $this->repository->find($uriVariables['id']); return $product ? $this->toOutput($product) : null; } // Get pagination parameters [$page, , $limit] = $this->pagination->getPagination($operation, $context); $offset = ($page - 1) * $limit; // Get paginated results $products = $this->repository->findBy([], ['createdAt' => 'DESC'], $limit, $offset); $total = $this->repository->count([]); // Transform to DTOs $items = array_map(fn($p) => $this->toOutput($p), $products); return new TraversablePaginator( new \ArrayIterator($items), $page, $limit, $total, ); } private function toOutput(object $product): ProductOutput { return new ProductOutput( id: $product->getId(), name: $product->getName(), description: $product->getDescription(), formattedPrice: sprintf('$%.2f', $product->getPriceInCents() / 100), inStock: $product->getStock() > 0, stockLevel: $product->getStock(), createdAt: $product->getCreatedAt()->format('c'), categories: array_map( fn($cat) => new CategoryOutput($cat->getId(), $cat->getName()), $product->getCategories()->toArray() ), ); } } ``` ## Nested Resources ### Nested DTO Structure ```php */ final class WeatherProvider implements ProviderInterface { public function __construct( private HttpClientInterface $httpClient, private CacheInterface $cache, private string $weatherApiKey, ) {} public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?WeatherResource { $city = $uriVariables['city']; return $this->cache->get("weather_{$city}", function (ItemInterface $item) use ($city) { $item->expiresAfter(300); // 5 minutes $response = $this->httpClient->request('GET', 'https://api.weather.example/current', [ 'query' => [ 'city' => $city, 'apikey' => $this->weatherApiKey, ], ]); $data = $response->toArray(); return new WeatherResource( city: $city, temperature: $data['temp'], condition: $data['condition'], humidity: $data['humidity'], windSpeed: $data['wind_speed'], lastUpdated: (new \DateTimeImmutable())->format('c'), ); }); } } ``` ## Read-Only Aggregated Resource DTO combining data from multiple entities: ```php request('GET', '/api/products'); $this->assertResponseIsSuccessful(); $this->assertJsonContains([ '@context' => '/api/contexts/Product', '@type' => 'hydra:Collection', ]); } public function testCreateProduct(): void { $response = static::createClient()->request('POST', '/api/products', [ 'json' => [ 'name' => 'New Product', 'description' => 'A test product', 'priceInCents' => 1999, 'stock' => 10, ], ]); $this->assertResponseStatusCodeSame(201); $this->assertJsonContains([ 'name' => 'New Product', 'formattedPrice' => '$19.99', 'inStock' => true, ]); } public function testValidationErrors(): void { $response = static::createClient()->request('POST', '/api/products', [ 'json' => [ 'name' => 'AB', // Too short 'priceInCents' => -100, // Negative ], ]); $this->assertResponseStatusCodeSame(422); } } ```