@@ -422,6 +422,271 @@ use App\State\BookRepresentationProvider;
422422class 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
427692The services in the previous examples are automatically registered because
0 commit comments