use GraphqlOrm\Mapping as GraphqlOrm;
#[GraphqlOrm\Entity(name: 'tasks', repositoryClass: TaskRepository::class)]
class Task
{
#[GraphqlOrm\Field(mappedFrom: 'id')]
#[GraphqlOrm\Identifier]
public int $id;
#[GraphqlOrm\Field(mappedFrom: 'title')]
public string $title;
#[GraphqlOrm\Field(mappedFrom: 'dueDate')]
public ?DateTimeImmutable $dueDate = null;
#[GraphqlOrm\Relation(mappedFrom: 'user')]
public ?User $user = null;
}The attributes are imported through a namespace alias, as with Doctrine use Doctrine\ORM\Mapping as ORM;: a class
mapped with Doctrine too can hold both Entity attributes.
| Parameter | Type | Description |
|---|---|---|
name |
string |
GraphQL root query field name (e.g. tasks) |
repositoryClass |
class-string|null |
Associated repository class |
manager |
string|null |
The manager of its API, when not the one whose entity namespace holds it, see several APIs |
type |
string|null |
The GraphQL type of the entity (e.g. Task), naming its mutations; the short name of the class by default |
#[GraphqlOrm\Cache] on the class keeps its entities in the second-level cache.
A field holding a value: a scalar, a date, an enum, or a value object given by a type converter.
| Parameter | Type | Description |
|---|---|---|
mappedFrom |
string|null |
Field name in the GraphQL schema; named by the naming strategy when omitted |
ignoreValidation |
bool |
Ignore the schema validation |
converter |
class-string|null |
The type converter of the value, rather than the one of the type of the property |
Marks the field identifying the entity, declared with #[GraphqlOrm\Field], as Doctrine #[Id]. find(), the identity
map, the lazy relations and the form type need it. An entity has one at most: a second one throws a
TooManyIdentifiersException, and #[GraphqlOrm\Identifier] without #[GraphqlOrm\Field] an
InvalidArgumentException.
#[GraphqlOrm\Field(mappedFrom: 'id')]
#[GraphqlOrm\Identifier]
public int $id;A relation to another entity, or a collection of them, as Doctrine #[ManyToOne] and #[OneToMany]: it replaces
#[GraphqlOrm\Field] on the property.
| Parameter | Type | Description |
|---|---|---|
mappedFrom |
string|null |
Field name in the GraphQL schema; named by the naming strategy when omitted |
targetEntity |
class-string|null |
The related entity, when the type of the property does not tell it |
fetch |
Fetch |
GraphqlOrm\Fetch::Eager (default) or GraphqlOrm\Fetch::Lazy, see lazy relations |
ignoreValidation |
bool |
Ignore the schema validation |
joinField |
string|null |
The field of the mutations holding the identifier of the related entity (e.g. user_id), see writing relations |
A property mapped with both #[GraphqlOrm\Field] and #[GraphqlOrm\Relation], a #[GraphqlOrm\Relation] whose type is
no entity, or a #[GraphqlOrm\Field] holding an entity, throws an InvalidArgumentException.
A relation is selected with its entity, up to max_depth. One read rarely, or heavy, can be fetched lazily, as with
Doctrine fetch: LAZY:
use GraphqlOrm\Mapping as GraphqlOrm;
#[GraphqlOrm\Relation(mappedFrom: 'user', fetch: GraphqlOrm\Fetch::Lazy)]
public ?User $user = null;
/** @var Task[] */
#[GraphqlOrm\Relation(mappedFrom: 'tasks', fetch: GraphqlOrm\Fetch::Lazy)]
public array $tasks = [];- The relation is selected by its identifier only (
user { id }), whatevermax_depth, and hydrated as a PHP lazy ghost: reading its identifier costs nothing, the first access to another property loads it. - The ghosts of a response are loaded together: one query for the users of all the tasks, when the dialect filters on a list (Data API Builder, Hasura).
- A collection is selected by the identifiers of its items, each item being a ghost: a PHP array cannot be lazy itself.
- An explicit
select('user.name'), orwith('user'), still loads the relation with the query. - It needs no
lazy_loading, which only applies to the relations beyondmax_depth.
mappedFrom can be left out: the field is then named from its property by the naming strategy of the bundle, as
Doctrine naming_strategy does for columns. An explicit mappedFrom always wins.
| Strategy | dueDate is named |
Suits |
|---|---|---|
DefaultNamingStrategy |
dueDate |
camelCase APIs (PostGraphile, most GraphQL APIs) |
SnakeCaseNamingStrategy |
due_date |
APIs exposing database columns (Hasura, Data API Builder) |
# config/packages/graphql_orm.yaml
graphql_orm:
naming_strategy: GraphqlOrm\Metadata\Naming\SnakeCaseNamingStrategy#[GraphqlOrm\Entity(name: 'tasks')]
class Task
{
#[GraphqlOrm\Field]
#[GraphqlOrm\Identifier]
public int $id;
#[GraphqlOrm\Field] // due_date
public ?DateTimeImmutable $dueDate = null;
#[GraphqlOrm\Field(mappedFrom: 'deadline_at')]
public ?DateTimeImmutable $deadline = null;
}naming_strategy is a service id: implement NamingStrategy for another convention. With the
PHPStan rules, set the same class in the graphqlOrm.namingStrategy parameter. After changing the
strategy, clear the cache (cache:clear), which holds the metadata outside debug mode.
The related entity is read from the PHP type of the property:
// From the type
#[GraphqlOrm\Relation(mappedFrom: 'user')]
public User $user;
// Explicit declaration
#[GraphqlOrm\Relation(mappedFrom: 'user', targetEntity: User::class)]
public mixed $user;
// Collection: the item type is read from the docblock (`Task[]`, `list<Task>`, `array<int, Task>`)
/** @var Task[] */
#[GraphqlOrm\Relation(mappedFrom: 'tasks')]
public array $tasks = [];
// Collection with an explicit target
#[GraphqlOrm\Relation(mappedFrom: 'tasks', targetEntity: Task::class)]
public array $tasks = [];With doctrine/collections installed, a collection can be typed Collection (or ReadableCollection,
ArrayCollection): it is hydrated as an ArrayCollection, its item type read from the generic type of the docblock.
The array properties stay arrays.
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
/** @var Collection<int, Task> */
#[GraphqlOrm\Relation]
public Collection $tasks;
$project->tasks->filter(fn (Task $task) => !$task->done)->count();It works with GraphqlOrm\Fetch::Lazy as an array does, and the serializer writes it as a list. The
results of findBy() and of the query builder stay arrays, as with Doctrine.
An array mapped with #[GraphqlOrm\Field] is a plain array (e.g. a list of scalars such as /** @var string[] */).
An untyped property is hydrated with the raw value.
The attributes #[GraphqlEntity] and #[GraphqlField] of 4.8 are removed in 5.0: see
upgrading from 4.x for their equivalents.
| PHP type | GraphQL scalars |
|---|---|
int |
Int, ID, Byte, Short, Long, bigint, smallint, integer |
float |
Float, Single, Decimal, numeric, float8, float4 |
string |
String, ID, Time, JSON, UUID, uuid, text, and the date scalars |
bool |
Boolean |
DateTimeImmutable |
DateTime, Datetime, Date, timestamptz, timestamp, date, Time, String |
DateTime, DateTimeInterface |
Same as DateTimeImmutable (DateTimeInterface gets a DateTimeImmutable) |
BackedEnum |
Built with ::from(); an unknown value throws CastException |
The lowercase scalars are those of Hasura and Data API Builder. The schema validation checks the
scalars of the int, float, string, bool and DateTimeImmutable properties.
Date fields are automatically cast from ISO 8601 strings or Unix timestamps (in milliseconds); a value that is no date
throws a CastException.
The bundle restores the state the API has, as Doctrine does, whatever the declaration of the property:
#[GraphqlOrm\Entity(name: 'notes')]
class Note
{
#[GraphqlOrm\Field]
#[GraphqlOrm\Identifier]
public readonly int $id; // set once, by the first query returning the note
#[GraphqlOrm\Field]
public private(set) string $title; // changed by a method of the entity, written by flush()
#[GraphqlOrm\Field]
public string $code {
get => strtoupper($this->code); // the application reads "ABC", the API keeps "abc"
}
}- Readonly properties are set by the first query returning the entity; another query returning it later leaves
them as they are. A readonly property set by the application is never updated by
flush(), which cannot change it. - Asymmetric visibility (
private(set),protected(set)) is no obstacle: the hydration andflush()read and write the properties through reflection. - Hooks are bypassed: the hydration writes the value of the API without the
sethook, andflush()sends the value the property holds, not the one itsgethook returns. The hooks serve the application, the API sees the stored values. - Virtual properties, whose hooks hold no value of their own, cannot be mapped: an
InvalidArgumentExceptionasks to map the property they are computed from instead.
PHPStan reports a readonly property without constructor as uninitialized: the extension of the bundle tells it that the mapped properties are written by the hydration.
A type converter turns a scalar of the API into a value object, Money, Email, Siret, and back. It implements
TypeConverterInterface, and #[AsTypeConverter] names the class it handles:
use GraphqlOrm\Attribute\AsTypeConverter;
use GraphqlOrm\Converter\TypeConverterInterface;
#[AsTypeConverter(Money::class)]
final class MoneyConverter implements TypeConverterInterface
{
// An Int of the API, in cents
public function toPhp(mixed $value): Money
{
return Money::ofCents((int) $value);
}
public function toGraphql(mixed $value): int
{
return $value->cents;
}
}In Symfony, a service implementing TypeConverterInterface is registered by autoconfiguration, and every manager uses
it. Each property typed with its class is then converted, unless its #[GraphqlOrm\Field] names another converter:
#[GraphqlOrm\Field]
public ?Money $price = null; // MoneyConverter, by its type
#[GraphqlOrm\Field(converter: DecimalMoneyConverter::class)]
public ?Money $discount = null; // a Float of the API, as 12.5- The values of the response are converted when the entity is hydrated, from the API or the second-level cache, and for the parameters of a DTO typed with the class (the converter the entity names for the field applies to a parameter of the same type).
- The value objects of the filters,
where()andfindBy(),count()and the aggregates, are converted back, inline or as variables, lists ofin()included:$qb->expr()->eq('price', Money::ofCents(1250))sendsprice: { eq: 1250 }. So is the value keying a result withindexBy. nullis never given to a converter: it staysnull.#[AsTypeConverter(Money::class, scalars: ['Int'])]tells the GraphQL scalars the converter reads and writes: the schema validation checks the fields it converts use one of them. It reports as well a property typed with a value object that no converter converts.- A converter named by
#[GraphqlOrm\Field]without dependencies needs no registration; one needing arguments must be a service, or anInvalidArgumentExceptionis thrown when it is used. - A converter receives the value of one field: a value object spread over several fields (an amount and its currency) is an embedded value object.
- Without Symfony, the converters are given to the manager:
new GraphqlManager(..., typeConverters: new TypeConverterRegistry([new MoneyConverter()])).
A value object spread over several fields, as the embeddables of Doctrine: the class is marked
#[GraphqlOrm\Embeddable], its fields mapped with #[GraphqlOrm\Field], and the property holding it with
#[GraphqlOrm\Embedded].
use GraphqlOrm\Mapping as GraphqlOrm;
#[GraphqlOrm\Embeddable]
final readonly class Money
{
public function __construct(
#[GraphqlOrm\Field] public int $amount,
#[GraphqlOrm\Field] public string $currency,
) {
}
}
#[GraphqlOrm\Entity(name: 'orders')]
class Order
{
// A GraphQL object: price { amount currency }
#[GraphqlOrm\Embedded]
public ?Money $price = null;
// Fields of the entity itself: total_amount, total_currency
#[GraphqlOrm\Embedded(prefix: 'total_')]
public ?Money $total = null;
}#[GraphqlOrm\Embedded] |
Description |
|---|---|
mappedFrom |
GraphQL name of the object; named by the naming strategy when omitted |
prefix |
the prefix of the fields of the entity holding the value object; a GraphQL object when omitted |
- The value object is created through its constructor, the values given by the name of their property: it can be
readonly. Its fields follow the naming strategy, and take type converters. - A GraphQL object
null, or prefixed fields allnull, givenull. A selection lacking a field the constructor needs leaves the property not selected, as a partial entity. - Its fields are named by their path:
select('price.amount'),where($qb->expr()->gte('total.amount', 100))(price: { amount: … }ortotal_amount). The ordering and the criteria of the repositories, which the dialects give for fields of the entity, name the fields of prefixed value objects only:findBy(['total.currency' => 'EUR'], ['total.amount' => Direction::Desc]). - It is kept in the second-level cache with its entity, and hydrated as well for a parameter of a
DTO typed with it, or mapped to one of its fields with
#[GraphqlOrm\MapFrom('price.amount')]. - The schema validation checks its fields, the profiler lists them in the "Entities" tab, and the PHPStan rules accept their paths.
- An embedded value object holds no relation nor other embedded value object; a filter compares its fields, not the value object as a whole.
#[GraphqlOrm\Embedded]with#[GraphqlOrm\Field],#[GraphqlOrm\Relation]or#[GraphqlOrm\Identifier], or on a property whose class has no#[GraphqlOrm\Embeddable], throws anInvalidArgumentException.