How to set up a GraphQL server with Symfony 4?
We set up a GraphQL server at AssoConnect.com using Symfony 4 which is not a trivial task if you want to go further than all the “Hello world” examples out there and written for Node.js.
By the end of the article, you will have a GraphQL server to fetch data coming from:
- properties of a Doctrine entity
- properties of Doctrine entities associated with another queried entity
- an autowired service (can be a third-party one)
You must be familiar with Symfony 4 autowired services, Doctrine ORM, Relay collections and have a working GraphQL server.
Let’s say we are building a blog so we have the following entities:
- Author
- Post
With a very simple association:
- an Author has multiple Posts
- a Post has a single Author
We are using the OverBlog/GraphQL bundle with Symfony 4. Installation is done with composer: composer require overblog/graphql-bundle
For each object, we have to define:
- A Doctrine entity in /src/Entity folder
- A GraphQL type in the /config/graphql/types folder
- A GraphQL resolver service in /src/GraphQL/Resolver
So first the Author:
#/src/Entity/Author.php
<?php
namespace App\Entity;use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;/**
* @ORM\Entity
*/
Class Author
{ public function __construct()
{
parent::__construct();
$this->posts = new ArrayCollection();
} /**
* @ORM\Column(type="email")
*/
protected $email; public function getEmail() :?string
{
return $this->email;
} /**
* @ORM\OneToMany(targetEntity="Post", mappedBy="author")
*/
protected $posts; public function getPosts() :Collection
{
return $this->posts;
}}
We recommend using ramsey/uuid-doctrine for UUID identifiers and assoconnect/doctrine-validator-bundle for data validation.
The Author type is defined in a yaml file according to OverBlog bundle documentation:
#/config/graphql/types/Author.types.yaml
Author:
type: object
config:
resolveField: '@=resolver("App\\GraphQL\\Resolver\\AuthorResolver", [info, value, args])'
fields:
email:
type: String posts:
type: PostConnection
argsBuilder: Relay::ForwardConnection
The most important line in the previous code is the resolveField
option: we are configuring the GraphQL server to invoke the class App\GraphQL\Resolver\AuthorResolver
to resolve all the defined properties (ie email and posts). GraphQL will then call the magic method __invoke
with the following arguments:
GraphQL\Type\Definition\ResolveInfo $info
contains the name of the field being resolved$value
is the current object ie the Author Doctrine entityOverblog\GraphQLBundle\Definition\Argument $args
contains the arguments passed in the query (useful for connections — GraphQL counterpart of Doctrine associations)
#/src/GraphQL/Resolver/AuthorResolver.php
<?php
namespace App\GraphQL\Resolver;use App\Entity\Author;
use Doctrine\ORM\EntityManagerInterface;
use GraphQL\Type\Definition\ResolveInfo;
use Overblog\GraphQLBundle\Definition\Argument;
use Overblog\GraphQLBundle\Definition\Resolver\ResolverInterface;
use Overblog\GraphQLBundle\Relay\Connection\Output\Connection;
use Overblog\GraphQLBundle\Relay\Connection\Paginator;Class AuthorResolver implements ResolverInterface
{ /**
* @var EntityManagerInterface
*/
protected $em; public function __construct(EntityManagerInterface $em)
{
$this->em = $em;
} public function __invoke(ResolveInfo $info, $value, Argument $args)
{
$method = $info->fieldName;
return $this->$method($value, $args);
} public function resolve(string $id) :Author
{
return $this->em->find(Author::class, $id);
} public function email(Author $author) :string
{
return $author->getEmail();
} public function posts(Author $author, Argument $args) :Connection
{
$posts = $author->getPosts(); $paginator = new Paginator(function ($offset, $limit) use ($posts) {
return array_slice($posts, $offset, $limit ?? 10);
}); return $paginator->auto($args, count($posts)); }}
Let’s review this class:
__construct()
uses Symfony autowiring feature to inject dependencies__invoke()
will be called by GraphQL and will then call a dedicated method to resolve the fieldgetEmail()
resolves theemail
field of the Author type. We’ll see later how the Author is passed as an argumentgetPosts()
resolves theposts
field. It is a collection of Posts and the bundle provides a Paginator to quickly implement the Relay feature. We use two arguments: Author as the previous method, and$args
which contains the pagination requirements of the query (Overblog doc)
I will not describe the code for Post but it’s very similar. Instead I will focus on how to query for an Author with its id, and how GraphQL calls the field methods.
You don’t have to expose all the properties of the Doctrine entity: only make resolvable the ones that make sense for your API. For instance, a property containing a hashed-password must stay private!
Lastly we need to define the PostConnection
GraphQL type. The documentation is available at https://github.com/overblog/GraphQLBundle/blob/master/docs/definitions/relay/connection.md
#/config/graphql/types/PostConnection.types.yaml
PostConnection:
type: relay-connection
config:
nodeType: Post!
The next part is about how to get the $author
argument and how to fetch an author using its id.
GraphQL uses two default types: Query and Mutation. You have to define them in a yaml file too.
#/config/graphql/types/Query.types.yaml
Query:
type: object
config:
fields:
author:
type: Author
args:
id:
type: Int
resolve: '@=resolver("App\\GraphQL\\Resolver\\AuthorResolver::resolve", [args["id"]])'
So let’s make this query to fetch Author #123, its email and his or her 10 first posts title:
query {
author(id: 123){
email
posts (first: 10){
edges {
cursor
node {
title
}
}
}
}
}
The following steps will happen:
- GraphQL will call
App\GraphQL\Resolver\AuthorResolver::resolve(123)
which will returns the associated Author Doctrine entity - GraphQL will call
App\GraphQL\Resolver\AuthorResolver::__invoke()
for bothemail
andposts
with this Doctrine entity as argument.__invoke
will resume the resolution process withgetEmail()
andgetPosts()
methods getEmail()
will return the emailgetPosts()
will return a Relay connection wrapping the 10 first Post entities- GraphQL will then call
App\GraphQL\Resolver\PostResolver::__invoke()
with each of these Post entities as argument.
Some data is not stored in a database column but requires some computation. Let’s say we want to fetch some stats about the Author provided by an AuthorStatsService
to respect the Single Responsibility Principle:
AuthorResolver
fetches the data to expose through the APIAuthorStatsService
computes stats
We first add a new field to our Author type:
#/config/graphql/types/Author.types.yaml
Author:
type: object
config:
fields:
popularityScore:
type: Int!
Then we add a getPopularityScore()
method to our resolver using the AuthorStatsService
to get the actual score. This service is autowired in the constructor and used in the dedicated method to resolve this new field.
#/src/GraphQL/Resolver/AuthorResolver.phpuse App\Service\AuthorStatsService;Class Author
{
/**
* @var AuthorStatsService
*/
protected $authorStatsService; public function __construct(EntityManagerInterface $em, AuthorStatsService $authorStatsService)
{
$this->em = $em;
$this->authorStatsService;
} public function getPopularityScore(Author $author) :int
{
return $this->authorStatsService->computePopularityScore($author);
}
We now have a working GraphQL server fetching data :
- from an entity identified with its id
- from entities associated with another entity
- from services autowired in our resolvers
Field and type access control can be implemented with the bundle as described in the documentation. At AssoConnect we have set up another mechanism based on Symfony controller security and this will be discussed in another article.