GraphQL

Serving GraphQL Over HTTP With PHP

The GraphQL spec defines the language and execution; a companion convention (the GraphQL-over-HTTP specification) defines how requests travel over HTTP. This lesson covers that transport contract and then builds a small working server in PHP with webonyx/graphql-php.

The HTTP Contract

One endpoint. A GraphQL API lives at a single URL, conventionally /graphql. Queries, mutations — everything — go through it. This is the sharpest contrast with REST, where the URL space is the API.

POST is the workhorse. The standard request is a POST with a JSON body:

{
  "query": "query ArtistPage($slug: String!) { artist(slug: $slug) { name } }",
  "variables": { "slug": "nova-tide" },
  "operationName": "ArtistPage"
}

variables and operationName are optional (operationName is required only when the document holds multiple operations).

GET is allowed for queries only. The document travels as a query URL parameter with variables as URL-encoded JSON. GET enables URL-level caching but runs into URL length limits, and mutations over GET are forbidden — GET must stay safe and idempotent, the same HTTP discipline as everywhere else.

Status codes work differently than REST. A request that executes — even one where every field errored — returns 200 with the data/errors body; the GraphQL errors array, not the status code, carries field failures. Non-200 codes are reserved for requests that never executed: 400 for unparseable or invalid documents, 401/403 for transport-level auth, 405 for wrong methods, 500 for a crashed server. Clients therefore must inspect the body, and generic HTTP monitoring will undercount failures — log GraphQL errors explicitly.

Content types. Send Content-Type: application/json; modern servers respond with application/graphql-response+json (older ones use plain application/json).

A Minimal Server With graphql-php

Install the reference PHP implementation:

composer require webonyx/graphql-php

Define the schema code-first and handle the request. The excerpt below is the body of a public/graphql.php front controller (after declare(strict_types=1) and requiring Composer's autoloader):

PHP example
use GraphQL\GraphQL;
use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Definition\Type;
use GraphQL\Type\Schema;

$albumType = new ObjectType([
    'name' => 'Album',
    'fields' => [
        'id' => Type::nonNull(Type::id()),
        'title' => Type::nonNull(Type::string()),
        'releaseYear' => Type::nonNull(Type::int()),
    ],
]);

$queryType = new ObjectType([
    'name' => 'Query',
    'fields' => [
        'album' => [
            'type' => $albumType,
            'args' => ['id' => Type::nonNull(Type::id())],
            'resolve' => fn ($root, array $args, AlbumRepository $ctx) =>
                $ctx->find($args['id']),
        ],
    ],
]);

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

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

$result = GraphQL::executeQuery(
    $schema,
    $input['query'] ?? '',
    null,                            // root value
    new AlbumRepository(),           // context — every resolver's third parameter
    $input['variables'] ?? null,
    $input['operationName'] ?? null,
);

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

Fifty lines gets you a spec-compliant executor: parsing, validation, execution, null propagation, and the response format all come from the library; you supplied types and one resolver. The contextValue you pass is what arrives as every resolver's third parameter — in a real application it carries the authenticated user and your service objects rather than a bare repository.

The same library also supports schema-first work: BuildSchema::build() parses an SDL file and you attach resolvers separately, which keeps the contract readable in one .graphql file. Framework integrations (Laravel's Lighthouse, API Platform, overblog for Symfony) wrap these same pieces with routing, and their concepts map one-to-one onto what you see here.

Production Concerns At The Transport Layer

  • Error detail leakage. By default, unexpected exceptions must not ship internals to clients. graphql-php masks unexpected exception messages unless you opt into debug flags — keep debug output for non-production environments only, and log the full exception server-side with the request's operation name and path.
  • Body limits and timeouts. The endpoint accepts arbitrary documents, so cap request body size at the web server and set execution time limits; the security lesson adds query-cost limits on top.
  • Compression. GraphQL responses are repetitive JSON; enabling gzip/brotli at the web server is close to free bandwidth.
  • Health checks. A trivial { __typename } query makes a fine liveness probe that exercises the full pipeline without touching your database.
  • CSRF. If the endpoint is used from browsers with cookie auth, it needs the same CSRF protections as any state-changing endpoint; requiring a custom header (which simple cross-site forms cannot set) plus strict content-type checking is the usual defense.

What To Check

Before moving on, make sure you can:

  • describe the standard POST request body and when operationName matters
  • explain why mutations must not travel over GET
  • state which failures produce non-200 status codes and which produce 200-with-errors
  • walk through what GraphQL::executeQuery needs and what the library does for you
  • explain what contextValue is for and what belongs in it
  • list transport-layer production concerns: masking errors, body limits, compression, CSRF

What You Should Be Able To Do

After this lesson, you should be able to stand up a working GraphQL endpoint in a plain PHP project with webonyx/graphql-php, exercise it with curl or a GraphQL IDE, and configure the transport basics — status-code behavior, error masking, limits — the way a production deployment needs them.