Skip to content

Repositories ​

Define a repository ​

php
use GraphqlOrm\Registry\GraphqlManagerRegistry;
use GraphqlOrm\Repository\ServiceGraphqlEntityRepository;

/**
 * @extends ServiceGraphqlEntityRepository<Task>
 */
class TaskRepository extends ServiceGraphqlEntityRepository
{
    public function __construct(GraphqlManagerRegistry $registry)
    {
        parent::__construct($registry, Task::class);
    }
}

ServiceGraphqlEntityRepository takes the manager of the API of its entity from the registry, as the ServiceEntityRepository of Doctrine (see several APIs). A repository extending GraphqlEntityRepository and taking GraphqlManager $manager keeps working, with the default manager.

Declared as a service (autowired, as with the default Symfony configuration) and named in the repositoryClass of #[GraphqlOrm\Entity], the repository is the one GraphqlManager::getRepository() gives, as Doctrine EntityManager::getRepository(); an entity without one gets a generic GraphqlEntityRepository:

php
$tasks = $manager->getRepository(Task::class); // TaskRepository

Inject GraphqlManagerInterface, as Doctrine EntityManagerInterface: it is the type to simulate or decorate in the tests of an application. GraphqlManager is final.

php
public function __construct(private GraphqlManagerInterface $manager) {}

// in a test
$manager = $this->createStub(GraphqlManagerInterface::class);
$manager->method('getRepository')->willReturn($taskRepository);

The form type, the controller argument resolver and the graphqlorm:find command go through it, so your own methods and overrides apply there too. The lazy loading keeps a generic repository, so that an override of find() does not change how lazy relations are loaded.

Built-in methods ​

php
use GraphqlOrm\Query\Direction;

// Fetch all entities
$tasks = $repository->findAll();

// Fetch one entity by its identifier (null when not found)
$task = $repository->find(42);

// Filter by criteria, with an optional ordering and limit
$tasks = $repository->findBy(['status' => 'active']);
$tasks = $repository->findBy(['status' => 'active'], ['createdAt' => Direction::Desc], 10);

// The entity of an identifier without a query, loaded on first access (see doctrine.md#references)
$client = $clientRepository->getReference('C-042');

// Keyed by a field, as Doctrine indexBy: ['bug' => Label, 'docs' => Label, ...]
$labels = $labelRepository->findBy([], indexBy: 'code');

// First entity matching the criteria (null when none)
$task = $repository->findOneBy(['status' => 'active'], ['createdAt' => Direction::Desc]);

// Number of entities matching the criteria
$count = $repository->count(['status' => 'active']);

// The class of its entities, as Doctrine getClassName()
$class = $repository->getClassName(); // Task::class

// A copy loading these relations in place of max_depth (see query-builder.md#loading-relations)
$tasks = $repository->with('assignee')->findBy(['status' => 'active']);

// Custom query via Query Builder
$qb = $repository->createQueryBuilder();
$tasks = $qb
    ->select('title', 'user.name')
    ->where($qb->expr()->eq('status', 'active'))
    ->limit(10)
    ->getQuery()
    ->getResult();

Criteria and ordering keys are the GraphQL names of the fields (mappedFrom) or the PHP properties of the entity: findBy(['dueDate' => null]) and findBy(['due_date' => null]) send the same query. A property having the GraphQL name of another field designates that field. Each criterion is an equality, and a list matches any of its values, as with Doctrine (findBy(['id' => [1, 3]]), an empty list matching nothing); the dialect decides how it is sent:

DialectfindBy(['status' => 'active', 'deletedAt' => null])
DefaultDialecttasks(status: "active", deletedAt: null)
DataApiBuilderDialecttasks(filter: { status: { eq: "active" }, deletedAt: { isNull: true } })
HasuraDialecttasks(where: { status: { _eq: "active" }, deletedAt: { _is_null: true } })
PostGraphileDialectallTasks(condition: { status: "active", deletedAt: null })

RelayDialect sends them as DefaultDialect does. A list is sent as in (Data API Builder) or _in (Hasura); PostGraphile conditions matching a single value, a list throws a LogicException there.

