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.

Two separate layers: only the provider and the processor cross over Doctrine layer Crossings API layer Entities Order OrderLine Customer Product EntityManager persist(), flush() OrderResource output, readonly CreateOrderInput POST input UpdateOrderInput PATCH input OrderProvider read, via OrderMapper OrderProcessor write, via the EntityManager response Entities never leave the Doctrine layer; resources never enter it.

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:

  • UniqueConstraintViolationException is mapped to 422 by default, with the same detail field.
  • ApiTestCase moves to the api-platform/test package, under the ApiPlatform\Test namespace.

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 Get operation 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: true and a 409 without an SQL message.
  • Every write read back by a separate GET in the tests.

Sources