A library to declare GraphQL types from Doctrine entities, PHP type hinting, and attributes, and to be used with webonyx/graphql-php.
It reads most information from type hints, complete some things from existing
Doctrine attributes and allow further customizations with specialized attributes.
It will then create ObjectType
and
InputObjectType
instances with fields for all getter and setter respectively found on Doctrine entities.
It will not build the entire schema. It is up to the user to use automated types, and other custom types, to define root queries.
Install the library via composer:
composer require ecodev/graphql-doctrine
And start using it:
<?php
use GraphQLTests\Doctrine\Blog\Model\Post;
use GraphQLTests\Doctrine\Blog\Types\DateTimeType;
use GraphQLTests\Doctrine\Blog\Types\PostStatusType;
use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Definition\Type;
use GraphQL\Type\Schema;
use GraphQL\Doctrine\DefaultFieldResolver;
use GraphQL\Doctrine\Types;
use Laminas\ServiceManager\ServiceManager;
// Define custom types with a PSR-11 container
$customTypes = new ServiceManager([
'invokables' => [
DateTimeImmutable::class => DateTimeType::class,
'PostStatus' => PostStatusType::class,
],
'aliases' => [
'datetime_immutable' => DateTimeImmutable::class, // Declare alias for Doctrine type to be used for filters
],
]);
// Configure the type registry
$types = new Types($entityManager, $customTypes);
// Configure default field resolver to be able to use getters
GraphQL::setDefaultFieldResolver(new DefaultFieldResolver());
// Build your Schema
$schema = new Schema([
'query' => new ObjectType([
'name' => 'query',
'fields' => [
'posts' => [
'type' => Type::listOf($types->getOutput(Post::class)), // Use automated ObjectType for output
'args' => [
[
'name' => 'filter',
'type' => $types->getFilter(Post::class), // Use automated filtering options
],
[
'name' => 'sorting',
'type' => $types->getSorting(Post::class), // Use automated sorting options
],
],
'resolve' => function ($root, $args) use ($types): void {
$queryBuilder = $types->createFilteredQueryBuilder(Post::class, $args['filter'] ?? [], $args['sorting'] ?? []);
// execute query...
},
],
],
]),
'mutation' => new ObjectType([
'name' => 'mutation',
'fields' => [
'createPost' => [
'type' => Type::nonNull($types->getOutput(Post::class)),
'args' => [
'input' => Type::nonNull($types->getInput(Post::class)), // Use automated InputObjectType for input
],
'resolve' => function ($root, $args): void {
// create new post and flush...
},
],
'updatePost' => [
'type' => Type::nonNull($types->getOutput(Post::class)),
'args' => [
'id' => Type::nonNull(Type::id()), // Use standard API when needed
'input' => $types->getPartialInput(Post::class), // Use automated InputObjectType for partial input for updates
],
'resolve' => function ($root, $args): void {
// update existing post and flush...
},
],
],
]),
]);
The public API is limited to the public methods on TypesInterface
, Types
's constructor, and the attributes.
Here is a quick overview of TypesInterface
:
$types->get()
to get custom types$types->getOutput()
to get anObjectType
to be used in queries$types->getFilter()
to get anInputObjectType
to be used in queries$types->getSorting()
to get anInputObjectType
to be used in queries$types->getInput()
to get anInputObjectType
to be used in mutations (typically for creation)$types->getPartialInput()
to get anInputObjectType
to be used in mutations (typically for update)$types->getId()
to get anEntityIDType
which may be used to receive an object from database instead of a scalar$types->has()
to check whether a type exists$types->createFilteredQueryBuilder()
to be used in query resolvers
To avoid code duplication as much as possible, information are gathered from several places, where available. And each of those might be overridden. The order of priority, from the least to most important is:
- Type hinting
- Doc blocks
- Attributes
That means it is always possible to override everything with attributes. But existing type hints and dock blocks should cover the majority of cases.
All getters, and setters, are included by default in the type. And all properties are included in the filters. But it can be specified otherwise for each method and property.
To exclude a sensitive field from ever being exposed through the API, use #[API\Exclude]
:
use GraphQL\Doctrine\Attribute as API;
/**
* Returns the hashed password
*
* @return string
*/
#[API\Exclude]
public function getPassword(): string
{
return $this->password;
}
And to exclude a property from being exposed as a filter:
use GraphQL\Doctrine\Attribute as API;
#[ORM\Column(name: 'password', type: 'string', length: 255)]
#[API\Exclude]
private string $password = '';
Even if a getter returns a PHP scalar type, such as string
, it might be preferable
to override the type with a custom GraphQL type. This is typically useful for enum
or other validation purposes, such as email address. This is done by specifying the
GraphQL type FQCN via #[API\Field]
attribute:
use GraphQL\Doctrine\Attribute as API;
use GraphQLTests\Doctrine\Blog\Types\PostStatusType;
/**
* Get status
*
* @return string
*/
#[API\Field(type: PostStatusType::class)]
public function getStatus(): string
{
return $this->status;
}
In most cases, the type must use the ::class
notation to specify the PHP class that is either implementing the GraphQL
type or the entity itself (see limitations). Use string literals only if you must define it as nullable
and/or as an array. Never use the short name of an entity (it is only possible for user-defined custom types).
Supported syntaxes (PHP style or GraphQL style) are:
MyType::class
'?Application\MyType'
'null|Application\MyType'
'Application\MyType|null'
'Application\MyType[]'
'?Application\MyType[]'
'null|Application\MyType[]'
'Application\MyType[]|null'
'Collection<Application\MyType>'
This attribute can be used to override other things, such as name
, description
and args
.
Similarly to #[API\Field]
, #[API\Argument]
allows to override the type of argument
if the PHP type hint is not enough:
use GraphQL\Doctrine\Attribute as API;
/**
* Returns all posts of the specified status
*
* @param string $status the status of posts as defined in \GraphQLTests\Doctrine\Blog\Model\Post
*
* @return Collection
*/
public function getPosts(
#[API\Argument(type: '?GraphQLTests\Doctrine\Blog\Types\PostStatusType')]
?string $status = Post::STATUS_PUBLIC
): Collection
{
// ...
}
Once again, it also allows to override other things such as name
, description
and defaultValue
.
#[API\Input]
is the opposite of #[API\Field]
and can be used to override things for
input types (setters), typically for validations purpose. This would look like:
use GraphQL\Doctrine\Attribute as API;
use GraphQLTests\Doctrine\Blog\Types\PostStatusType;
/**
* Set status
*
* @param string $status
*/
#[API\Input(type: PostStatusType::class)]
public function setStatus(string $status = self::STATUS_PUBLIC): void
{
$this->status = $status;
}
This attribute also supports description
, and defaultValue
.
#[API\FilterGroupCondition]
is the equivalent for filters that are generated from properties.
So usage would be like:
use GraphQL\Doctrine\Attribute as API;
#[API\FilterGroupCondition(type: '?GraphQLTests\Doctrine\Blog\Types\PostStatusType')]
#[ORM\Column(type: 'string', options: ['default' => self::STATUS_PRIVATE])]
private string $status = self::STATUS_PRIVATE;
An important thing to note is that the value of the type specified will be directly used in DQL. That means
that if the value is not a PHP scalar, then it must be convertible to string via __toString()
, or you have to
do the conversion yourself before passing the filter values to Types::createFilteredQueryBuilder()
.
By default, all PHP scalar types and Doctrine collection are automatically detected
and mapped to a GraphQL type. However, if some getter return custom types, such
as DateTimeImmutable
, or a custom class, then it will have to be configured beforehand.
The configuration is done with a PSR-11 container implementation configured according to your needs. In the following example, we use laminas/laminas-servicemanager, because it offers useful concepts such as: invokables, aliases, factories and abstract factories. But any other PSR-11 container implementation could be used instead.
The keys should be the whatever you use to refer to the type in your model. Typically,
that would be either the FQCN of a PHP class "native" type such as DateTimeImmutable
, or the
FQCN of a PHP class implementing the GraphQL type, or directly the GraphQL type name:
$customTypes = new ServiceManager([
'invokables' => [
DateTimeImmutable::class => DateTimeType::class,
'PostStatus' => PostStatusType::class,
],
]);
$types = new Types($entityManager, $customTypes);
// Build schema...
That way it is not necessary to annotate every single getter returning one of the configured type. It will be mapped automatically.
If a getter takes an entity as parameter, then a specialized InputType
will
be created automatically to accept an ID
. The entity will then be automatically
fetched from the database and forwarded to the getter. So this will work out of
the box:
public function isAllowedEditing(User $user): bool
{
return $this->getUser() === $user;
}
You may also get an input type for an entity by using Types::getId()
to write
things like:
[
// ...
'args' => [
'id' => $types->getId(Post::class),
],
'resolve' => function ($root, array $args) {
$post = $args['id']->getEntity();
// ...
},
]
In addition to normal input types, it is possible to get a partial input type via
getPartialInput()
. This is especially useful for mutations that update existing
entities, when we do not want to have to re-submit all fields. By using a partial
input, the API client is able to submit only the fields that need to be updated
and nothing more.
This potentially reduces network traffic, because the client does not need to fetch all fields just to be able re-submit them when he wants to modify only one field.
And it also enables to easily design mass editing mutations where the client would submit only a few fields to be updated for many entities at once. This could look like:
<?php
$mutations = [
'updatePosts' => [
'type' => Type::nonNull(Type::listOf(Type::nonNull($types->get(Post::class)))),
'args' => [
'ids' => Type::nonNull(Type::listOf(Type::nonNull(Type::id()))),
'input' => $types->getPartialInput(Post::class), // Use automated InputObjectType for partial input for updates
],
'resolve' => function ($root, $args) {
// update existing posts and flush...
}
],
];
Default values are automatically detected from arguments for getters, as seen in
getPosts()
example above.
For setters, the default value will be looked up on the mapped property, if there is one matching the setter name. But if the setter itself has an argument with a default value, it will take precedence.
So the following will make an input type with an optional field name
with a
default value john
, an optional field foo
with a default value defaultFoo
and
a mandatory field bar
without any default value:
#[ORM\Column(type: 'string']
private $name = 'jane';
public function setName(string $name = 'john'): void
{
$this->name = $name;
}
public function setFoo(string $foo = 'defaultFoo'): void
{
// do something
}
public function setBar(string $bar): void
{
// do something
}
It is possible to expose generic filtering for entity fields and their types to let users easily create and apply generic filters. This expose basic SQL-like syntax that should cover most simple cases.
Filters are structured in an ordered list of groups. Each group contains an unordered set of joins
and conditions on fields. For simple cases a single group of a few conditions would probably be enough.
But the ordered list of group allow more advanced filtering with OR
logic between a set of conditions.
In the case of the Post
class, it would generate that GraphQL schema
for filtering, and for sorting it would be that simpler schema.
For concrete examples of possibilities and variables syntax, refer to the test cases.
For security and complexity reasons, it is not meant to solve advanced use cases. For those it is possible to write custom filters and sorting.
A custom filer must extend AbstractOperator
. This will allow to define custom arguments for
the API, and then a method to build the DQL condition corresponding to the argument.
This would also allow to filter on joined relations by carefully adding joins when necessary.
Then a custom filter might be used like so:
use Doctrine\ORM\Mapping as ORM;
use GraphQL\Doctrine\Attribute as API;
use GraphQLTests\Doctrine\Blog\Filtering\SearchOperatorType;
/**
* A blog post with title and body
*/
#[ORM\Entity]
#[API\Filter(field: 'custom', operator: SearchOperatorType::class, type: 'string')]
final class Post extends AbstractModel
A custom sorting option must implement SortingInterface
. The constructor has no arguments and
the __invoke()
must define how to apply the sorting.
Similarly to custom filters, it may be possible to carefully add joins if necessary.
Then a custom sorting might be used like so:
use Doctrine\ORM\Mapping as ORM;
use GraphQL\Doctrine\Attribute as API;
use GraphQLTests\Doctrine\Blog\Sorting\UserName;
/**
* A blog post with title and body
*/
#[ORM\Entity]
#[API\Sorting([UserName::class])]
final class Post extends AbstractModel
The use
statement is not supported. So types in attributes or doc blocks must
be the FQCN, or the name of a user-defined custom types (but never the short name of an entity).
Entities with composite identifiers are not supported for automatic creation of input types. Possible workarounds are to change input argument to be something else than an entity, write custom input types and use them via attributes, or adapt the database schema.
Logical operators support only two levels, and second level cannot mix logic operators. In SQL that would mean only one level of parentheses. So you can generate SQL that would look like:
-- mixed top level
WHERE cond1 AND cond2 OR cond3 AND ...
-- mixed top level and non-mixed sublevels
WHERE cond1 OR (cond2 OR cond3 OR ...) AND (cond4 AND cond5 AND ...) OR ...
But you cannot generate SQL that would like that:
-- mixed sublevels does NOT work
WHERE cond1 AND (cond2 OR cond3 AND cond4) AND ...
-- more than two levels will NOT work
WHERE cond1 OR (cond2 AND (cond3 OR cond4)) OR ...
Those cases would probably end up being too complex to handle on the client-side. And we recommend instead to implement them as a custom filter on the server side, in order to hide complexity from the client and benefit from Doctrine's QueryBuilder full flexibility.
Out of the box, it is not possible to sort by a field from a joined relation. This should be done via a custom sorting to ensure that joins are done properly.
Doctrine GraphQL Mapper has been an inspiration to write this package. While the goals are similar, the way it works is different. In Doctrine GraphQL Mapper, attributes are spread between properties and methods (and classes for filtering), but we work only on methods. Setup seems slightly more complex, but might be more flexible. We built on conventions and widespread use of PHP type hinting to have an easier out-of-the-box experience.