Watch the Custom Resources screencast
The DTO pattern isolates your public API contract from your internal data model (Entities). This decoupling allows you to evolve your data structure without breaking the API and provides finer control over validation and serialization.
In API Platform, the general design considerations recommended pattern is
DTO as a Resource: the class marked with
#[ApiResource] is the DTO, effectively becoming the “contract” of your API.
This reference covers three implementation strategies:
This is a Symfony only feature in 4.2 and is not working properly without the
symfony/object-mapper:^7.4
You can map a DTO Resource directly to a Doctrine Entity using stateOptions. This automatically configures the built-in State Providers and Processors to fetch/persist data using the Entity and map it to your Resource (DTO) using the Symfony Object Mapper.
You must apply the #[Map] attribute to your DTO class. This signals API Platform to use
the Object Mapper for transforming data between the Entity and the DTO.
First, ensure your entity is a standard Doctrine entity.
// src/Entity/Book.php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity]
class Book
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
public private(set) int $id;
#[ORM\Column(length: 13)]
#[Assert\NotBlank, Assert\Isbn]
public string $isbn;
#[ORM\Column(length: 255)]
#[Assert\NotBlank, Assert\Length(max: 255)]
public string $title;
#[ORM\Column(length: 255)]
#[Assert\NotBlank, Assert\Length(max: 255)]
public string $description;
#[ORM\Column]
#[Assert\PositiveOrZero]
public int $price;
}The Resource DTO handles the public representation. We use #[Map] to handle differences between
the internal model (title) and the public API (name), as well as value transformations
(formatPrice).
// src/Api/Resource/Book.php
namespace App\Api\Resource;
use ApiPlatform\Doctrine\Orm\State\Options;
use ApiPlatform\Metadata\ApiResource;
use App\Entity\Book as BookEntity;
use Symfony\Component\ObjectMapper\Attribute\Map;
#[ApiResource(
shortName: 'Book',
// 1. Link this DTO to the Doctrine Entity
stateOptions: new Options(entityClass: BookEntity::class),
operations: [ /* ... defined in next sections ... */ ]
)]
#[Map(source: BookEntity::class)]
final class Book
{
public int $id;
// 2. Map the Entity 'title' property to the DTO 'name' property
#[Map(source: 'title')]
public string $name;
public string $description;
public string $isbn;
// 3. Use a custom static method to transform the price
#[Map(transform: [self::class, 'formatPrice'])]
public string $price;
public static function formatPrice(mixed $price, object $source): int|string
{
// Entity (int) -> DTO (string)
if ($source instanceof BookEntity) {
return number_format($price / 100, 2).'$';
}
// DTO (string) -> Entity (int)
if ($source instanceof self) {
return 100 * (int) str_replace('$', '', $price);
}
throw new \LogicException(\sprintf('Unexpected "%s" source.', $source::class));
}
}Automated mapping relies on two internal classes: ApiPlatform\State\Provider\ObjectMapperProvider
and ApiPlatform\State\Processor\ObjectMapperProcessor.
These classes act as decorators around the standard Provider/Processor chain. They are activated when:
stateOptions are configured with an entityClass (or documentClass for ODM).#[Map] attribute.Read (GET):
The ObjectMapperProvider delegates fetching the data to the underlying Doctrine provider (which
returns an Entity). It then uses $objectMapper->map($entity, $resourceClass) to transform the
Entity into your DTO Resource.
Write (POST/PUT/PATCH):
The ObjectMapperProcessor receives the deserialized Input DTO. It uses
$objectMapper->map($inputDto, $entityClass) to transform the input into an Entity instance. It
then delegates to the underlying Doctrine processor (to persist the Entity). Finally, it maps the
persisted Entity back to the Output DTO Resource.
Ideally, your read and write models should differ. You might want to expose less data in a collection view (Output DTO) or enforce strict validation during creation/updates (Input DTOs).
For POST and PATCH, we define specific DTOs. The #[Map(target: BookEntity::class)] attribute tells
the system to map this DTO onto the Entity class before persistence.
// src/Api/Dto/CreateBook.php
namespace App\Api\Dto;
use App\Entity\Book as BookEntity;
use Symfony\Component\ObjectMapper\Attribute\Map;
use Symfony\Component\Validator\Constraints as Assert;
#[Map(target: BookEntity::class)]
final class CreateBook
{
#[Assert\NotBlank, Assert\Length(max: 255)]
#[Map(target: 'title')] // Maps 'name' input to 'title' entity field
public string $name;
#[Assert\NotBlank, Assert\Length(max: 255)]
public string $description;
#[Assert\NotBlank, Assert\Isbn]
public string $isbn;
#[Assert\PositiveOrZero]
public int $price;
}<?php
// src/Api/Dto/UpdateBook.php
namespace App\Api\Dto;
use App\Entity\Book as BookEntity;
use Symfony\Component\ObjectMapper\Attribute\Map;
use Symfony\Component\Validator\Constraints as Assert;
#[Map(target: BookEntity::class)]
final class UpdateBook
{
#[Assert\NotBlank, Assert\Length(max: 255)]
#[Map(target: 'title')]
public string $name;
#[Assert\NotBlank, Assert\Length(max: 255)]
public string $description;
}For the GetCollection operation, we use a lighter DTO that exposes only essential fields.
// src/Api/Dto/BookCollection.php
namespace App\Api\Dto;
use App\Entity\Book as BookEntity;
use Symfony\Component\ObjectMapper\Attribute\Map;
#[Map(source: BookEntity::class)]
final class BookCollection
{
public int $id;
#[Map(source: 'title')]
public string $name;
public string $isbn;
}In your Book resource, configure the operations to use these classes via input and output.
// src/Api/Resource/Book.php
#[ApiResource(
stateOptions: new Options(entityClass: BookEntity::class),
operations: [
new Get(),
// Use the specialized Output DTO for collections
new GetCollection(
output: BookCollection::class
),
// Use the specialized Input DTO for creation
new Post(
input: CreateBook::class
),
// Use the specialized Input DTO for updates
new Patch(
input: UpdateBook::class
),
]
)]
final class Book { /* ... */ }For complex business actions (like applying a discount), standard CRUD mapping isn’t enough. You should use a custom Processor paired with a specific Input DTO.
This DTO holds the data required for the specific action.
// src/Api/Dto/DiscountBook.php
namespace App\Api\Dto;
use Symfony\Component\Validator\Constraints as Assert;
final class DiscountBook
{
#[Assert\Range(min: 0, max: 100)]
public int $percentage;
}The processor handles the business logic. It receives the DiscountBook DTO as $data and the loaded Entity (retrieved automatically via stateOptions) in the context.
// src/State/DiscountBookProcessor.php
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Api\Resource\Book; // The Output Resource
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\ObjectMapper\ObjectMapperInterface;
final readonly class DiscountBookProcessor implements ProcessorInterface
{
public function __construct(
// Inject the built-in Doctrine persist processor to handle saving
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private ProcessorInterface $persistProcessor,
private ObjectMapperInterface $objectMapper,
) {
}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Book
{
// 1. Retrieve the Entity loaded by API Platform (via stateOptions)
if (!$entity = $context['request']->attributes->get('read_data')) {
throw new NotFoundHttpException('Not Found');
}
// 2. Apply Business Logic
// $data is the validated DiscountBook DTO
$entity->price = (int) ($entity->price * (1 - $data->percentage / 100));
// 3. Persist using the inner processor
$entity = $this->persistProcessor->process($entity, $operation, $uriVariables, $context);
// 4. Map the updated Entity back to the main Book Resource
return $this->objectMapper->map($entity, $operation->getClass());
}
}Finally, register the custom operation in your Book resource.
// src/Api/Resource/Book.php
#[ApiResource(
operations: [
// ... standard operations ...
new Post(
uriTemplate: '/books/{id}/discount',
uriVariables: ['id'],
input: DiscountBook::class,
processor: DiscountBookProcessor::class,
status: 200,
),
]
)]
final class Book { /* ... */ }You can also help us improve the documentation of this page.
Made with love by
Les-Tilleuls.coop can help you design and develop your APIs and web projects, and train your teams in API Platform, Symfony, Next.js, Kubernetes and a wide range of other technologies.
Learn more