Validation, Execution, And The Response
The Pipeline At A Glance
- Parse the document into an AST. Syntax errors stop here.
- Validate the AST against the schema. Nothing user-defined runs yet.
- Execute by walking the operation's selection set, calling a resolver for each field.
- Respond with a JSON object containing
data, anderrorswhen anything went wrong.
Validation: Rejected Before Any Code Runs
Validation checks the whole document against the schema and either passes it entirely or rejects it entirely. Among the rules:
- every selected field must exist on the type it is selected from,
- scalar fields must not carry selection sets; object fields must,
- arguments must exist, be correctly typed, and include all required ones,
- variables must be declared, used, and of types compatible with where they are plugged in,
- fragments must be spread on compatible types, must be used, and must not form cycles.
The payoff: a typo like selecting Album.titel fails with a precise error before a single resolver or database call happens. Your resolver code can assume every request it sees is structurally sound. This is also why validation errors always mean a broken client build or a schema change — they are worth alerting on differently from runtime errors.
Execution: A Resolver Per Field
Execution walks the selection set top-down. Every field is backed by a resolver function receiving four things — in webonyx/graphql-php the signature looks like:
function (mixed $parent, array $args, AppContext $context, ResolveInfo $info): mixed
$parent— the value the enclosing field resolved to. For root fields it is your configured root value; forAlbum.artistit is the album.$args— the field's arguments, already validated and coerced (['first' => 3]).$context— one request-scoped object you provide: the current user, database connections, loaders. This is the channel for request state; resolvers should not reach for globals.$info— metadata about the selection (field name, path, what sub-fields were requested), useful for optimizations.
A resolver for our catalog might be:
'artist' => function ($root, array $args, AppContext $ctx): ?array {
return $ctx->artists->findBySlug($args['slug']);
},
Most fields need no explicit resolver at all: the default resolver looks up a matching property or array key on $parent — Album.title just reads $album['title']. You write resolvers where work happens: root lookups, relationship traversal, computed values.
Two execution rules worth knowing: sibling fields under Query may run concurrently while root Mutation fields are strictly serial (as the mutations lesson covered), and results always come back in the order the query requested, regardless of the order in which resolvers finished.
One quiet step happens after each resolver returns: scalar coercion. The engine converts the resolver's raw return value into the field's declared scalar type — a numeric string returned for an Int field becomes an integer, 1 for a Boolean becomes true, and a value that cannot be coerced becomes a field error. Resolvers therefore do not need to hand back perfectly typed values, but coercion failures surface as runtime errors, so sloppy resolver types are still bugs waiting in the data.
Null Propagation: How Errors Stay Contained
When a resolver throws, GraphQL does not abandon the whole request. It records an error and sets that field to null. Then nullability decides the blast radius:
- if the field is nullable, the
nullsits there and the rest of the response is intact; - if the field is non-null,
nullis not a legal value — so the parent object becomesnullinstead, and if that position is also non-null, the null keeps climbing until it reaches a nullable ancestor (or nulls outdataentirely).
Concretely: Album.averageRating: Float failing costs you one number. Album.averageRating: Float! failing destroys the whole album object in the response. This is the mechanical reason the schema lesson advised nullable types for fields backed by flaky dependencies.
The Response Format
The spec fixes the top-level response shape:
{
"data": {
"album": {
"title": "Glasswork",
"averageRating": null
}
},
"errors": [
{
"message": "Stats service timed out.",
"path": ["album", "averageRating"],
"locations": [{ "line": 4, "column": 5 }],
"extensions": { "code": "UPSTREAM_TIMEOUT" }
}
]
}
data— the result tree, mirroring the query. Present (possiblynull) whenever execution began.errors— a list of error objects, each with amessage, usually apathpointing at the field that failed, and an optionalextensionsmap for machine-readable details like error codes. Validation failures produce a response witherrorsand nodataat all.extensions(top level) — an optional server-defined map for metadata such as tracing or cost information.
The coexistence of data and errors — partial success — is a defining GraphQL behavior. A client must always check both; "HTTP 200" tells you nothing. The error-handling lesson later covers designing for this deliberately.
What To Check
Before moving on, make sure you can:
- name the four pipeline stages and what each one can reject or produce
- list several validation rules and explain why validation failures need no resolver execution
- describe the four resolver parameters and the role of the context object
- explain what the default resolver does and when you must write an explicit one
- trace null propagation through nested non-null fields for a given failing resolver
- read a response containing both
dataanderrorsand identify exactly what failed
What You Should Be Able To Do
After this lesson, you should be able to reason like the engine: given a schema, a query, and a description of which resolver fails, predict the exact response — which fields survive, where nulls land, and what appears in errors — and explain to a teammate why a non-null field turned a small failure into a large one.