From d6a25bd2f479ac1d1a8ae98580f80c184de3dcb5 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 11 Jun 2026 15:35:10 +0200 Subject: [PATCH] docs(state-providers): document repositoryMethod state option Covers the Doctrine `stateOptions: new Options(repositoryMethod: ...)` option for REST and GraphQL, including the computed-field pattern where a processor flattens scalar-augmented rows and requires `write: true`. Refs api-platform/core#7115 --- core/dto.md | 4 + core/state-providers.md | 265 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 269 insertions(+) diff --git a/core/dto.md b/core/dto.md index bf8c36067aa..fa4b241bada 100644 --- a/core/dto.md +++ b/core/dto.md @@ -30,6 +30,10 @@ You can map a DTO Resource directly to a Doctrine Entity using stateOptions. Thi 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. +The Doctrine `stateOptions` also support a `repositoryMethod` parameter to start the provider query +from a custom repository method. See +[Customizing the Doctrine Query via `repositoryMethod`](state-providers.md#customizing-the-doctrine-query-via-repositorymethod-symfony-only). + > [!WARNING] 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. diff --git a/core/state-providers.md b/core/state-providers.md index a50db2766c3..97f69f0801b 100644 --- a/core/state-providers.md +++ b/core/state-providers.md @@ -422,6 +422,271 @@ use App\State\BookRepresentationProvider; class Book {} ``` +## Customizing the Doctrine Query via `repositoryMethod` (Symfony only) + +When using the built-in Doctrine ORM or MongoDB ODM state providers, you can instruct them to start +from a custom query builder produced by your entity repository instead of the default +`createQueryBuilder('o')` / `createAggregationBuilder()` call. This keeps all the standard provider +behavior (pagination, filters, link handling, identifier WHERE clauses) intact while giving you full +control over the base query. + +Set `repositoryMethod` on the `stateOptions` of the operation: + +```php + + */ +class ProductRepository extends EntityRepository +{ + public function findAvailable(): QueryBuilder + { + return $this->createQueryBuilder('o') + ->andWhere('o.available = :available') + ->setParameter('available', true); + } +} +``` + +The providers apply identifier resolution (for item operations), pagination, and filters on top of +the returned builder. A custom root alias is supported — the link handler reads the builder's root +alias automatically. + +If the method does not exist on the repository, a `RuntimeException` is thrown: +`The repository method "ProductRepository::findAvailable" does not exist.` + +If the method returns a value that is not the expected builder type, a `RuntimeException` is thrown: +`The repository method "findAvailable" must return a QueryBuilder instance.` + +> [!NOTE] Because the filter applies at the item level too, a `Get` operation using a +> `repositoryMethod` that filters rows will return a 404 response for any item excluded by that +> filter. + +### GraphQL + +`repositoryMethod` works identically for GraphQL queries. Use it on the `ApiResource` or on specific +GraphQL operations: + +```php + $entity, 'fieldAlias' => $scalar]` instead of plain entities. To map the +scalar back onto the entity, combine `repositoryMethod` with a `processor` on the operation. + +A processor only runs on a read operation when `write: true` is set on that operation. Without this +flag the processor stage is skipped and the raw array rows reach normalization, which produces +errors such as "Cannot return null for non-nullable field". Set `write: true` explicitly to enable +the processor. + +**REST example:** + +```php + + */ +class CartRepository extends EntityRepository +{ + public function getCartsWithTotalQuantity(): QueryBuilder + { + return $this->createQueryBuilder('o') + ->leftJoin('o.items', 'items') + ->addSelect('COALESCE(SUM(items.quantity), 0) AS totalQuantity') + ->addGroupBy('o.id'); + } +} +``` + +```php +totalQuantity = $row['totalQuantity'] ?? 0; + $row = $cart; + } + + return $data; + } +} +``` + +**GraphQL example:** + +The same `process` method works for GraphQL. Declare it on the `QueryCollection` operation alongside +`write: true`: + +```php +totalQuantity = $row['totalQuantity'] ?? 0; + $row = $cart; + } + + return $data; + } +} +``` + +With `paginationEnabled: false` the GraphQL query returns a plain list: + +```graphql +{ + carts { + totalQuantity + } +} +``` + +With pagination enabled (the default), it returns a Relay connection: + +```graphql +{ + carts { + edges { + node { + totalQuantity + } + } + } +} +``` + ## Registering Services Without Autowiring (only for the Symfony variant) The services in the previous examples are automatically registered because