Schema-First gegen Code-First im Vergleich
graphql-php von Webonyx ist die PHP-Referenzimplementierung der GraphQL-Spezifikation und die Grundlage praktisch jeder größeren PHP-GraphQL-Lösung, von Lighthouse bis zu eigenen Standalone-APIs. Wer graphql-php direkt nutzt, muss früh entscheiden: Schema per SDL-Datei definieren oder Typen als PHP-Klassen im Code beschreiben. Diese Entscheidung prägt Wartbarkeit, IDE-Unterstützung und Teamworkflow für die gesamte Projektlaufzeit.
Inhaltsverzeichnis
- 1. Was ist graphql-php und wofür eignet es sich
- 2. Schema-First: SDL-Dateien und Type-Definitions
- 3. Code-First: PHP-Klassen als Typen
- 4. Resolver-Funktionen in graphql-php
- 5. Fehlerbehandlung und Validierung
- 6. Performance: DataLoader gegen N+1-Probleme
- 7. Integration in Symfony, Laravel und Standalone-Projekte
- 8. Testing von graphql-php-Schemas
- 9. Schema-First vs. Code-First im direkten Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Was ist graphql-php und wofür eignet es sich
graphql-php ist die von Webonyx entwickelte, spezifikationstreue PHP-Portierung der offiziellen GraphQL.js-Referenzimplementierung. Anders als High-Level-Frameworks wie Lighthouse oder API Platform liefert graphql-php keine fertigen Konventionen für Eloquent-Modelle oder Doctrine-Entities mit, sondern die reinen Bausteine: Type-System, Schema-Validierung, Query-Ausführung und Introspection. Genau diese Nüchternheit macht die Bibliothek zur bevorzugten Grundlage, wenn ein Team volle Kontrolle über Resolver-Verhalten, Caching und Fehlerbehandlung braucht, ohne die Abstraktionen eines größeren Frameworks mitzuschleppen.
In der Praxis begegnet man graphql-php häufig indirekt, als Unterbau von Lighthouse in Laravel oder von API Platforms GraphQL-Modul in Symfony. Wer direkt mit graphql-php arbeitet, tut das meist aus einem von zwei Gründen: entweder existiert kein passendes Framework für den jeweiligen Anwendungsfall, etwa bei einem schlanken Microservice ohne volles Symfony- oder Laravel-Ökosystem, oder das Team braucht Low-Level-Kontrolle, die High-Level-Frameworks bewusst verstecken. Beide Szenarien führen zur ersten grundlegenden Entscheidung: Schema-First oder Code-First.
2. Schema-First: SDL-Dateien und Type-Definitions
Beim Schema-First-Ansatz beschreibt eine .graphqls-Datei in der GraphQL Schema Definition Language (SDL) sämtliche Typen, Felder und Beziehungen. graphql-php parst diese Datei zur Laufzeit mit dem BuildSchema-Helper und erzeugt daraus ein ausführbares Schema-Objekt. Der große Vorteil: Die SDL-Datei ist für jeden lesbar, auch für Frontend-Entwickler ohne PHP-Kenntnisse, und lässt sich leicht mit dem tatsächlich ausgelieferten Schema per Introspection abgleichen. Viele Teams pflegen die SDL-Datei sogar als eigenständiges, versioniertes Vertragsdokument zwischen Frontend und Backend.
Der Nachteil zeigt sich bei wachsender Größe: Reine SDL-Dateien bieten keine Typsicherheit auf PHP-Seite, Tippfehler in Feldnamen fallen erst zur Laufzeit auf, nicht beim Schreiben. Resolver werden separat als PHP-Callables definiert und über einen Type-Config-Decorator mit den SDL-Typen verknüpft, was eine zusätzliche Indirektionsebene erzeugt. Für kleinere bis mittlere Schemas mit stabiler Struktur ist Schema-First trotzdem oft die pragmatischere Wahl, weil die SDL-Datei als lebende Dokumentation fungiert.
# schema.graphqls — Schema-First definition for graphql-php
type Product {
id: ID!
sku: String!
name: String!
price: Float!
categories: [Category!]!
}
type Category {
id: ID!
name: String!
products: [Product!]!
}
type Query {
product(sku: String!): Product
categories: [Category!]!
}
<?php
declare(strict_types=1);
use GraphQL\GraphQL;
use GraphQL\Type\Schema;
use GraphQL\Utils\BuildSchema;
use GraphQL\Utils\SchemaExtender;
// Load SDL and attach resolvers via a type config decorator
$sdl = file_get_contents(__DIR__ . '/schema.graphqls');
$schema = BuildSchema::build($sdl, function (array $typeConfig) use ($resolvers): array {
$typeName = $typeConfig['name'];
if ($typeName === 'Query') {
$typeConfig['resolveField'] = static fn ($root, array $args): mixed =>
$resolvers[$typeConfig['name']][$args['fieldName'] ?? 'product']($root, $args);
}
return $typeConfig;
});
3. Code-First: PHP-Klassen als Typen
Beim Code-First-Ansatz existiert kein separates SDL-Dokument. Stattdessen werden GraphQL-Typen als PHP-Klassen definiert, die von ObjectType erben oder Objekte davon instanziieren, mit Feldern als PHP-Arrays inklusive Typangaben, Argumenten und direkt eingebetteten Resolvern. Der entscheidende Vorteil: PHP-IDEs wie PhpStorm bieten vollständige Autovervollständigung, Refactoring-Unterstützung und statische Analyse, weil das Schema tatsächlich PHP-Code ist, den PHPStan und Psalm verstehen. Tippfehler in Feldnamen werden potenziell schon durch statische Analyse gefunden, nicht erst durch einen fehlgeschlagenen Request.
Der Nachteil liegt in der Verteilung: Bei großen Schemas verteilen sich Typdefinitionen über viele Dateien, und ohne zusätzliches Tooling gibt es keine kompakte, für Nicht-PHP-Entwickler lesbare Übersicht. graphql-php bietet dafür SchemaPrinter::doPrint(), das aus dem Code-First-Schema automatisch eine SDL-Repräsentation exportiert, die dann zum Beispiel im Frontend-Repository als Referenzdokument abgelegt werden kann. Damit lässt sich der Dokumentationsnachteil von Code-First weitgehend kompensieren.
<?php
declare(strict_types=1);
namespace App\GraphQL\Type;
use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Definition\Type;
final class ProductType extends ObjectType
{
public function __construct(CategoryType $categoryType)
{
parent::__construct([
'name' => 'Product',
'fields' => [
'id' => Type::nonNull(Type::id()),
'sku' => Type::nonNull(Type::string()),
'name' => Type::nonNull(Type::string()),
'price' => Type::nonNull(Type::float()),
'categories' => [
'type' => Type::nonNull(Type::listOf(Type::nonNull($categoryType))),
'resolve' => static fn (array $product): array =>
$product['categories'] ?? [],
],
],
]);
}
}
4. Resolver-Funktionen in graphql-php
Unabhängig vom gewählten Ansatz sind Resolver in graphql-php gewöhnliche PHP-Callables mit vier Parametern: dem Root-Wert des übergeordneten Typs, den Feld-Argumenten, dem Context-Objekt, das Request-übergreifende Abhängigkeiten wie den aktuellen Nutzer oder Repository-Instanzen transportiert, und der ResolveInfo, die Metadaten über das gerade angefragte Feld enthält, etwa welche Unterfelder selektiert wurden. Diese Signatur gilt sowohl für Schema-First-Type-Config-Decorators als auch für Code-First-Klassen.
Ein häufiges Missverständnis: Ohne expliziten resolve-Callback greift graphql-php auf einen Default-Resolver zurück, der einfach die gleichnamige Property oder Methode des Root-Werts liest. Das funktioniert gut für einfache Felder, führt aber bei komplexeren Beziehungen, etwa dem Nachladen verknüpfter Kategorien aus der Datenbank, schnell zu N+1-Problemen, wenn jeder Resolver naiv seine eigene Datenbankabfrage ausführt, statt Batching zu nutzen.
5. Fehlerbehandlung und Validierung
graphql-php unterscheidet strikt zwischen zwei Fehlerarten. Syntax- und Validierungsfehler, etwa eine unbekannte Query-Feldreferenz, werden automatisch von der Bibliothek erkannt und als GraphQL-Errors im standardisierten errors-Array der Response zurückgegeben, bevor überhaupt ein Resolver aufgerufen wird. Fachliche Fehler, etwa ein nicht gefundenes Produkt, müssen Entwickler selbst werfen, typischerweise als GraphQL\Error\Error-Instanz mit einer für Clients verständlichen Nachricht.
Für strukturierte Fehlercodes, die Frontend-Clients maschinell auswerten können, bietet graphql-php das extensions-Feld der Fehlerobjekte. Statt Fehlercodes in die Nachricht selbst zu kodieren, wie "PRODUCT_NOT_FOUND: SKU 42 unknown", gehört der Code strukturiert in extensions.code, während die Nachricht menschenlesbar bleibt. Ein zentraler errorFormatter, der beim Schema-Aufbau registriert wird, sorgt dafür, dass diese Struktur konsistent für das gesamte Schema angewendet wird, statt in jedem Resolver einzeln formatiert zu werden.
<?php
declare(strict_types=1);
use GraphQL\Error\Error;
use GraphQL\Error\DebugFlag;
use GraphQL\Error\FormattedError;
// Throw a business error with a structured extensions code
final class ProductResolver
{
public function resolveProduct(array $root, array $args): array
{
$product = $this->repository->findBySku($args['sku']);
if ($product === null) {
throw new Error(
message: "Product with SKU {$args['sku']} was not found",
extensions: ['code' => 'PRODUCT_NOT_FOUND', 'sku' => $args['sku']],
);
}
return $product;
}
}
// Custom error formatter — applied once for the whole schema
$errorFormatter = static function (Error $error): array {
$formatted = FormattedError::createFromException($error);
$formatted['extensions']['code'] ??= 'INTERNAL_ERROR';
return $formatted;
};
6. Performance: DataLoader gegen N+1-Probleme
Das N+1-Problem ist bei graphql-php genauso präsent wie bei jeder anderen GraphQL-Implementierung: Eine Liste von 50 Produkten mit jeweils einem verschachtelten Kategorie-Feld löst ohne Batching 50 einzelne Datenbankabfragen aus, eine pro Produkt, statt einer einzigen gesammelten Abfrage. Die Lösung ist das DataLoader-Pattern, das ursprünglich von Facebook für JavaScript entwickelt wurde und für PHP in Paketen wie overblog/dataloader-php verfügbar ist.
Ein DataLoader sammelt innerhalb eines einzelnen Event-Loop-Ticks alle angefragten IDs, bevor er sie in einer einzigen Batch-Abfrage auflöst und die Ergebnisse den ursprünglichen, individuellen Promises zuordnet. In graphql-php lässt sich das gut mit der SyncPromiseAdapter kombinieren, die auch ohne echte asynchrone Runtime wie ReactPHP funktioniert, weil sie das Batching synchron innerhalb der Query-Ausführung koordiniert. Für produktive PHP-GraphQL-APIs mit relevanter Nutzerzahl ist DataLoader kein optionales Extra, sondern eine Grundvoraussetzung für akzeptable Antwortzeiten.
<?php
declare(strict_types=1);
use GraphQL\Executor\Promise\Adapter\SyncPromiseAdapter;
use Overblog\DataLoader\DataLoader;
// Batch-load categories by product ID instead of one query per product
$categoryLoader = new DataLoader(
static function (array $productIds) use ($categoryRepository): array {
$categoriesByProduct = $categoryRepository->findByProductIds($productIds);
return array_map(
static fn (int $id): array => $categoriesByProduct[$id] ?? [],
$productIds
);
},
new SyncPromiseAdapter()
);
// Inside the resolve callback — accumulates, then resolves in one batch
'resolve' => static function (array $product) use ($categoryLoader) {
return $categoryLoader->load($product['id']);
},
7. Integration in Symfony, Laravel und Standalone-Projekte
In Symfony lässt sich graphql-php entweder direkt als Service über overblog/graphql-bundle einbinden, das eigene Schema-Definitions-Formate und Symfony-typische Dependency Injection mitbringt, oder man baut den Endpunkt komplett selbst als einfachen Controller, der einen HTTP-Request entgegennimmt und an GraphQL::executeQuery() weiterreicht. Für Laravel ist der direkte Einsatz seltener, weil Lighthouse, das intern ebenfalls auf graphql-php aufbaut, bereits die meisten Integrationsaufgaben löst.
Für Standalone-Projekte ohne Framework genügt ein schlanker index.php-Einstiegspunkt, der den Request-Body parst, das Schema lädt und die Query ausführt. Diese Minimallösung eignet sich besonders für Microservices, bei denen eine volle Framework-Installation unnötigen Overhead bedeuten würde, und zeigt, wie unabhängig graphql-php vom umgebenden Ökosystem tatsächlich ist.
8. Testing von graphql-php-Schemas
Für Tests bietet graphql-php keine spezielle Testinfrastruktur, sondern setzt auf normale PHPUnit-Tests, die GraphQL::executeQuery() direkt mit einer Test-Query und Testdaten aufrufen und die zurückgegebene Datenstruktur assertieren. Das ist deutlich schneller als End-to-End-Tests über HTTP, weil kein Webserver und keine echte Netzwerkschicht involviert sind, das Schema läuft direkt im PHPUnit-Prozess.
Sinnvoll ist eine Testpyramide mit drei Ebenen: Unit-Tests für einzelne Resolver-Funktionen mit gemockten Repositories, Integrationstests, die das komplette Schema mit einer Test-Datenbank ausführen, und wenige End-to-End-Tests über den echten HTTP-Endpunkt, die vor allem Middleware, Authentifizierung und Fehlerformatierung abdecken. Snapshot-Tests, die die komplette JSON-Response gegen eine gespeicherte Referenzdatei vergleichen, helfen zusätzlich, unbeabsichtigte Schema-Änderungen frühzeitig zu erkennen.
9. Schema-First vs. Code-First im direkten Vergleich
Beide Ansätze produzieren am Ende ein funktional gleichwertiges GraphQL-Schema, die Entscheidung ist also keine Frage von Möglichkeiten, sondern von Teamgröße, Toolchain und Präferenzen bei der Fehlererkennung.
| Kriterium | Schema-First (SDL) | Code-First (PHP-Klassen) |
|---|---|---|
| Lesbarkeit für Frontend-Teams | Hoch, SDL direkt lesbar | Nur über generierten SDL-Export |
| IDE-Unterstützung | Eingeschränkt, kein Typ-Check der SDL | Vollständig, PHPStan/Psalm-fähig |
| Fehlererkennung | Zur Laufzeit | Zum Teil schon statisch |
| Skalierung bei großen Schemas | Eine wachsende Datei | Natürlich in Klassen aufgeteilt |
| Vertragsdokument mit Frontend | Direkt nutzbar | SchemaPrinter-Export nötig |
Für Teams mit klarer Frontend/Backend-Trennung und stabilem, mittelgroßem Schema ist Schema-First oft die schnellere Einstiegslösung. Für Teams, die stark auf statische Analyse setzen und ein sehr großes, häufig wachsendes Schema pflegen, ist Code-First die wartbarere Wahl. Manche Projekte kombinieren beides: Schema-First für stabile Kernbereiche, Code-First für sich schnell entwickelnde neue Module.
Mironsoft
PHP-GraphQL-APIs mit graphql-php, Symfony und Magento
Eigene GraphQL-API mit graphql-php planen?
Wir konzipieren Schema-Architektur, Resolver-Struktur und DataLoader-Strategie für eure PHP-GraphQL-API, wählen den passenden Ansatz zwischen Schema-First und Code-First und sorgen für saubere Fehlerbehandlung von Anfang an.
Schema-Architektur
Schema-First oder Code-First passend zu Teamgröße und Toolchain wählen
Performance-Tuning
DataLoader-Batching gegen N+1-Probleme in Resolvern implementieren
Framework-Integration
graphql-php sauber in Symfony, Laravel oder Standalone-Projekte einbinden
10. Zusammenfassung
graphql-php von Webonyx ist die spezifikationstreue Grundlage praktisch jeder ernstzunehmenden PHP-GraphQL-Lösung, ob direkt genutzt oder als Unterbau von Lighthouse und API Platform. Schema-First mit SDL-Dateien punktet mit Lesbarkeit und einem direkt nutzbaren Vertragsdokument für Frontend-Teams, Code-First mit PHP-Klassen punktet mit voller IDE-Unterstützung und statischer Analyse. Beide Ansätze führen zu einem funktional identischen, ausführbaren Schema.
Unabhängig vom gewählten Ansatz sind Resolver-Design, strukturierte Fehlerbehandlung über das extensions-Feld und DataLoader-basiertes Batching gegen N+1-Probleme die drei Bausteine, die über die tatsächliche Produktionstauglichkeit einer mit graphql-php gebauten API entscheiden. Wer diese Grundlagen beherrscht, kann souverän zwischen den High-Level-Frameworks wählen oder ganz bewusst direkt auf graphql-php setzen.
graphql-php (Webonyx) — Das Wichtigste auf einen Blick
Schema-First
SDL-Datei plus BuildSchema::build(). Lesbar für alle, Resolver über Type-Config-Decorator angebunden.
Code-First
PHP-Klassen mit ObjectType. Volle IDE- und Statik-Unterstützung, Export via SchemaPrinter möglich.
Fehlerbehandlung
Fachfehler als GraphQL\Error\Error mit strukturiertem extensions.code, zentraler errorFormatter.
Performance
DataLoader-Pattern mit SyncPromiseAdapter für Batching, verhindert N+1-Datenbankabfragen.