Building a GraphQL Server in PHP From Scratch
AI generated
8.4
PHP · GraphQL · Server
Building a GraphQL Server in PHP From Scratch
Schema, resolvers and the N+1 problem without a framework

GraphQL often looks like a finished black box in frontend tutorials, yet a working server in PHP can be built with comparatively little code. We use webonyx/graphql-php as a lightweight base for the type system and execution, define schema and resolvers by hand, and show exactly how the notorious N+1 problem arises and how it can be solved without a dedicated DataLoader library.

17 min read webonyx/graphql-php Schema Resolvers N+1 Problem

1. What from scratch concretely means here

Building a GraphQL server from scratch in this article does not mean reimplementing the GraphQL standard itself, that would be unrealistic effort for a single project. Instead we use webonyx/graphql-php as a lightweight reference implementation of the type system and execution logic, but deliberately skip a full framework like Lighthouse or API Platform, which already automates schema definition, resolver wiring, and caching.

That distinction matters for understanding: a framework generates types from annotations and wires resolvers automatically for you. With webonyx/graphql-php as a plain library, you define schema and resolvers explicitly yourself, which makes visible what a framework actually does behind the scenes, and where typical performance traps like the N+1 problem really originate.

2. Schema definition without a full framework

webonyx/graphql-php maps GraphQL types onto PHP objects, for example ObjectType for a type with named fields and their return types. Each field optionally gets a resolve function that determines how the value of that field is computed at runtime. Without that function, the library falls back to a property or method of the same name on the passed value object by default.

For a simple blog schema, you first define a PostType with fields like id, title and author, where author is itself an AuthorType. This nesting is the core of GraphQL, a client can query both the post and its author in a single request, which is later exactly the spot where the N+1 problem arises.


<?php

declare(strict_types=1);

use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Definition\Type;

$authorType = new ObjectType([
    'name' => 'Author',
    'fields' => [
        'id' => Type::nonNull(Type::id()),
        'name' => Type::nonNull(Type::string()),
    ],
]);

$postType = new ObjectType([
    'name' => 'Post',
    'fields' => [
        'id' => Type::nonNull(Type::id()),
        'title' => Type::nonNull(Type::string()),
        'author' => [
            'type' => Type::nonNull($authorType),
            'resolve' => static function (array $post) use ($authorRepository): array {
                return $authorRepository->find($post['authorId']);
            },
        ],
    ],
]);

3. Resolver functions for queries

The actual entry point of a GraphQL schema is the query type, likewise an ObjectType, whose fields represent the available top level queries. A field posts, for instance, returns a list of all posts, and its resolve function accesses a repository directly and returns an array of post data, which the PostType then processes further.

Notably, resolver functions in webonyx/graphql-php can receive four arguments: the parent field's value, the query arguments, a shared context for example for authentication, and information about the requested fields themselves. The context argument in particular is where database connections or batching structures pass cleanly through the entire resolver tree without needing global variables.


<?php

declare(strict_types=1);

use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Definition\Type;

$queryType = new ObjectType([
    'name' => 'Query',
    'fields' => [
        'posts' => [
            'type' => Type::listOf(Type::nonNull($postType)),
            'resolve' => static function ($root, array $args, GraphQlContext $context): array {
                return $context->postRepository->findAll();
            },
        ],
        'post' => [
            'type' => $postType,
            'args' => ['id' => Type::nonNull(Type::id())],
            'resolve' => static function ($root, array $args, GraphQlContext $context): ?array {
                return $context->postRepository->find($args['id']);
            },
        ],
    ],
]);

4. Assembling the server endpoint yourself

Instead of using a full framework router, a single PHP entry point is enough: it decodes the request body as JSON, extracts the query string and variables, and passes them to GraphQL::executeQuery(). That method from webonyx/graphql-php expects the assembled schema, the query string, a root value, the context, and optional variables, and returns an ExecutionResult.

The ExecutionResult is then converted to an array via toArray(), which can be returned directly as JSON to the client. It is important not to suppress errors in that output, but to control them via the appropriate HTTP status code and a debug flag that decides how much internal error detail is actually visible in production environments.


<?php

declare(strict_types=1);

use GraphQL\GraphQL;
use GraphQL\Type\Schema;

$schema = new Schema(['query' => $queryType]);

$input = json_decode(file_get_contents('php://input'), true);

$result = GraphQL::executeQuery(
    $schema,
    $input['query'] ?? '',
    null,
    new GraphQlContext($postRepository, $authorRepository),
    $input['variables'] ?? null,
);

header('Content-Type: application/json');
echo json_encode($result->toArray());

5. Showing the N+1 problem concretely

The N+1 problem arises exactly at the point where a field like author gets resolved separately for every single post object. If a client queries a list of twenty posts along with their author, the naive resolver from the first section runs one query for the list itself, followed by twenty additional individual queries, one per author, meaning twenty one instead of ideally two database round trips.

The reason is that every field resolver in GraphQL executes in isolation, without any knowledge of parallel resolvers at the same level. From the author resolver's point of view, there is no information that nineteen other author resolvers with a structurally identical request are currently active, each one makes its own uncoordinated decision to immediately run a database query.

6. A simple fix without a DataLoader library

Without a dedicated DataLoader library, the problem can be solved via manual batching by splitting execution into two phases: first, every author resolver only collects the needed authorId in a shared list on the context object, without immediately running a query, and returns a placeholder instead. After the actual execution pass, you read the collected IDs, run a single query with an IN clause, and resolve the placeholders afterward.

In practice with webonyx/graphql-php, this pattern can also be implemented in a simplified, synchronous upfront collection: instead of a true deferred pattern, the posts query resolver directly collects all authorId values of the loaded posts and preloads the matching authors in a single query into the context, so the later author resolver only reads from an already populated cache array instead of triggering its own database query.


