GraphQL: a powerful alternative to REST
GraphQL lets clients request exactly the data they need. In projects such as those of Matalto and Manymore, this flexibility made it possible to significantly reduce the number of API requests.
With a classic REST API, each resource has its own URL and the server decides the shape of the response. A screen that displays a product, its category and its reviews often triggers three calls, each returning fields the interface does not need. GraphQL reverses the logic: the server publishes a typed schema describing everything that is available, and the client sends a query that describes precisely the shape of the expected data. There is usually a single endpoint, called with POST, and the JSON response mirrors the structure of the query exactly.
In PHP, the reference is the webonyx/graphql-php library, which implements the specification. In a Symfony application, it is rarely used directly: overblog/graphql-bundle integrates it into the framework (YAML or attribute configuration, service container, security), and API Platform can also expose a GraphQL endpoint from the same resources as the REST API. This article uses OverblogGraphQLBundle.
Installation with Symfony
composer require overblog/graphql-bundle
composer require overblog/graphiql-bundle --dev
The first package provides the GraphQL endpoint and the execution engine. The second adds GraphiQL, an in-browser IDE with schema-based autocompletion: very handy in development, but never to be deployed to production, hence the --dev option. The bundle configuration states where to find the types and which type is the root for queries:
# config/packages/graphql.yaml
overblog_graphql:
definitions:
schema:
query: Query
mappings:
types:
- type: yaml
dir: "%kernel.project_dir%/config/graphql/types"
security:
query_max_depth: 10
query_max_complexity: 1000
enable_introspection: '%kernel.debug%'
The security section is important and often forgotten; we will come back to it below.
Defining a schema
The schema is the contract between the server and its clients. Each object type declares its fields, their type and, when needed, how to resolve them. The exclamation mark means "non-null": String! guarantees the client that the field will always have a value.
# config/graphql/types/Product.types.yaml
Product:
type: object
config:
fields:
id:
type: "ID!"
name:
type: "String!"
price:
type: "Float!"
category:
type: "Category"
resolve: "@=resolver('product_category', [value])"
Simple fields (id, name, price) are read automatically from the PHP object through its getters or public properties. The category field, however, delegates to a named resolver, passing it the parent object (value). You then need a Query root type, which defines the entry points for reads and their arguments:
# config/graphql/types/Query.types.yaml
Query:
type: object
config:
fields:
product:
type: "Product"
args:
id:
type: "ID!"
resolve: "@=resolver('products', [args])"
products:
type: "[Product!]!"
args:
first:
type: "Int"
defaultValue: 10
resolve: "@=resolver('products', [args])"
Resolver
A resolver is a regular Symfony service: it receives the query arguments (or the parent object) and returns the data. Like any service, it benefits from dependency injection:
class ProductResolver implements ResolverInterface
{
public function __construct(
private ProductRepository $repository,
) {}
public function resolve(Argument $args): Product|array|null
{
if (isset($args['id'])) {
return $this->repository->find($args['id']);
}
return $this->repository->findBy([], ['id' => 'ASC'], $args['first'] ?? 10);
}
}
class ProductCategoryResolver implements ResolverInterface
{
public function resolve(Product $product): Category
{
return $product->getCategory();
}
}
For the names products and product_category used in the schema to point to these classes, declare them as aliases, for example by implementing AliasedInterface and its static getAliases() method. Also note that these examples use the bundle's historical naming: in recent versions, ResolverInterface and the resolver() expression function have been renamed QueryInterface and query(). Check the documentation of the installed version. For writes, the principle is the same with a Mutation root type and classes implementing MutationInterface.
Always cap the number of items returned (here with first), and switch to cursor-based pagination (Relay's "Connection" model, supported by the bundle) as soon as lists get long.
GraphQL query
query {
products(first: 10) {
id
name
price
category {
name
}
}
}
The response contains exactly these fields, nested the same way, under a data key. In practice, clients use named queries with variables rather than hard-coded values. This makes client-side caching easier and avoids any string concatenation:
query ProductDetail($id: ID!) {
product(id: $id) {
id
name
price
category {
name
}
}
}
Over HTTP, the query and its variables are sent as JSON in the body of a POST (the exact URL depends on the bundle's route configuration):
curl -X POST https://api.example.com/graphql \
-H 'Content-Type: application/json' \
-d '{"query": "query ProductDetail($id: ID!) { product(id: $id) { name price } }", "variables": {"id": "42"}}'
The N+1 trap
GraphQL's flexibility has a hidden cost. For the products(first: 10) query above, the category resolver is called once per product: one SQL query for the list, then ten queries for the categories if the relation is lazy-loaded. With nested lists, this quickly explodes. Two complementary solutions exist:
- Load the most requested relations directly in the repository, with a join and an
addSelect. - Use the DataLoader pattern (via
overblog/dataloader-bundle): the identifiers requested during execution are collected, then loaded in a single batched query.
Monitor the number of SQL queries per GraphQL operation in the Symfony profiler: it is the most reliable indicator.
Securing a GraphQL API
An endpoint that accepts arbitrary queries must protect itself against abuse. A malicious client can send a very deep or very wide query that exhausts the server. The query_max_depth and query_max_complexity settings in the configuration reject such queries before execution. Introspection, which lets anyone download the full schema, is limited to debug mode here. Finally, authorization is declared field by field:
# Type excerpt: field restricted to administrators
Product:
type: object
config:
fields:
purchasePrice:
type: "Float"
access: "@=hasRole('ROLE_ADMIN')"
Another difference from REST: a GraphQL request usually returns HTTP status 200 even when something goes wrong, with errors listed under the errors key of the response. Your monitoring must therefore inspect response bodies, not just HTTP codes.
Advantages over REST
- No over-fetching: the client chooses the fields
- No under-fetching: a single request for related data
- Strong typing: a self-documenting schema
- Easy evolution: add fields without breaking existing clients
To evolve the schema without versioning the API, add fields rather than changing them, and mark old ones with deprecationReason before removing them once no client uses them any more.
When to stick with REST
GraphQL is not a universal replacement. Standard HTTP caching (CDN, reverse proxy) works poorly with POST requests all sent to the same URL. File uploads require an extension to the specification. And for a simple public API consumed by third parties, a REST API documented with OpenAPI remains more familiar. GraphQL shines mostly when several clients (web, mobile) have different needs over a rich, highly connected data model.
In short: define a clear schema, cap lists, hunt down N+1 from the very first queries, limit depth and complexity, and disable introspection and GraphiQL in production.