Schema-first compared to code-first
graphql-php by Webonyx is the PHP reference implementation of the GraphQL specification and the foundation of virtually every larger PHP GraphQL solution, from Lighthouse to standalone APIs built from scratch. Anyone using graphql-php directly has to decide early on: define the schema via an SDL file, or describe types as PHP classes in code. That decision shapes maintainability, IDE support and team workflow for the entire project lifetime.
Table of contents
- 1. What graphql-php is and where it fits
- 2. Schema-first: SDL files and type definitions
- 3. Code-first: PHP classes as types
- 4. Resolver functions in graphql-php
- 5. Error handling and validation
- 6. Performance: DataLoader against N+1 problems
- 7. Integration into Symfony, Laravel and standalone projects
- 8. Testing graphql-php schemas
- 9. Schema-first vs. code-first compared directly
- 10. Summary
- 11. FAQ
1. What graphql-php is and where it fits
graphql-php is Webonyx's spec-compliant PHP port of the official GraphQL.js reference implementation. Unlike high-level frameworks such as Lighthouse or API Platform, graphql-php doesn't ship ready-made conventions for Eloquent models or Doctrine entities, it delivers the pure building blocks: type system, schema validation, query execution and introspection. That sobriety is exactly what makes the library the preferred foundation when a team needs full control over resolver behavior, caching and error handling, without dragging along the abstractions of a larger framework.
In practice, you often encounter graphql-php indirectly, as the underpinning of Lighthouse in Laravel or of API Platform's GraphQL module in Symfony. Teams that work directly with graphql-php usually do so for one of two reasons: either no suitable framework exists for the use case, for example a lean microservice without a full Symfony or Laravel ecosystem, or the team needs low-level control that high-level frameworks deliberately hide. Both scenarios lead to the same first fundamental decision: schema-first or code-first.
2. Schema-first: SDL files and type definitions
In the schema-first approach, a .graphqls file written in the GraphQL Schema Definition Language (SDL) describes every type, field and relationship. graphql-php parses this file at runtime using the BuildSchema helper and produces an executable schema object from it. The big advantage: the SDL file is readable by anyone, even frontend developers without PHP knowledge, and can easily be diffed against the actually deployed schema via introspection. Many teams even maintain the SDL file as a standalone, versioned contract document between frontend and backend.
The downside shows up as things grow: plain SDL files offer no type safety on the PHP side, typos in field names only surface at runtime, not while writing code. Resolvers are defined separately as PHP callables and wired to the SDL types through a type config decorator, which adds an extra layer of indirection. For smaller to mid-sized schemas with a stable structure, schema-first is still often the more pragmatic choice, because the SDL file doubles as living documentation.
# 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 classes as types
In the code-first approach, there is no separate SDL document at all. Instead, GraphQL types are defined as PHP classes that either extend ObjectType or instantiate objects of it, with fields as PHP arrays including type declarations, arguments and resolvers embedded directly. The decisive advantage: PHP IDEs like PhpStorm offer full autocompletion, refactoring support and static analysis, because the schema really is PHP code that PHPStan and Psalm understand. Typos in field names can potentially be caught by static analysis, not just by a failed request.
The downside lies in the spread: on large schemas, type definitions spread across many files, and without extra tooling there's no compact overview readable by non-PHP developers. graphql-php addresses this with SchemaPrinter::doPrint(), which automatically exports an SDL representation from the code-first schema, which can then be dropped, for example, into the frontend repository as a reference document. That largely offsets the documentation disadvantage of code-first.
<?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 functions in graphql-php
Regardless of the approach chosen, resolvers in graphql-php are ordinary PHP callables with four parameters: the root value of the parent type, the field arguments, a context object that carries request-wide dependencies such as the current user or repository instances, and the ResolveInfo, which contains metadata about the currently requested field, for example which subfields were selected. This signature applies equally to schema-first type config decorators and to code-first classes.
A common misunderstanding: without an explicit resolve callback, graphql-php falls back to a default resolver that simply reads the identically-named property or method from the root value. That works well for simple fields, but quickly leads to N+1 problems on more complex relationships, for example loading linked categories from the database, if every resolver naively runs its own database query instead of using batching.
5. Error handling and validation
graphql-php strictly distinguishes between two kinds of errors. Syntax and validation errors, for example an unknown query field reference, are detected automatically by the library and returned as GraphQL errors in the response's standardized errors array, before any resolver even runs. Business errors, for example a product that isn't found, must be thrown by developers themselves, typically as a GraphQL\Error\Error instance with a client-understandable message.
For structured error codes that frontend clients can evaluate programmatically, graphql-php offers the extensions field on error objects. Instead of encoding error codes into the message itself, like "PRODUCT_NOT_FOUND: SKU 42 unknown", the code belongs structured in extensions.code, while the message stays human-readable. A central errorFormatter, registered when the schema is built, ensures this structure is applied consistently across the entire schema, instead of being formatted separately in every resolver.
<?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 against N+1 problems
The N+1 problem shows up in graphql-php just as much as in any other GraphQL implementation: a list of 50 products, each with a nested category field, triggers 50 separate database queries without batching, one per product, instead of a single collected query. The fix is the DataLoader pattern, originally developed by Facebook for JavaScript and available for PHP in packages such as overblog/dataloader-php.
A DataLoader collects every requested ID within a single event-loop tick before resolving them in one batched query and mapping the results back to the original, individual promises. In graphql-php, this pairs well with the SyncPromiseAdapter, which works even without a real asynchronous runtime like ReactPHP, because it coordinates batching synchronously within query execution. For production PHP GraphQL APIs with meaningful user counts, DataLoader isn't an optional extra, it's a basic prerequisite for acceptable response times.
<?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 into Symfony, Laravel and standalone projects
In Symfony, graphql-php can either be wired in directly as a service via overblog/graphql-bundle, which brings its own schema definition formats and Symfony-style dependency injection, or you can build the endpoint entirely yourself as a plain controller that accepts an HTTP request and forwards it to GraphQL::executeQuery(). In Laravel, direct use is rarer, because Lighthouse, which itself is also built on graphql-php, already solves most integration tasks.
For standalone projects without a framework, a lean index.php entry point is enough, one that parses the request body, loads the schema and executes the query. That minimal solution is particularly suited to microservices where a full framework installation would be unnecessary overhead, and it shows just how independent graphql-php really is from the surrounding ecosystem.
8. Testing graphql-php schemas
For testing, graphql-php doesn't offer special test infrastructure, it relies on ordinary PHPUnit tests that call GraphQL::executeQuery() directly with a test query and test data, then assert on the returned data structure. That's considerably faster than end-to-end tests over HTTP, since no web server and no real network layer are involved, the schema runs directly inside the PHPUnit process.
A sensible test pyramid has three layers: unit tests for individual resolver functions with mocked repositories, integration tests that run the complete schema against a test database, and a small number of end-to-end tests against the real HTTP endpoint, covering mainly middleware, authentication and error formatting. Snapshot tests that compare the entire JSON response against a stored reference file additionally help catch unintended schema changes early.
9. Schema-first vs. code-first compared directly
Both approaches ultimately produce a functionally equivalent GraphQL schema, so the decision isn't a question of capability, it's a question of team size, toolchain and preference for how errors get caught.
| Criterion | Schema-first (SDL) | Code-first (PHP classes) |
|---|---|---|
| Readability for frontend teams | High, SDL directly readable | Only via generated SDL export |
| IDE support | Limited, no type checking of SDL | Full, PHPStan/Psalm-capable |
| Error detection | At runtime | Partly already static |
| Scaling with large schemas | One growing file | Naturally split into classes |
| Contract document with frontend | Directly usable | Requires SchemaPrinter export |
For teams with a clear frontend/backend split and a stable, mid-sized schema, schema-first is often the faster entry point. For teams that lean heavily on static analysis and maintain a very large, frequently growing schema, code-first is the more maintainable choice. Some projects combine both: schema-first for stable core areas, code-first for rapidly evolving new modules.
Mironsoft
PHP GraphQL APIs with graphql-php, Symfony and Magento
Planning your own GraphQL API with graphql-php?
We design schema architecture, resolver structure and DataLoader strategy for your PHP GraphQL API, pick the right approach between schema-first and code-first, and get error handling right from the start.
Schema architecture
Choosing schema-first or code-first to fit team size and toolchain
Performance tuning
Implementing DataLoader batching against N+1 problems in resolvers
Framework integration
Wiring graphql-php cleanly into Symfony, Laravel or standalone projects
10. Summary
graphql-php by Webonyx is the spec-compliant foundation of virtually every serious PHP GraphQL solution, whether used directly or as the underpinning of Lighthouse and API Platform. Schema-first with SDL files scores on readability and a directly usable contract document for frontend teams, code-first with PHP classes scores on full IDE support and static analysis. Both approaches lead to a functionally identical, executable schema.
Regardless of the approach chosen, resolver design, structured error handling via the extensions field, and DataLoader-based batching against N+1 problems are the three building blocks that determine whether an API built with graphql-php is actually production-ready. Anyone who masters these fundamentals can confidently choose between the high-level frameworks, or deliberately build directly on graphql-php.
graphql-php (Webonyx) — Key Takeaways
Schema-first
SDL file plus BuildSchema::build(). Readable by everyone, resolvers wired through a type config decorator.
Code-first
PHP classes with ObjectType. Full IDE and static analysis support, exportable via SchemaPrinter.
Error handling
Business errors as GraphQL\Error\Error with structured extensions.code, central errorFormatter.
Performance
DataLoader pattern with SyncPromiseAdapter for batching, prevents N+1 database queries.