Schema, Resolver und das N+1-Problem ohne Framework
GraphQL wirkt in Frontend-Tutorials oft wie eine fertige Blackbox, dabei lässt sich ein funktionierender Server in PHP mit vergleichsweise wenig Code selbst bauen. Wir nutzen webonyx/graphql-php als schlanke Basis für Typsystem und Ausführung, definieren Schema und Resolver von Hand und zeigen, wie das berüchtigte N+1-Problem entsteht und ohne eine dedizierte DataLoader-Bibliothek gelöst werden kann.
Inhaltsverzeichnis
- 1. Was Grund auf hier konkret bedeutet
- 2. Schema-Definition ohne vollwertiges Framework
- 3. Resolver-Funktionen für Queries
- 4. Den Server-Endpunkt selbst zusammenbauen
- 5. Das N+1-Problem konkret zeigen
- 6. Eine einfache Lösung ohne DataLoader-Bibliothek
- 7. Mutations und Input-Types ergänzen
- 8. Fehlerbehandlung und Error-Formatting
- 9. Abgrenzung zu einer vollen REST-API
- 10. Zusammenfassung
- 11. FAQ
1. Was Grund auf hier konkret bedeutet
Ein GraphQL-Server von Grund auf zu bauen bedeutet in diesem Artikel nicht, den GraphQL-Standard selbst neu zu implementieren, das wäre unrealistischer Aufwand für ein einzelnes Projekt. Stattdessen nutzen wir webonyx/graphql-php als schlanke Referenzimplementierung des Typsystems und der Ausführungslogik, verzichten aber bewusst auf ein volles Framework wie Lighthouse oder API Platform, das Schema-Definition, Resolver-Auflösung und Caching bereits automatisiert.
Der Unterschied ist entscheidend für das Verständnis: Ein Framework nimmt einem ab, Typen aus Annotationen zu generieren und Resolver automatisch zu verdrahten. Mit webonyx/graphql-php als reiner Bibliothek definiert man Schema und Resolver explizit selbst, wodurch sichtbar wird, was ein Framework im Hintergrund eigentlich tut, und wo typische Performance-Fallen wie das N+1-Problem tatsächlich entstehen.
2. Schema-Definition ohne vollwertiges Framework
webonyx/graphql-php bildet GraphQL-Typen über PHP-Objekte ab, etwa ObjectType für einen Typ mit benannten Feldern und deren Rückgabetypen. Jedes Feld erhält neben seinem Typ optional eine resolve-Funktion, die bestimmt, wie der Wert dieses Feldes zur Laufzeit ermittelt wird. Ohne diese Funktion greift die Bibliothek standardmäßig auf eine gleichnamige Eigenschaft oder Methode des übergebenen Werteobjekts zu.
Für ein einfaches Blog-Schema definiert man zunächst einen PostType mit Feldern wie id, title und author, wobei author selbst wieder ein AuthorType ist. Diese Verschachtelung ist der Kern von GraphQL, ein Client kann in einer einzigen Anfrage sowohl den Beitrag als auch dessen Autor abfragen, was später genau die Stelle ist, an der das N+1-Problem entsteht.
<?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-Funktionen für Queries
Der eigentliche Einstiegspunkt eines GraphQL-Schemas ist der Query-Typ, ebenfalls ein ObjectType, dessen Felder die verfügbaren Top-Level-Abfragen repräsentieren. Ein Feld posts liefert etwa eine Liste aller Beiträge, dessen resolve-Funktion direkt auf ein Repository zugreift und ein Array von Beitragsdaten zurückgibt, das anschließend durch den PostType weiterverarbeitet wird.
Wichtig ist, dass Resolver-Funktionen in webonyx/graphql-php vier Argumente erhalten können: den Wert des Elternfelds, die Query-Argumente, einen geteilten Kontext etwa für Authentifizierung, und Informationen über die angefragten Felder selbst. Gerade das Kontext-Argument ist der Ort, an dem sich Datenbankverbindungen oder Batching-Strukturen sauber durch den gesamten Resolver-Baum reichen lassen, ohne globale Variablen zu benötigen.
<?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. Den Server-Endpunkt selbst zusammenbauen
Statt einen vollen Framework-Router zu nutzen, genügt ein einzelner PHP-Einstiegspunkt, der den Request-Body als JSON dekodiert, Query-String und Variablen extrahiert und diese an GraphQL::executeQuery() übergibt. Diese Methode aus webonyx/graphql-php erwartet das zusammengesetzte Schema, den Query-String, ein Root-Value, den Kontext und optionale Variablen, und liefert ein ExecutionResult zurück.
Das ExecutionResult wird anschließend über toArray() in ein Array überführt, das direkt als JSON an den Client zurückgegeben werden kann. Wichtig ist, Fehler in dieser Ausgabe nicht zu unterdrücken, sondern über den passenden HTTP-Statuscode und ein Debug-Flag zu steuern, wie viele Interna eines Fehlers in Produktionsumgebungen tatsächlich sichtbar werden.
<?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. Das N+1-Problem konkret zeigen
Das N+1-Problem entsteht genau an der Stelle, an der ein Feld wie author in jedem einzelnen Post-Objekt separat aufgelöst wird. Fragt ein Client eine Liste von zwanzig Beiträgen samt Autor ab, führt die naive Resolver-Implementierung aus dem ersten Abschnitt eine Abfrage für die Liste selbst aus, gefolgt von zwanzig weiteren Einzelabfragen, jeweils eine pro Autor, macht also einundzwanzig statt idealerweise zwei Datenbankzugriffe.
Der Grund liegt darin, dass jeder Feld-Resolver in GraphQL isoliert und ohne Wissen über parallel laufende Resolver derselben Ebene ausgeführt wird. Aus Sicht des author-Resolvers gibt es keine Information darüber, dass gerade neunzehn weitere author-Resolver mit strukturell identischer Anfrage aktiv sind, jeder trifft seine eigene, unkoordinierte Entscheidung, sofort eine Datenbankabfrage auszuführen.
6. Eine einfache Lösung ohne DataLoader-Bibliothek
Ohne eine dedizierte DataLoader-Bibliothek lässt sich das Problem über manuelles Batching lösen, indem man die Ausführung in zwei Phasen trennt: Zunächst sammelt jeder author-Resolver nur die benötigte authorId in einer geteilten Liste im Kontext-Objekt, ohne sofort eine Abfrage auszuführen, und gibt stattdessen einen Platzhalter zurück. Nach dem eigentlichen Ausführungsdurchlauf liest man die gesammelten IDs aus, führt eine einzige Abfrage mit einer IN-Klausel aus und löst die Platzhalter danach auf.
In der Praxis mit webonyx/graphql-php lässt sich dieses Muster vereinfacht auch über eine synchrone Vorab-Sammlung umsetzen: Statt echtem Deferred-Pattern sammelt der Query-Resolver für posts direkt alle authorId-Werte der geladenen Beiträge und lädt die zugehörigen Autoren in einer einzigen Abfrage vorab in den Kontext, sodass der spätere author-Resolver nur noch aus einem bereits befüllten Cache-Array liest, statt selbst eine Datenbankabfrage auszulösen.
<?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();
// Alle benötigten Autoren-IDs vorab sammeln und in
// EINER Abfrage laden, statt später pro Post einzeln
$authorIds = array_unique(array_column($posts, 'authorId'));
$context->authorCache = $context->authorRepository->findByIds($authorIds);
return $posts;
},
],
],
]);
// Der author-Resolver liest nur noch aus dem bereits befüllten Cache
'author' => [
'type' => Type::nonNull($authorType),
'resolve' => static function (array $post, array $args, GraphQlContext $context): array {
return $context->authorCache[$post['authorId']];
},
],
7. Mutations und Input-Types ergänzen
Neben Queries definiert GraphQL Mutations für schreibende Operationen, technisch ebenfalls ein ObjectType, dessen Felder Änderungen am Datenbestand auslösen. Um komplexe Eingabedaten sauber zu validieren, nutzt man InputObjectType statt eines einfachen ObjectType, da GraphQL beide Richtungen bewusst trennt und Eingabetypen keine Resolver-Funktionen besitzen dürfen.
Eine createPost-Mutation erhält damit ein strukturiertes CreatePostInput mit Feldern wie title und authorId, validiert diese über die eingebaute Typprüfung von GraphQL und reicht die Daten an einen Service weiter, der den eigentlichen Schreibvorgang übernimmt. Fehlerhafte Eingaben, etwa ein fehlendes Pflichtfeld, werden dabei bereits vor Erreichen des Resolvers durch das Typsystem selbst abgefangen.
8. Fehlerbehandlung und Error-Formatting
GraphQL unterscheidet sich von REST darin, dass eine Antwort auch bei Fehlern meist mit HTTP-Status 200 zurückkommt, Fehler werden stattdessen in einem eigenen errors-Array neben den regulären data-Feldern gemeldet. webonyx/graphql-php sammelt dabei automatisch alle während der Ausführung geworfenen Exceptions, solange sie über die Klasse Error oder eine davon abgeleitete Klasse signalisiert werden.
Für Produktionsumgebungen sollte man eine eigene Error-Formatting-Funktion registrieren, die interne Exception-Nachrichten und Stack Traces nicht unverändert an den Client weitergibt, sondern generische, aber eindeutige Fehlercodes zurückgibt. Debug-Informationen lassen sich über einen expliziten Debug-Flag nur in Entwicklungsumgebungen aktivieren, niemals standardmäßig in Produktion.
9. Abgrenzung zu einer vollen REST-API
Eine vollständige REST-API-Architektur mit Ressourcen, HTTP-Verben und Statuscodes ist ein eigenständiges Thema mit anderen Designfragen, etwa Versionierung über URL-Pfade oder HATEOAS-Links. GraphQL löst ein anderes Problem: Es erlaubt Clients, in einer einzigen Anfrage exakt die benötigten Felder über mehrere verschachtelte Ressourcen hinweg abzurufen, statt mehrere REST-Endpunkte nacheinander aufzurufen oder mit Overfetching zu leben.
Der hier gezeigte Eigenbau eignet sich für interne Tools, Admin-Backends oder kleine Microservices, bei denen die volle Kontrolle über Schema und Resolver wichtiger ist als Tooling wie automatische Persisted Queries oder eingebautes Rate-Limiting. Für öffentliche, stark frequentierte APIs sind ausgereifte Lösungen wie API Platform mit GraphQL-Unterstützung oder ein dediziertes GraphQL-Gateway meist die robustere Wahl.
| Aspekt | Reiner Eigenbau mit webonyx | Volles Framework wie API Platform | REST-API zum Vergleich |
|---|---|---|---|
| Schema-Definition | Manuell über PHP-Objekte | Automatisch aus Annotationen/Attributen | Keine, Ressourcen statt Schema |
| N+1-Kontrolle | Manuelles Batching nötig | Oft eingebaute DataLoader-Unterstützung | Nicht relevant, klassisches Overfetching |
| Lernkurve | Hoch, Mechanik wird sichtbar | Niedriger, aber Blackbox-Charakter | Niedrig, weit verbreitetes Muster |
| Kontrolle | Vollständig | Eingeschränkt durch Framework-Konventionen | Vollständig, aber anderes Modell |
| Eignung | Interne Tools, kleine Services | Öffentliche, wachsende APIs | Öffentliche APIs mit einfachen Ressourcen |
Mironsoft
PHP-Modernisierung, Code-Qualität und Legacy-Refactoring
Gewachsener PHP-Code, der niemand mehr gern anfasst?
Wir modernisieren PHP-Codebasen auf aktuelle Sprachstandards, führen statische Analyse und Coding Standards ein und refactorn Legacy-Code Schritt für Schritt, ohne den laufenden Betrieb zu gefährden.
Legacy-Refactoring
Gewachsenen PHP-Code strukturiert und risikoarm modernisieren.
Code-Qualität etablieren
PHPStan, Coding Standards und CI-Checks nachhaltig im Team verankern.
Versions-Upgrade
PHP-Major-Version-Upgrades sicher planen und ohne Ausfallzeit umsetzen.
10. Zusammenfassung
GraphQL-Server in PHP: Das Wichtigste auf einen Blick
Schlanke Basis
webonyx/graphql-php liefert Typsystem und Ausführung, ohne Schema und Resolver zu automatisieren.
Resolver-Kontext
Datenbankzugriffe und Batching-Strukturen wandern sauber über das Kontext-Argument durch den Resolver-Baum.
N+1 erkennen
Isolierte Feld-Resolver ohne Wissen über parallele Aufrufe erzeugen das klassische N+1-Muster.
Manuelles Batching
Vorab-Sammeln von IDs und eine einzige IN-Abfrage lösen das Problem auch ohne DataLoader-Bibliothek.