find() requires an identifier (#[GraphqlOrm\Identifier]) and throws a LogicException otherwise. An ordering must be a Direction, an InvalidArgumentException telling otherwise. Ordering is supported by neither DefaultDialect nor RelayDialect (see dialects). Use the query builder for anything other than equalities.

Counting ​

count() sends a query of its own, with the same criteria as findBy():

DialectCount query
DataApiBuilderDialecttasks(filter: ...) { groupBy { aggregations { count(field: id) } } }
HasuraDialecttasks_aggregate(where: ...) { aggregate { count } }
PostGraphileDialectallTasks(condition: ...) { totalCount }
RelayDialecttasks(...) { totalCount }: not in the Relay specification, but common
DefaultDialectnone: count() throws a LogicException

Data API Builder only counts the values of a numeric field that are not null. count() counts on the identifier when its property is typed int or float. Otherwise, as for an identifier typed string or an entity without identifier, it introspects the schema once (cached as the variables, in the variables.cache_pool for variables.ttl seconds) and counts on the first numeric field of the entity that is never null (Int!, Float!...): every entity has a value there, so that the count is exact. A LogicException tells when the entity has no such field, or when the schema cannot be introspected.

The values of a count query are sent inline, not as GraphQL variables.

Aggregates ​

sum(), avg(), min() and max() aggregate a field, a GraphQL name or a PHP property, over the entities matching the criteria, with a query of their own. They return null when no entity matches:

php
$taskRepository->sum('estimate');                    // int|float|null
$taskRepository->avg('estimate', ['done' => false]);
$taskRepository->max('dueDate');                     // '2026-02-01T09:00:00+00:00'
DialectAggregate query
DataApiBuilderDialecttasks(filter: ...) { groupBy { aggregations { sum(field: estimate) } } }: numeric fields only
HasuraDialecttasks_aggregate(where: ...) { aggregate { sum { estimate } } }
Other dialectsnone: the methods throw a LogicException

min() and max() return the value as the API sent it: a date is a string. A sum or an average sent as a string (large or decimal numbers) is converted to a number.

When a query fails ​

A failing query throws a GraphqlRequestException holding the errors of the server, whether it answered with a 200 or, as Data API Builder and PostGraphile do for an invalid query, with a 400. When the query names fields the entity does not map, they are pointed out, one per line, with the closest mapped field:

GraphQL endpoint "https://api.example.com/graphql" answered with HTTP status 400: The specified input object field `titel` does not exist.
Field "titel" is not mapped on App\GraphQL\Entity\Task: did you mean "title"?

Such fields are still sent, as the API may accept fields the entity does not map (a foreign key such as user_id): they are only pointed out when the query fails. The PHPStan rules report them before running.

In a controller ​

An argument typed with an entity receives the entity whose identifier is in the route, found with findOneBy() on its identifier:

php
#[Route('/tasks/{id}')]
public function show(Task $task): Response
{
    // ...
}

The identifier is read from the route parameter named after the argument ({task}), otherwise from {id}. An entity not found is a 404 response, or null for a nullable argument (?Task $task). Route parameters being strings, an integer identifier is converted, and a value that is not an integer is a 404 without query.

#[MapGraphqlEntity] changes how the entity is found, as Doctrine #[MapEntity] does:

php
use GraphqlOrm\Attribute\MapGraphqlEntity;

// The identifier in another route parameter
#[Route('/users/{userId}/tasks/{taskId}')]
public function show(
    #[MapGraphqlEntity(id: 'userId')] User $user,
    #[MapGraphqlEntity(id: 'taskId')] Task $task,
): Response

// Other fields, found with findOneBy(): route parameter => GraphQL field name or PHP property
#[Route('/projects/{slug}')]
public function project(#[MapGraphqlEntity(mapping: ['slug' => 'slug'])] Project $project): Response

// Left to the other resolvers
public function create(#[MapGraphqlEntity(disabled: true)] Task $task): Response

A mapping on a field the entity does not map throws an InvalidArgumentException.

Released under the MIT License.