<?php

declare(strict_types=1);

$queryType = new ObjectType([
    'name' => 'Query',
    'fields' => [
        'posts' => [
            'type' => Type::listOf(Type::nonNull($postType)),
            'resolve' => static function ($root, array $args, GraphQlContext $context): array {
                $posts = $context->postRepository->findAll();

                // Collect all required author IDs upfront and load
                // them in ONE query, instead of one per post later
                $authorIds = array_unique(array_column($posts, 'authorId'));
                $context->authorCache = $context->authorRepository->findByIds($authorIds);

                return $posts;
            },
        ],
    ],
]);

// The author resolver only reads from the already populated cache
'author' => [
    'type' => Type::nonNull($authorType),
    'resolve' => static function (array $post, array $args, GraphQlContext $context): array {
        return $context->authorCache[$post['authorId']];
    },
],

7. Adding mutations and input types

Alongside queries, GraphQL defines mutations for write operations, technically also an ObjectType, whose fields trigger changes to the data. To validate complex input data cleanly, you use InputObjectType instead of a plain ObjectType, since GraphQL deliberately separates both directions and input types are not allowed to have resolve functions.

A createPost mutation therefore receives a structured CreatePostInput with fields like title and authorId, validates them through GraphQL's built in type checking, and passes the data on to a service that performs the actual write operation. Invalid input, such as a missing required field, gets caught by the type system itself before ever reaching the resolver.

8. Error handling and error formatting

GraphQL differs from REST in that a response usually comes back with HTTP status 200 even on error, errors are instead reported in a dedicated errors array next to the regular data field. webonyx/graphql-php automatically collects every exception thrown during execution, as long as it is signaled via the Error class or a subclass of it.

For production environments, you should register a custom error formatting function that does not pass internal exception messages and stack traces through unchanged, but returns generic yet unambiguous error codes instead. Debug information can be enabled via an explicit debug flag only in development environments, never by default in production.

9. Where this differs from a full REST API

A complete REST API architecture with resources, HTTP verbs and status codes is a separate topic with its own design questions, such as versioning through URL paths or HATEOAS links. GraphQL solves a different problem: it lets clients fetch exactly the fields they need across multiple nested resources in a single request, instead of calling several REST endpoints sequentially or living with overfetching.

The from scratch build shown here suits internal tools, admin backends, or small microservices, where full control over schema and resolvers matters more than tooling like automatic persisted queries or built in rate limiting. For public, high traffic APIs, mature solutions like API Platform with GraphQL support or a dedicated GraphQL gateway are usually the more robust choice.

Aspect Pure from scratch with webonyx Full framework like API Platform REST API for comparison
Schema definition Manual via PHP objects Generated from annotations/attributes None, resources instead of a schema
N+1 control Manual batching required Often built in DataLoader support Not applicable, classic overfetching instead
Learning curve High, the mechanism stays visible Lower, but more of a black box Low, a widely known pattern
Control Complete Limited by framework conventions Complete, but a different model
Fit Internal tools, small services Public, growing APIs Public APIs with simple resources

Mironsoft

PHP modernization, code quality, and legacy refactoring

Grown PHP code nobody wants to touch anymore?

We modernize PHP codebases to current language standards, introduce static analysis and coding standards, and refactor legacy code step by step without endangering live operations.

Legacy Refactoring

Modernize grown PHP code in a structured, low-risk way.

Establishing Code Quality

Anchor PHPStan, coding standards, and CI checks sustainably in the team.

Version Upgrades

Plan and execute PHP major version upgrades safely, without downtime.

10. Summary

GraphQL Server in PHP: The Essentials at a Glance

Lightweight base

webonyx/graphql-php provides the type system and execution without automating schema and resolvers.

Resolver context

Database access and batching structures pass cleanly through the resolver tree via the context argument.

Spotting N+1

Isolated field resolvers with no knowledge of parallel calls produce the classic N+1 pattern.

Manual batching

Collecting IDs upfront and running one IN query solves the problem even without a DataLoader library.

11. FAQ: GraphQL Server in PHP: The Essentials at a Glance

1Is webonyx/graphql-php a complete GraphQL framework?
No, it is a library for the type system and execution. Schema and resolvers are defined explicitly, a framework like API Platform adds automation on top.
2Why does the N+1 problem happen in the first place?
Because every field resolver executes in isolation with no knowledge of parallel resolvers at the same level, so each one triggers its own database query.
3Do I strictly need a DataLoader library?
No, for many use cases manual batching by collecting IDs upfront and running a single query with an IN clause is enough, as shown in the article.
4How does a GraphQL mutation differ from a query?
Technically both are ObjectType definitions, but mutations trigger write operations and typically use InputObjectType for structured input data.
5Does GraphQL return a different HTTP status than 200 on errors?
Usually not, errors are instead reported in a dedicated errors array of the JSON response while the HTTP status typically stays 200.
6How do I prevent internal error messages from reaching clients?
Through a custom error formatting function in webonyx/graphql-php that only passes exception details through in debug mode of a development environment, never by default in production.
7When is a GraphQL from scratch build worth it over a full framework?
For internal tools, admin backends, and small microservices, where full control over schema and resolvers matters more than automated tooling.
8Does GraphQL fully replace a REST API?
Not necessarily, both solve different problems. GraphQL shines at flexible, nested queries, REST often remains the more pragmatic choice for simple resources.
9Where do I store database connections for resolvers?
Most cleanly in the shared context object that GraphQL::executeQuery() receives as a parameter and that gets passed through the entire resolver tree.
10Does manual batching scale for deeply nested schemas too?
With very deep nesting, manual batching quickly becomes hard to manage, at that point switching to a dedicated DataLoader library with a generic deferred pattern pays off.