The problem
The order service of an online shop exposes its
orders with API Platform. The shortest path is to put
#[ApiResource] on the
Order Doctrine entity. It holds as long as the
API looks like the database schema.
The schema becomes the API contract. Renaming a column breaks the clients. Every Doctrine migration becomes an API decision.
Serialization groups multiply.
order:read, order:write,
customer:read... To know what an operation
returns, you have to cross-check several classes.
Write validation and persistence get mixed. The POST constraints live on the entity, and also apply to imports and message handlers.
Relations are expensive. As soon as a relation falls outside the groups, a page of orders loads its lines order by order: the N+1 problem. A bidirectional relation badly split into groups ends up as a circular reference.
The options
Expose the entities directly
- What it solves: everything is provided, from CRUD to pagination, with very little code.
- What it costs: the coupling described above, which grows with every relation.
DTO resources mapped by ObjectMapper
Since version 4.2, API Platform links a DTO resource to an
entity with ObjectMapper, a Symfony component driven by
#[Map] attributes.
- What it solves: the contract is separate from the entity, for little code.
- What it costs: relations and special cases go through transformers declared in attributes. The behaviour becomes implicit.
DTO resources with an explicit provider, processor and mappers
-
What it solves: every conversion is plain
PHP code, readable and testable. With
stateOptions, the API Platform Doctrine provider still runs the query: pagination, filters and extensions stay active. - What it costs: more classes, and a mapping written by hand.
Our recommendation
Doctrine entities without any API Platform attribute, DTO
resources linked through stateOptions, and an
explicit provider, processor and mappers.
The resource is the contract: a readonly class,
with no logic. The entity stays a classic Doctrine object,
saved with persist() and flush().
Between the two, there are only two crossings: the provider
for reads, the processor for writes.
stateOptions: new Options(entityClass:
Order::class)
is the bridge: the built-in Doctrine provider queries this
entity, with pagination, filters and query extensions.
Writes go through separate input DTOs:
CreateOrderInput for POST,
UpdateOrderInput for PATCH. We keep
ObjectMapper for flat resources, close to their entity.
The trade-offs we accept
More classes. A resource needs an output DTO, two inputs, a provider, a processor, a mapper and an extension. In exchange, the contract evolves without depending on the schema.
A mapping to maintain. A new field touches the entity, the resource, the mapper and sometimes an input. The tests catch what was forgotten.
Loading relations is our job. The built-in eager loading follows serialization groups, which are absent here. Our extension must follow the relations the mapper reads.
The signal that should trigger a review of this choice: mappers that copy, field by field, resources almost identical to their entities. ObjectMapper then becomes cheaper.
Implementation
The examples target API Platform 4.4 (checked against the 4.4.2 code), Symfony 7.4, Doctrine ORM 3 and PostgreSQL. The differences with 5.0 are summed up at the end.
1. Entities without any API Platform attribute
The identifier is a UUID v7 generated in PHP, at construction time. The status is a string, whose values are constants.
// src/Entity/OrderStatus.php
namespace App\Entity;
final class OrderStatus
{
public const DRAFT = 'draft';
public const PLACED = 'placed';
public const CANCELLED = 'cancelled';
}
// src/Entity/Order.php
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity]
#[ORM\Table(name: 'orders')]
class Order
{
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME)]
private Uuid $id;
#[ORM\Column(length: 32, unique: true)]
private string $reference;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)]
private Customer $customer;
/** @var Collection<int, OrderLine> */
#[ORM\OneToMany(targetEntity: OrderLine::class, mappedBy: 'order', cascade: ['persist'])]
private Collection $lines;
#[ORM\Column(length: 20)]
private string $status = OrderStatus::DRAFT;
#[ORM\Column(length: 500, nullable: true)]
private ?string $deliveryNote = null;
public function __construct(string $reference, Customer $customer)
{
$this->id = Uuid::v7();
$this->reference = $reference;
$this->customer = $customer;
$this->lines = new ArrayCollection();
}
public function addLine(Product $product, string $quantity): void
{
$this->lines->add(new OrderLine($this, $product, $quantity));
}
public function setDeliveryNote(?string $deliveryNote): void
{
$this->deliveryNote = $deliveryNote;
}
// Getters: getId(), getReference(), getLines()...
}
OrderLine holds a
ManyToOne relation to Product and
a decimal quantity (precision 12, scale 3).
Doctrine reads this type as a string: no loss of precision,
unlike a float.
2. The resource and its operations
// src/ApiResource/OrderResource.php
namespace App\ApiResource;
use ApiPlatform\Doctrine\Orm\State\Options;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\ApiResource\Input\CreateOrderInput;
use App\ApiResource\Input\UpdateOrderInput;
use App\Entity\Order;
use App\Exception\OrderConflict;
use App\State\OrderProcessor;
use App\State\OrderProvider;
use Symfony\Component\Uid\Uuid;
#[ApiResource(
shortName: 'Order',
operations: [
new GetCollection(),
new Get(),
new Post(input: CreateOrderInput::class),
new Patch(input: UpdateOrderInput::class),
],
provider: OrderProvider::class,
processor: OrderProcessor::class,
stateOptions: new Options(entityClass: Order::class),
forceEager: false,
collectDenormalizationErrors: true,
exceptionToStatus: [OrderConflict::class => 409],
)]
final readonly class OrderResource
{
public function __construct(
public Uuid $id,
public string $reference,
public string $status,
public CustomerResource $customer,
/** @var list<OrderLineView> */
#[ApiProperty(genId: false)]
public array $lines,
public ?string $deliveryNote,
) {
}
}
shortName: 'Order' gives the
/orders path. The provider and the processor
apply to every operation, but POST reads nothing: API
Platform does not call the provider for it.
The lines are OrderLineView objects
(sku, productName,
quantity), with no operation. In JSON-LD, a
nested object that is not a resource gets an anonymous
@id under /.well-known/genid/...:
genId: false removes it.
The customer, on the other hand, is a resource, referenced
by its IRI /customers/{id}.
A resource referenced by IRI must expose a
Get operation.
Without it, API Platform adds an internal operation: the IRI
is generated, but it answers 404.
Without an identifier, the IRI becomes anonymous:
/.well-known/genid/.... A DTO resource takes
its id property as its identifier. Passing an
entity to
IriConverterInterface::getIriFromResource()
also yields an anonymous IRI: the entity is no longer a
resource.
CustomerResource and
ProductResource therefore follow the order's
model, with at least operations: [new Get()],
their provider and their mapper.
3. The provider and the mapper
The provider writes no query: it delegates to the API Platform Doctrine providers, then converts the entities.
// src/State/OrderProvider.php
namespace App\State;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\PaginatorInterface;
use ApiPlatform\State\Pagination\TraversablePaginator;
use ApiPlatform\State\ProviderInterface;
use App\Mapper\OrderMapper;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final readonly class OrderProvider implements ProviderInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.collection_provider')]
private ProviderInterface $collectionProvider,
#[Autowire(service: 'api_platform.doctrine.orm.state.item_provider')]
private ProviderInterface $itemProvider,
private OrderMapper $mapper,
) {
}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
{
if (!$operation instanceof CollectionOperationInterface) {
$order = $this->itemProvider->provide($operation, $uriVariables, $context);
return null === $order ? null : $this->mapper->toResource($order);
}
$orders = $this->collectionProvider->provide($operation, $uriVariables, $context);
$resources = [];
foreach ($orders as $order) {
$resources[] = $this->mapper->toResource($order);
}
if (!$orders instanceof PaginatorInterface) {
return $resources;
}
return new TraversablePaginator(
new \ArrayIterator($resources),
$orders->getCurrentPage(),
$orders->getItemsPerPage(),
$orders->getTotalItems(),
);
}
}
The TraversablePaginator keeps the total and
the pagination links. The mapper, for its part, does not
depend on API Platform.
// src/Mapper/OrderMapper.php
namespace App\Mapper;
use App\ApiResource\OrderLineView;
use App\ApiResource\OrderResource;
use App\Entity\Order;
final readonly class OrderMapper
{
public function __construct(
private CustomerMapper $customerMapper,
) {
}
public function toResource(Order $order): OrderResource
{
$lines = [];
foreach ($order->getLines() as $line) {
$product = $line->getProduct();
$lines[] = new OrderLineView($product->getSku(), $product->getName(), $line->getQuantity());
}
return new OrderResource(
$order->getId(),
$order->getReference(),
$order->getStatus(),
$this->customerMapper->toResource($order->getCustomer()),
$lines,
$order->getDeliveryNote(),
);
}
}
4. Load the relations in one query
The mapper reads the customer, the lines and their products: without a join, Doctrine loads them order after order. API Platform's eager loading only joins the relations covered by serialization groups, which are absent here.
// src/Doctrine/OrderEagerLoadingExtension.php
namespace App\Doctrine;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Entity\Order;
use Doctrine\ORM\QueryBuilder;
final class OrderEagerLoadingExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
{
$this->join($queryBuilder, $queryNameGenerator, $resourceClass);
}
public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, ?Operation $operation = null, array $context = []): void
{
$this->join($queryBuilder, $queryNameGenerator, $resourceClass);
}
private function join(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass): void
{
// With stateOptions, $resourceClass is the entity class.
if (Order::class !== $resourceClass) {
return;
}
$order = $queryBuilder->getRootAliases()[0];
$customer = $queryNameGenerator->generateJoinAlias('customer');
$line = $queryNameGenerator->generateJoinAlias('line');
$product = $queryNameGenerator->generateJoinAlias('product');
$queryBuilder
->innerJoin("$order.customer", $customer)->addSelect($customer)
->leftJoin("$order.lines", $line)->addSelect($line)
->leftJoin("$line.product", $product)->addSelect($product);
}
}
Autoconfiguration registers the extension. The join on
lines does not break pagination: API Platform
detects it and adapts the Doctrine paginator.
On the resource, forceEager: false restricts
the built-in extension to associations declared with
fetch: 'EAGER'. If serialization groups show up
one day, our extension stays the only one deciding the
joins.
5. The inputs: POST and PATCH
The creation DTO is readonly: the serializer
builds it through its constructor.
// src/ApiResource/Input/CreateOrderInput.php
namespace App\ApiResource\Input;
use App\ApiResource\CustomerResource;
use Symfony\Component\Validator\Constraints as Assert;
final readonly class CreateOrderInput
{
public function __construct(
#[Assert\NotBlank, Assert\Length(max: 32)]
public string $reference,
public CustomerResource $customer,
/** @var list<CreateOrderLineInput> */
#[Assert\Count(min: 1), Assert\Valid]
public array $lines,
) {
}
}
CreateOrderLineInput follows the same model: a
ProductResource $product and a quantity as a
string, checked by Assert\Regex and
Assert\Positive. The customer and the products
arrive as IRIs: API Platform resolves them through the
provider of their resource, and rejects an unknown IRI.
PATCH follows JSON Merge Patch (RFC 7396), with the
application/merge-patch+json type: a missing
field does not change, a field set to null is
cleared. So "missing" and "null" must be told apart.
// src/ApiResource/Input/UpdateOrderInput.php
namespace App\ApiResource\Input;
use Symfony\Component\Validator\Constraints as Assert;
final class UpdateOrderInput
{
// No default value: a field missing from the body stays uninitialized.
#[Assert\Length(max: 500)]
public ?string $deliveryNote;
public function has(string $property): bool
{
return (new \ReflectionProperty($this, $property))->isInitialized($this);
}
}
The serializer only initializes the properties present in
the body. isset() would not do: it returns
false for a missing field as well as for a
field set to null. The Symfony validator, for
its part, reads an uninitialized property as
null.
This DTO cannot be readonly. A
readonly property can only be initialized from
its own class, so through the constructor, where a missing
nullable argument receives null. The difference
would vanish.
6. The processor and the errors
The processor is the only place that writes.
// src/State/OrderProcessor.php
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\Input\CreateOrderInput;
use App\ApiResource\Input\UpdateOrderInput;
use App\ApiResource\OrderResource;
use App\Entity\Customer;
use App\Entity\Order;
use App\Entity\Product;
use App\Exception\OrderConflict;
use App\Mapper\OrderMapper;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
/**
* @implements ProcessorInterface<CreateOrderInput|UpdateOrderInput, OrderResource>
*/
final readonly class OrderProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private OrderMapper $mapper,
) {
}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): OrderResource
{
if ($data instanceof CreateOrderInput) {
$order = new Order($data->reference, $this->entityManager->getReference(Customer::class, $data->customer->id));
foreach ($data->lines as $line) {
$order->addLine($this->entityManager->getReference(Product::class, $line->product->id), $line->quantity);
}
$this->entityManager->persist($order);
} else {
// The provider has already loaded the order, or answered 404.
$order = $this->entityManager->find(Order::class, $uriVariables['id']);
if ($data->has('deliveryNote')) {
$order->setDeliveryNote($data->deliveryNote);
}
}
try {
$this->entityManager->flush();
} catch (UniqueConstraintViolationException $e) {
// The only unique constraint besides the primary key: reference.
throw new OrderConflict('An order already uses this reference.', previous: $e);
}
return $this->mapper->toResource($order);
}
}
OrderConflict is a
final class that extends
\RuntimeException. For PATCH,
find() reads the order from the Doctrine
identity map, without a query.
Two POST requests with the same reference, after a double click, violate the unique constraint. We answer 409 Conflict: the request is valid, but it contradicts the state of the database.
Why not map
UniqueConstraintViolationException directly?
For a 4xx status, API Platform returns the exception message
in detail, even outside debug mode: here, the
PostgreSQL message.
exceptionToStatus is declared on the resource
or on an operation, which wins. Finally,
collectDenormalizationErrors: true returns a
422 that lists every badly typed field, instead of stopping
at the first one.
7. Check persistence with a GET
The POST response is built from the object in memory. A field forgotten in the Doctrine mapping still shows up in it. So we check every write with a separate GET.
// tests/Api/OrderTest.php
namespace App\Tests\Api;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
final class OrderTest extends ApiTestCase
{
protected static ?bool $alwaysBootKernel = false;
public function testCreatedOrderIsReadBackFromTheDatabase(): void
{
$client = static::createClient();
// Customer and product created by the test fixtures.
$response = $client->request('POST', '/orders', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'reference' => 'WEB-1042',
'customer' => '/customers/0199b1a0-7c3e-7d2a-9f10-2b6c1d4e5f60',
'lines' => [['product' => '/products/0199b1a0-8d41-7e3b-a2c4-5d6e7f809a1b', 'quantity' => '2']],
],
]);
self::assertResponseStatusCodeSame(201);
// The kernel reboots between two requests: the GET reads the database again.
$client->request('GET', $response->toArray()['@id']);
self::assertJsonContains(['reference' => 'WEB-1042', 'lines' => [['quantity' => '2.000']]]);
}
}
The quantity sent as 2 comes back as
2.000, as PostgreSQL stores it. The POST
response, built in memory, still showed 2.
What about API Platform 5.0?
5.0 was released on 17 September 2026, the same day as 4.4.
In the 5.0.1 code, stateOptions,
forceEager,
collectDenormalizationErrors,
exceptionToStatus and the extension interfaces
are unchanged. Two differences affect this article:
-
UniqueConstraintViolationExceptionis mapped to 422 by default, with the samedetailfield. -
ApiTestCasemoves to theapi-platform/testpackage, under theApiPlatform\Testnamespace.
Checklist
- No API Platform attribute on the entities.
-
DTO resources linked to their entity through
stateOptions. - A provider that delegates to the Doctrine provider and keeps pagination.
-
A
Getoperation on every resource referenced by IRI. - An extension that loads the relations read by the mapper.
- Separate inputs for POST and PATCH, with no default values for PATCH.
-
collectDenormalizationErrors: trueand a 409 without an SQL message. - Every write read back by a separate GET in the tests.
Sources
-
API Platform,
DTO,
State Providers
and
Extensions:
stateOptions, providers and extensions. -
API Platform,
Performance: eager loading and
forceEager. -
API Platform,
Operations: IRIs without a
Getoperation,/.well-known/genid/{id}. -
API Platform,
Content Negotiation,
Validation
and
Errors Handling: PATCH,
collectDenormalizationErrors,exceptionToStatus. -
API Platform, 4.4.2 code:
ApiProperty
(
genId), EagerLoadingExtension, ErrorProvider (detail). - API Platform, 5.0.0 release notes, Upgrade Guide and 5.0.1 CHANGELOG.
- Symfony, ObjectMapper, UID and Testing.
-
Doctrine,
DBAL types:
decimalread as a string. - PHP, ReflectionProperty::isInitialized and readonly properties.
- IETF, RFC 7396, JSON Merge Patch.