How to set up a GraphQL server with Symfony 4?

Sylvain Fabre
AssoConnect
Published in
4 min readSep 8, 2018

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 entity
  • Overblog\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 field
  • getEmail() resolves the email field of the Author type. We’ll see later how the Author is passed as an argument
  • getPosts() resolves the posts 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 both email and posts with this Doctrine entity as argument. __invoke will resume the resolution process with getEmail() and getPosts() methods
  • getEmail() will return the email
  • getPosts() 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 API
  • AuthorStatsService 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.

--

--

Sylvain Fabre
AssoConnect

Happy CTO at https://www.assoconnect.com a SaaS product to help non-profits focus on their missions. We’re hiring!