Skip to content

Commit 4bf8ef9

Browse files
authored
docs(state-providers): document repositoryMethod state option (#2293)
1 parent 93df9a9 commit 4bf8ef9

2 files changed

Lines changed: 269 additions & 0 deletions

File tree

core/dto.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ You can map a DTO Resource directly to a Doctrine Entity using stateOptions. Thi
3030
configures the built-in State Providers and Processors to fetch/persist data using the Entity and
3131
map it to your Resource (DTO) using the Symfony Object Mapper.
3232

33+
The Doctrine `stateOptions` also support a `repositoryMethod` parameter to start the provider query
34+
from a custom repository method. See
35+
[Customizing the Doctrine Query via `repositoryMethod`](state-providers.md#customizing-the-doctrine-query-via-repositorymethod-symfony-only).
36+
3337
> [!WARNING] You must apply the #[Map] attribute to your DTO class. This signals API Platform to use
3438
> the Object Mapper for transforming data between the Entity and the DTO.
3539

core/state-providers.md

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,271 @@ use App\State\BookRepresentationProvider;
422422
class Book {}
423423
```
424424

425+
## Customizing the Doctrine Query via `repositoryMethod` (Symfony only)
426+
427+
When using the built-in Doctrine ORM or MongoDB ODM state providers, you can instruct them to start
428+
from a custom query builder produced by your entity repository instead of the default
429+
`createQueryBuilder('o')` / `createAggregationBuilder()` call. This keeps all the standard provider
430+
behavior (pagination, filters, link handling, identifier WHERE clauses) intact while giving you full
431+
control over the base query.
432+
433+
Set `repositoryMethod` on the `stateOptions` of the operation:
434+
435+
```php
436+
<?php
437+
// api/src/Entity/Product.php
438+
439+
namespace App\Entity;
440+
441+
use ApiPlatform\Doctrine\Orm\State\Options;
442+
use ApiPlatform\Metadata\ApiResource;
443+
use ApiPlatform\Metadata\Get;
444+
use ApiPlatform\Metadata\GetCollection;
445+
use App\Repository\ProductRepository;
446+
use Doctrine\ORM\Mapping as ORM;
447+
448+
#[ORM\Entity(repositoryClass: ProductRepository::class)]
449+
#[ApiResource]
450+
#[GetCollection(stateOptions: new Options(repositoryMethod: 'findAvailable'))]
451+
#[Get(stateOptions: new Options(repositoryMethod: 'findAvailable'))]
452+
class Product
453+
{
454+
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
455+
private ?int $id = null;
456+
457+
#[ORM\Column]
458+
public bool $available = true;
459+
460+
// ...
461+
}
462+
```
463+
464+
The repository method must be `public` and return a `Doctrine\ORM\QueryBuilder` for ORM (or a
465+
`Doctrine\ODM\MongoDB\Aggregation\Builder` for MongoDB ODM):
466+
467+
```php
468+
<?php
469+
// api/src/Repository/ProductRepository.php
470+
471+
namespace App\Repository;
472+
473+
use App\Entity\Product;
474+
use Doctrine\ORM\EntityRepository;
475+
use Doctrine\ORM\QueryBuilder;
476+
477+
/**
478+
* @extends EntityRepository<Product>
479+
*/
480+
class ProductRepository extends EntityRepository
481+
{
482+
public function findAvailable(): QueryBuilder
483+
{
484+
return $this->createQueryBuilder('o')
485+
->andWhere('o.available = :available')
486+
->setParameter('available', true);
487+
}
488+
}
489+
```
490+
491+
The providers apply identifier resolution (for item operations), pagination, and filters on top of
492+
the returned builder. A custom root alias is supported — the link handler reads the builder's root
493+
alias automatically.
494+
495+
If the method does not exist on the repository, a `RuntimeException` is thrown:
496+
`The repository method "ProductRepository::findAvailable" does not exist.`
497+
498+
If the method returns a value that is not the expected builder type, a `RuntimeException` is thrown:
499+
`The repository method "findAvailable" must return a QueryBuilder instance.`
500+
501+
> [!NOTE] Because the filter applies at the item level too, a `Get` operation using a
502+
> `repositoryMethod` that filters rows will return a 404 response for any item excluded by that
503+
> filter.
504+
505+
### GraphQL
506+
507+
`repositoryMethod` works identically for GraphQL queries. Use it on the `ApiResource` or on specific
508+
GraphQL operations:
509+
510+
```php
511+
<?php
512+
// api/src/Entity/Product.php
513+
514+
namespace App\Entity;
515+
516+
use ApiPlatform\Doctrine\Orm\State\Options;
517+
use ApiPlatform\Metadata\ApiResource;
518+
use ApiPlatform\Metadata\GraphQl\Query;
519+
use ApiPlatform\Metadata\GraphQl\QueryCollection;
520+
use App\Repository\ProductRepository;
521+
use Doctrine\ORM\Mapping as ORM;
522+
523+
#[ORM\Entity(repositoryClass: ProductRepository::class)]
524+
#[ApiResource(
525+
stateOptions: new Options(repositoryMethod: 'findAvailable'),
526+
graphQlOperations: [
527+
new Query(),
528+
new QueryCollection(),
529+
]
530+
)]
531+
class Product
532+
{
533+
// ...
534+
}
535+
```
536+
537+
### Computed Fields
538+
539+
A common use case is adding a computed scalar to each row using `addSelect`. Doctrine then returns
540+
mixed rows shaped `[0 => $entity, 'fieldAlias' => $scalar]` instead of plain entities. To map the
541+
scalar back onto the entity, combine `repositoryMethod` with a `processor` on the operation.
542+
543+
A processor only runs on a read operation when `write: true` is set on that operation. Without this
544+
flag the processor stage is skipped and the raw array rows reach normalization, which produces
545+
errors such as "Cannot return null for non-nullable field". Set `write: true` explicitly to enable
546+
the processor.
547+
548+
**REST example:**
549+
550+
```php
551+
<?php
552+
// api/src/Repository/CartRepository.php
553+
554+
namespace App\Repository;
555+
556+
use App\Entity\Cart;
557+
use Doctrine\ORM\EntityRepository;
558+
use Doctrine\ORM\QueryBuilder;
559+
560+
/**
561+
* @extends EntityRepository<Cart>
562+
*/
563+
class CartRepository extends EntityRepository
564+
{
565+
public function getCartsWithTotalQuantity(): QueryBuilder
566+
{
567+
return $this->createQueryBuilder('o')
568+
->leftJoin('o.items', 'items')
569+
->addSelect('COALESCE(SUM(items.quantity), 0) AS totalQuantity')
570+
->addGroupBy('o.id');
571+
}
572+
}
573+
```
574+
575+
```php
576+
<?php
577+
// api/src/Entity/Cart.php
578+
579+
namespace App\Entity;
580+
581+
use ApiPlatform\Doctrine\Orm\State\Options;
582+
use ApiPlatform\Metadata\GetCollection;
583+
use ApiPlatform\Metadata\Operation;
584+
use App\Repository\CartRepository;
585+
use Doctrine\ORM\Mapping as ORM;
586+
587+
#[ORM\Entity(repositoryClass: CartRepository::class)]
588+
#[GetCollection(
589+
stateOptions: new Options(repositoryMethod: 'getCartsWithTotalQuantity'),
590+
processor: [self::class, 'process'],
591+
write: true,
592+
)]
593+
class Cart
594+
{
595+
public ?int $totalQuantity = null;
596+
597+
// ...
598+
599+
public static function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
600+
{
601+
foreach ($data as &$row) {
602+
$cart = $row[0];
603+
$cart->totalQuantity = $row['totalQuantity'] ?? 0;
604+
$row = $cart;
605+
}
606+
607+
return $data;
608+
}
609+
}
610+
```
611+
612+
**GraphQL example:**
613+
614+
The same `process` method works for GraphQL. Declare it on the `QueryCollection` operation alongside
615+
`write: true`:
616+
617+
```php
618+
<?php
619+
// api/src/Entity/Cart.php
620+
621+
namespace App\Entity;
622+
623+
use ApiPlatform\Doctrine\Orm\State\Options;
624+
use ApiPlatform\Metadata\ApiResource;
625+
use ApiPlatform\Metadata\GetCollection;
626+
use ApiPlatform\Metadata\GraphQl\Query;
627+
use ApiPlatform\Metadata\GraphQl\QueryCollection;
628+
use ApiPlatform\Metadata\Operation;
629+
use App\Repository\CartRepository;
630+
use Doctrine\ORM\Mapping as ORM;
631+
632+
#[ORM\Entity(repositoryClass: CartRepository::class)]
633+
#[ApiResource(
634+
stateOptions: new Options(repositoryMethod: 'getCartsWithTotalQuantity'),
635+
graphQlOperations: [
636+
new Query(),
637+
new QueryCollection(
638+
processor: [self::class, 'process'],
639+
write: true,
640+
),
641+
],
642+
)]
643+
#[GetCollection(
644+
processor: [self::class, 'process'],
645+
write: true,
646+
)]
647+
class Cart
648+
{
649+
public ?int $totalQuantity = null;
650+
651+
// ...
652+
653+
public static function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
654+
{
655+
foreach ($data as &$row) {
656+
$cart = $row[0];
657+
$cart->totalQuantity = $row['totalQuantity'] ?? 0;
658+
$row = $cart;
659+
}
660+
661+
return $data;
662+
}
663+
}
664+
```
665+
666+
With `paginationEnabled: false` the GraphQL query returns a plain list:
667+
668+
```graphql
669+
{
670+
carts {
671+
totalQuantity
672+
}
673+
}
674+
```
675+
676+
With pagination enabled (the default), it returns a Relay connection:
677+
678+
```graphql
679+
{
680+
carts {
681+
edges {
682+
node {
683+
totalQuantity
684+
}
685+
}
686+
}
687+
}
688+
```
689+
425690
## Registering Services Without Autowiring (only for the Symfony variant)
426691

427692
The services in the previous examples are automatically registered because

0 commit comments

Comments
 (0)