Computed fields allow you to expose derived values from entity methods in your GraphQL schema without storing them in the database. Common use cases include full names, formatted values, calculations, and other business logic.
The simplest way to add a computed field is with the #[ComputedField] attribute.
This attribute is placed on a public method in your entity.
use ApiSkeletons\Doctrine\ORM\GraphQL\Attribute as GraphQL;
#[GraphQL\Entity]
class Artist
{
#[GraphQL\Field]
private string $firstName;
#[GraphQL\Field]
private string $lastName;
#[GraphQL\ComputedField(
type: 'string',
description: 'Full name of the artist'
)]
public function getFullName(): string
{
return $this->firstName . ' ' . $this->lastName;
}
}
This creates a fullName field in your GraphQL schema that calls the getFullName()
method when queried. The field name is automatically derived from the method name
by removing the get prefix.
query {
artists {
edges {
node {
firstName
lastName
fullName
}
}
}
}
The #[ComputedField] attribute accepts these parameters:
type - Required. The GraphQL type name (e.g., 'string', 'int', 'boolean'),
which must match a registered type in the TypeContainer, or the class of an entity; see
Entity Types.
list - Optional. true when the method returns a list of the type, such as an
array of strings or of entities. The default is false.
args - Optional. The type of each method parameter which is not an int,
float, string or bool, by its name; see `Arguments`_.
description - Optional. A description of the computed field for GraphQL schema documentation.
name - Optional. Override the field name in the GraphQL schema. If not provided,
the name is derived from the method name.
group - Optional. The attribute group (default: 'default').
expression - Optional. The DQL expression of the field’s value, by which it is
filtered and sorted; see Filters and Sorting.
excludeFilters - Optional. Filters to exclude for a field with an expression.
includeFilters - Optional. The only filters to allow for a field with an
expression. It is mutually exclusive with excludeFilters.
batch - Optional. true when a method of the entity’s repository is given a
Collection of entities and returns their values; see Batching. The default
is false.
By default, field names are derived from method names:
getFullName() becomes fullName
isActive() stays isActive (for boolean methods)
Other methods use the method name as-is, including a method such as
getaway() whose name only begins with get
You can override this with the name parameter:
#[GraphQL\ComputedField(
type: 'string',
name: 'displayName',
description: 'Display name for UI'
)]
public function getFullDisplayName(): string
{
return 'Artist: ' . $this->name;
}
This creates a displayName field instead of fullDisplayName. Two
computed fields may not have the same name, nor a field’s, which throws
ApiSkeletons\Doctrine\ORM\GraphQL\Exception\Metadata when the metadata
is built.
You can add multiple computed fields to the same entity:
#[GraphQL\Entity]
class User
{
#[GraphQL\Field]
private string $name;
#[GraphQL\Field]
private string $email;
#[GraphQL\ComputedField(type: 'string', description: 'Full name')]
public function getFullName(): string
{
return $this->name . ' (' . $this->email . ')';
}
#[GraphQL\ComputedField(type: 'string', description: 'Email domain')]
public function getEmailDomain(): string
{
return substr($this->email, strpos($this->email, '@') + 1);
}
#[GraphQL\ComputedField(type: 'boolean', description: 'Is user active')]
public function isActive(): bool
{
return $this->password !== '';
}
}
Computed fields are:
Lazy evaluated - The method is called only when the field is requested in a GraphQL query, and once for each entity however many times the query requests it. A field with `arguments`_ is computed once for each entity and set of arguments.
Integrated with the hydrator - The hydrator’s extract() returns computed values
with the regular fields, except those of fields with arguments, which have no one value
Cached - If you enable useHydratorCache, a computed value is cached with the
entity’s other values for as long as the entity exists
Filtered by an expression - A computed field’s value is calculated in PHP, so it is
filtered and sorted only when it has an expression, its value in DQL; see
Filters and Sorting
A computed field has an argument for each parameter of its method, of the same name:
#[GraphQL\ComputedField(type: 'int')]
public function getTotalRecordings(int|null $year = null): int
{
if ($year === null) {
return $this->recordings->count();
}
return $this->recordings->filter(
static fn (Recording $recording): bool => $recording->getYear() === $year,
)->count();
}
{ artist { edges { node { name totalRecordings } } } }
{ artist { edges { node { name totalRecordings(year: 2002) } } } }
GraphQL arguments are named, so the argument is year, not a position.
An int, float, string or bool parameter is an Int,
Float, String or Boolean argument. Give the type of any other
parameter, by its name, in the attribute’s args, as a type registered in
the TypeContainer:
#[GraphQL\ComputedField(type: 'boolean', args: ['date' => 'date_immutable'])]
public function getFoundedBefore(DateTimeImmutable $date): bool
A parameter which does not allow null is a required, non-null argument, unless it has a default value.
A default value is the argument’s default, and must be an int,
float, string or bool. An argument which is not given, and has
no default, is null.
A variadic parameter, or one passed by reference, cannot be an argument.
These are checked when the metadata is built, and throw
ApiSkeletons\Doctrine\ORM\GraphQL\Exception\Metadata. When the type
is built, an argument’s type must be one which can be input, and a default
must be a value of it: '2020-01-01' is not a value of date_immutable,
whose values are dates, so a parameter given that type in args may not
have it as its default. These throw the same exception.
Each set of arguments has its own value, so aliases of a field with
different arguments, such as a: totalRecordings(year: 2002)
b: totalRecordings(year: 2003), each have theirs. A computed field with
arguments has no one value, so the hydrator’s extract() leaves it out.
The method is called for each entity a query returns. A method which queries the database, rather than reading the entity, runs a query for each of them. A method of the entity’s repository may be batched instead, to run one query for all of them.
A computed field’s type may be the class of an entity exposed in the
driver’s group. The field is then of that entity’s type, and its method
returns an entity of the class, or null. With list: true the method
returns a list of them, such as an array or a Doctrine Collection.
#[GraphQL\Entity]
class Book
{
#[GraphQL\ComputedField(type: Author::class)]
public function getWriter(): Author
{
return $this->author;
}
}
#[GraphQL\Entity]
class Author
{
#[GraphQL\ComputedField(type: Book::class, list: true)]
public function getBestSellers(): array
{
return array_values($this->books->filter(
static fn (Book $book): bool => $book->isBestSeller(),
)->toArray());
}
}
This exposes a relation which is not mapped as an association, or which is
derived from one, or a subclass’s own fields through its parent’s type; see
Inheritance. The entity may be of the same
class as the field’s own entity, and two entities may have computed fields of
each other’s types. An
entity of a class which is not exposed in the group throws
ApiSkeletons\Doctrine\ORM\GraphQL\Exception\Metadata when the metadata
is built. The field’s description is its own, else the entity’s.
An entity which is not loaded, such as the target of an unloaded to-one
association, is loaded in a batch with the others, as an unloaded to-one
association is; so are the entities of a list. The method itself is called
for each entity, so a method which reads a collection loads that collection
for each entity. A computed field is not a connection: a list has no
pagination, filters or totalCount, and it holds what the method returns.
For a large collection, expose the association instead, whose rows the
database filters and pages.
A computed field’s value is calculated in PHP, after the rows are fetched, but
a filter must be applied by the database: the rows of a page, and its
totalCount, are counted and limited there. So a computed field is filtered
and sorted only when it has an expression: the DQL expression of its value.
The field’s filters, sort and sortPriority among them, then apply to the
expression as a field’s apply to its column.
use ApiSkeletons\Doctrine\ORM\GraphQL\Attribute as GraphQL;
#[GraphQL\Entity]
class Artist
{
#[GraphQL\ComputedField(
type: 'int',
expression: '(SELECT COUNT({p}.id) FROM App\ORM\Entity\Performance {p} WHERE {p}.artist = {entity})',
)]
public function getPerformanceCount(): int
{
return $this->performances->count();
}
}
{
artists (filter: { performanceCount: { gt: 0, sort: DESC } }) {
edges { node { name performanceCount } }
}
}
The method still computes the value a query returns; the expression is used only to filter and sort. They must compute the same value, or a filter will disagree with the value shown, and a sorted list will appear out of order.
Important
Every alias in an expression must be a placeholder, written in braces:
{entity} for the field’s own entity, and any other name, such as
{p}, for an alias of the expression’s own.
DQL aliases must be unique across a query, subqueries included, and an
expression may be used more than once in one query: by two filters, such as
{ gt: 0, lt: 5 }, or by a filter and a sort. Each placeholder is given a
name unique in the query each time the expression is used, so its aliases
never collide with each other’s, or with those of a QueryBuilder listener. An
alias written plainly, such as p, works until the expression is used twice;
the check described below finds it.
A placeholder is any {name} in the expression, including one within a
string literal.
{:name} in an expression is the field’s argument
name, bound as a parameter. A filter is applied before any field is
resolved, so its arguments cannot be those the field is given where it is
selected. The field’s filters take them instead, in args, of the
arguments the expression uses:
#[GraphQL\ComputedField(
type: 'int',
expression: '(SELECT COUNT({r}.id) FROM App\ORM\Entity\Recording {r} '
. 'WHERE {r}.artist = {entity} AND {r}.year >= {:year})',
)]
public function getRecordingCountSince(int $year): int
{
artists (filter: { recordingCountSince: { args: { year: 2003 }, gt: 0 } }) {
edges { node { name recordingCountSince(year: 2003) } }
}
}
An argument not given in args is its default value, or null when it has
none. args is required when an argument the expression uses is. The
filter’s args and the field’s own arguments are independent: a filter may
use 2003 while the field shows 2004.
The filters of a computed field are those of its type, less those
excluded by the configuration, by its entity, by the association whose
filters they are, and by its own excludeFilters or includeFilters.
DQL compares any expression, but takes a subquery, or an arithmetic
expression, for none of IN, IS NULL and LIKE. So the in,
notin, isnull, contains, startswith and endswith filters
are each excluded for an expression DQL does not take them for, and added to
the field’s excludeFilters in the metadata. between is applied as two
comparisons, so it applies to every expression. A function, such as
UPPER({entity}.name) or SIZE({entity}.performances), takes every
filter.
A sort orders by the expression selected as a hidden result variable, as DQL
does not order by a subquery. A QueryBuilder listener must
not replace the query’s select, as ->select() does, which would remove it.
Each expression is checked when the metadata is built, after the
metadata.build event, and throws
ApiSkeletons\Doctrine\ORM\GraphQL\Exception\Metadata when:
it is not valid DQL, such as for a syntax error, an unknown field or class,
or an alias written without a placeholder. Doctrine’s QueryException is
the previous exception. The expression is parsed, not run, so no database
is needed.
it uses {:name} for a name which is not a parameter of the method.
its field is a list, or of an entity type, neither of which has a single
value to filter or sort by.
a field without an expression has excludeFilters or
includeFilters.
Metadata read from a cache is not checked again.
A subquery is evaluated for each row the filters consider, and a sort by one orders every row before the page is taken, without an index. For a large table, store the value in a column instead.
Databases order nulls differently, as they do for a field.
An entity has no entity manager, so a value which needs a query, such as a
count of related rows, is better computed by the entity’s repository. The
methods of an entity’s repository, its repositoryClass, are read for
#[ComputedField] attributes as the entity’s own are. The method is given
the entity first:
use ApiSkeletons\Doctrine\ORM\GraphQL\Attribute as GraphQL;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Mapping as ORM;
#[GraphQL\Entity]
#[ORM\Entity(repositoryClass: ArtistRepository::class)]
class Artist
{
}
class ArtistRepository extends EntityRepository
{
#[GraphQL\ComputedField(type: 'string')]
public function getLatestVenue(Artist $artist, int|null $year = null): string|null
{
// A query using $this->getEntityManager()
}
}
Its further parameters are the field’s arguments, and every
other parameter of the attribute applies as it does on an entity, an
expression among them. A computed field of the repository may not have the
name of a field, or of a computed field, of the entity.
The repository is the one the entity manager’s getRepository() gives, so a
custom repository factory, such as Symfony’s service repositories, is honoured.
When the metadata is built, the method’s first parameter must accept the
entity, or it throws ApiSkeletons\Doctrine\ORM\GraphQL\Exception\Metadata.
A repository’s method called for each entity still runs a query for each of
them. With batch: true, it is given every entity being resolved, in a
Doctrine Collection, and returns their values, so one query gives them all:
/**
* @param Collection<int|string, Artist> $artists
*
* @return array<int|string, int>
*/
#[GraphQL\ComputedField(type: 'int', batch: true)]
public function getPerformanceCount(Collection $artists, int|null $year = null): array
{
$queryBuilder = $this->getEntityManager()->createQueryBuilder()
->select('IDENTITY(p.artist) AS id', 'COUNT(p.id) AS total')
->from(Performance::class, 'p')
->where('p.artist IN (:artists)')
->groupBy('p.artist')
->setParameter('artists', $artists->getKeys());
// Filter by $year
return array_column($queryBuilder->getQuery()->getScalarResult(), 'total', 'id');
}
The Collection is keyed by the database value of each entity’s
identifier, which is what a scalar query, such as one selecting
IDENTITY() of an association, returns. For an integer identifier, that
is the integer. Bind the keys, as above, rather than the entities: Doctrine
does not convert an entity’s identifier in an IN list, which matters for
an identifier such as a binary UUID.
The method returns the values keyed the same way, as an array or a
Collection. Anything else is an error of every entity’s field. An
entity it gives no value for is null; for a non-null type, that is an error,
which a client sees as “Internal server error”.
The parameter must accept any Collection: type it Collection, or
wider, such as iterable. The library passes an ArrayCollection, but
promises only a Collection, so ArrayCollection and array are
rejected when the metadata is built.
The entities waiting for the field with the same arguments, at any depth of the query, are given to one call, up to 1,000 entities a call. Aliases with different arguments are separate calls.
An entity whose identifier is composite, or an association, has no single
key, so batch: true throws when the metadata is built. So does
batch: true on a method of the entity.
With the batchAssociations config off, the method is given each entity
alone.
The hydrator’s extract() leaves a batched field out, as it would be a
query for each entity extracted.
A batched field may be of an entity type, and a list.
The method returns, keyed by the identifier of the entity it is given, an
entity of the type, or for a list, an array or Collection of them. Select
each related entity with the identifier it belongs to, then group them:
#[GraphQL\ComputedField(type: Recording::class, list: true, batch: true)]
public function getLiveRecordings(Collection $artists): array
{
$rows = $this->getEntityManager()->createQuery(
'SELECT r, IDENTITY(r.artist) AS artistId FROM ' . Recording::class . ' r'
. ' WHERE r.artist IN (:artists) AND r.live = true',
)->setParameter('artists', $artists->getKeys())->getResult();
$recordings = [];
foreach ($rows as $row) {
$recordings[$row['artistId']][] = $row[0];
}
return $recordings;
}
A list is not a connection: it has no pagination, filters or limit, so the method must bound it.
An expression and a batched method do not run together. The expression
only filters and sorts: it is written for one entity and evaluated by the
database for each row of the connection’s own query. The method gives the
values, once the page is fetched. So the value is written twice, in two
shapes, which must agree.
Each entity class’s own repository is read. Doctrine gives an entity the
repository of a parent only when the parent is a mapped superclass, not when
it is an entity. A row of an exposed subclass is resolved by the subclass’s
fields, even where its parent’s type is queried, so an exposed subclass whose
repository lacks a computed field of its exposed parent’s repository throws
ApiSkeletons\Doctrine\ORM\GraphQL\Exception\Metadata when the metadata
is built. Give the subclass a repository which extends its parent’s. A row of
a subclass which is not exposed is resolved as its exposed parent, by the
parent’s repository.
Computed fields do not use hydrator strategies. The value
returned by the entity method is used as-is; it is not passed through a strategy
and a strategy’s $fieldName argument is never a computed field name. Transform
the value inside the entity method instead.
For more complex scenarios, such as computed fields that require database queries or external service calls, you can use the EntityDefinition Event.
This approach gives you full control over the field definition and resolver logic. You must attach a listener before defining your GraphQL schema.
Events of this type are named Entity::class . '.definition' and the event
name cannot be modified.
use ApiSkeletons\Doctrine\ORM\GraphQL\Driver;
use ApiSkeletons\Doctrine\ORM\GraphQL\Event\EntityDefinition;
use App\ORM\Entity\Artist;
use App\ORM\Entity\Performance;
use Doctrine\ORM\EntityManager;
use GraphQL\Type\Definition\ResolveInfo;
use GraphQL\Type\Definition\Type;
use League\Event\EventDispatcher;
$driver = new Driver($entityManager);
$driver->get(EventDispatcher::class)->subscribeTo(
Artist::class . '.definition',
static function (EntityDefinition $event) use ($driver): void {
$definition = $event->getDefinition();
// In order to modify the fields you must resolve the closure
$fields = $definition['fields']();
/**
* Add a computed field to show the count of performances
* This field will only be computed when it is requested specifically
* in the query
*/
$fields['performanceCount'] = [
'type' => Type::int(),
'description' => 'The count of performances for an Artist',
'resolve' => static function (Artist $objectValue, array $args, $context, ResolveInfo $info) use ($driver): int {
$queryBuilder = $driver->get(EntityManager::class)->createQueryBuilder();
$queryBuilder
->select('COUNT(performance)')
->from(Performance::class, 'performance')
->andWhere('performance.artist = :artistId')
->setParameter('artistId', $objectValue->getId());
return (int) $queryBuilder->getQuery()->getSingleScalarResult();
},
];
// Assign modified fields array to the ArrayObject
$definition['fields'] = $fields;
}
);
A query for this computed field:
query ArtistQueryWithComputedField($id: Int!) {
artist(id: $id) {
id
name
performanceCount
}
}
This is documentation for API-Skeletons/doctrine-orm-graphql. Please add your ★ star to the project.