GraphQL APIs in PHP
Reviewed & published by Brayan K
GraphQL is a query language for APIs that lets clients ask for exactly the data they need from a single endpoint. In this lesson you'll design a typed schema, write queries and mutations, back fields with resolvers using webonyx/graphql-php, and see why it solves REST's over- and under-fetching.
Part of the free PHP course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
- Write a typed schema in the Schema Definition Language
- Tell queries (reads) apart from mutations (writes)
- Back each field with a resolver function in PHP
- See how one endpoint replaces many REST routes
- Understand over-fetching vs under-fetching
- Know the key libraries: webonyx/graphql-php and Lighthouse
1️⃣ The Schema: A Typed Contract
Everything in GraphQL starts with the schema, written in the Schema Definition Language (SDL). It declares your types (like User and Post), their fields, and the relationships between them. Two special types are the entry points: Query lists every read operation, and Mutation lists every write. A trailing ! marks a field as non-nullable. Because the schema is typed, the server can validate any incoming query before running a line of resolver code.
# schema.graphql — written in the Schema Definition Language (SDL).
# The schema is a typed CONTRACT describing everything the API can do.
type User {
id: ID! # ! means non-nullable: always present
name: String!
email: String!
posts: [Post!]! # a list of Posts (the relationship)
}
type Post {
id: ID!
title: String!
body: String!
}
# The special "Query" type lists every READ operation a client can run.
type Query {
user(id: ID!): User # fetch one user by id
users: [User!]! # fetch all users
}
# The special "Mutation" type lists every WRITE operation.
type Mutation {
createUser(name: String!, email: String!): User!
}This one document is the whole API's contract. It also self-documents: tools can read it to give clients autocompletion and validation, which is a big part of GraphQL's appeal.
2️⃣ Queries: Reading Exactly What You Need
A query reads data. The client writes the shape it wants — which fields, and how deep — and the server returns JSON in exactly that shape. Ask for three fields and you get three fields; ask for nested posts and they come back inside the user. This is the heart of GraphQL: the client controls the response shape, so there are no wasted bytes and no second request for related data.
# A QUERY reads data. The client asks for EXACTLY the fields it wants.
# Sent in the request body to the single /graphql endpoint.
query {
user(id: 1) { # call the user(id) field
name # ...and ask only for these three fields
email
posts {
title # nested: each post's title only
}
}
}Notice how the response mirrors the request one-to-one. That predictability makes GraphQL responses easy to consume on the client.
3️⃣ Mutations: Changing Data
Reads use queries; anything that changes data uses a mutation — creating, updating, or deleting. The syntax is nearly identical, but the operation keyword is mutation, and it maps to a field on the special Mutation type. Usefully, a mutation returns data too: you pick which fields of the newly changed object to read back, so a create can immediately hand you the new id.
# A MUTATION changes data. Same endpoint, different operation keyword.
# It returns whatever fields you ask for from the newly created object.
mutation {
createUser(name: "Grace", email: "grace@example.com") {
id # return the new id...
name # ...and name, to confirm the write
}
}Keeping reads (queries) and writes (mutations) separate is a clear, enforced convention — the schema makes it impossible to accidentally mutate data from a query.
4️⃣ Resolvers: Where PHP Does the Work
A schema describes what is possible; resolvers implement how. Every field is backed by a resolver function that returns its value — usually by reading a database, calling a service, or following a relationship. In PHP the standard engine is webonyx/graphql-php (composer require webonyx/graphql-php): you define types and their resolve callbacks, build a Schema, and call GraphQL::executeQuery() with the incoming query.
<?php
// Server side with webonyx/graphql-php — the core PHP GraphQL engine.
// composer require webonyx/graphql-php
//
// Each FIELD is backed by a RESOLVER: a function that returns its value.
use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Definition\Type;
use GraphQL\Type\Schema;
use GraphQL\GraphQL;
$userType = new ObjectType([
'name' => 'User',
'fields' => [
'id' => Type::nonNull(Type::id()), // id: ID!
'name' => Type::nonNull(Type::string()), // name: String!
'email' => Type::nonNull(Type::string()),
],
]);
$queryType = new ObjectType([
'name' => 'Query',
'fields' => [
'user' => [
'type' => $userType,
'args' => ['id' => Type::nonNull(Type::id())],
// The RESOLVER: given the args, return the data for this field.
'resolve' => function ($root, array $args) {
return findUserById($args['id']); // e.g. a DB lookup
},
],
],
]);
$schema = new Schema(['query' => $queryType]);
// Execute an incoming query string against the schema:
$result = GraphQL::executeQuery($schema, $incomingQueryString);
echo json_encode($result->toArray()); // -> { "data": { "user": {...} } }
?>For Laravel, Lighthouse builds on this engine with a schema-first, directive-driven style so you write far less of this wiring by hand — but knowing the raw resolver model first means you understand what it's doing.
5️⃣ Why GraphQL? Over- and Under-Fetching
The clearest reason to reach for GraphQL is the data-fetching problem REST runs into. With REST, each endpoint returns a fixed shape: a screen that needs a user plus their posts plus comments often means several requests (under-fetching), while an endpoint that returns every field forces clients to download data they'll never show (over-fetching). GraphQL collapses that into one request against one endpoint, returning precisely the fields asked for.
# THE CORE PROBLEM GraphQL SOLVES: over- and under-fetching.
# --- REST: under-fetching needs MANY round trips ---
GET /users/1 # returns the user (but not their posts)
GET /users/1/posts # second request for the posts
GET /posts/9/comments # third request for the comments...
# --- REST: over-fetching returns fields you don't need ---
GET /users/1 # returns id, name, email, address, phone, settings...
# ...even if the page only shows the name.
# --- GraphQL: ONE request, EXACTLY the fields you want ---
query {
user(id: 1) {
name
posts { title comments { body } }
}
} # one endpoint, one round trip, no wasted bytes.Now you try the two core skills — finishing a schema and writing a query. Fill in each ___ using the 👉 hint, then check it against the Output panel.
# 🎯 YOUR TURN — finish the schema for a simple "book" API.
# Fill in each blank marked ___ , keeping the types valid.
type Book {
id: ID!
title: ___ # 👉 a required text field, so: String!
pages: Int!
}
# The READ operations live on the special "Query" type:
type ___ { # 👉 the reserved read-root type name: Query
book(id: ID!): Book
books: [Book!]!
}
# ✅ A client can then run: query { book(id: 1) { title pages } }One more. Write the smallest possible query that asks for just one field.
# 🎯 YOUR TURN — write a query that fetches only a book's title.
# The endpoint is the same /graphql; you choose the operation + fields.
___ { # 👉 the read operation keyword: query
book(id: 1) {
___ # 👉 ask for just one field: title
}
}
# ✅ Expected response:
# { "data": { "book": { "title": "Clean Code" } } }Common Errors (and the fix)
- "Cannot query field X on type Y" — you asked for a field that isn't in the schema (often a typo or a field you forgot to add). Check the type definition; the schema is the single source of truth.
- "Cannot return null for non-nullable field" — a resolver returned null for a field marked !. Either make the field nullable (drop the !) or ensure the resolver always supplies a value.
- Using a query to change data — writes belong on the Mutation type. Define the operation under type Mutation and call it with the mutation keyword, not query.
- The N+1 problem on nested fields — resolving posts for many users fires one DB query per user. Batch them with a data-loader (or eager-load) so related records load in one query.
- Hitting multiple URLs like REST — there's only one endpoint. Don't append the type to the path; POST your whole query to /graphql.
Pro Tips
- 💡 Design the schema first. Agree on types and fields before writing resolvers — the schema is the contract both client and server build against.
- 💡 Use Lighthouse on Laravel. Its directives let you map fields straight to Eloquent models, cutting most resolver boilerplate.
- 💡 Limit query depth. Because clients choose the shape, add depth/complexity limits so a deeply nested query can't overload your server.
📋 Quick Reference — GraphQL in PHP
| Concept / Snippet | What It Means |
|---|---|
| type User | Define an object type in SDL |
| String! | A non-nullable (required) field |
| type Query | Root type listing all read operations |
| type Mutation | Root type listing all write operations |
| query | Read data, choosing exact fields |
| mutation | Create, update, or delete data |
| resolve => fn(...) | Function that returns a field's value |
| webonyx/graphql-php | Core PHP GraphQL server engine |
| Lighthouse | Schema-first GraphQL for Laravel |
Mini-Challenge: A GraphQL Todo API
No code is filled in this time — just a brief and an outline. Build it with webonyx/graphql-php: define a Todo type, a query that lists todos, and a mutation that adds one, then wire the single endpoint. This is the full read-and-write GraphQL loop you'll use in real services.
<?php
// 🎯 MINI-CHALLENGE: A minimal GraphQL "todo" API in PHP.
// No code is filled in — work from the steps below.
//
// 1. Install the engine: composer require webonyx/graphql-php
// 2. Define a Todo ObjectType with fields:
// id (ID!), title (String!), done (Boolean!)
// 3. Define a Query type with a field "todos" that returns [Todo!]!,
// whose resolver returns an array of todos (from memory or a DB).
// 4. Define a Mutation type with a field "addTodo(title: String!): Todo!"
// whose resolver creates and returns the new todo.
// 5. Build new Schema(['query' => $queryType, 'mutation' => $mutationType]).
// 6. Read the incoming query from php://input, run
// GraphQL::executeQuery($schema, $query, null, null, $variables),
// and echo json_encode($result->toArray()).
//
// ✅ Expected: POSTing query { todos { title done } } returns your list;
// POSTing mutation { addTodo(title:"Ship it") { id } } adds one.
// your code here
?>🎉 Lesson Complete!
- ✅ A typed schema in SDL is the contract; ! marks non-nullable fields
- ✅ Queries read data and return exactly the fields you ask for, even nested
- ✅ Mutations create, update, and delete — and return chosen fields of the result
- ✅ Resolvers are PHP functions that produce each field's value
- ✅ One endpoint replaces many REST routes, fixing over- and under-fetching
- ✅ Build it with webonyx/graphql-php, or Lighthouse on Laravel
Practice quiz
How many HTTP endpoints does a typical GraphQL API expose?
- One endpoint for everything (usually /graphql)
- One per resource, like REST
- Two: one read, one write
- One per field
Answer: One endpoint for everything (usually /graphql). GraphQL exposes a single endpoint. The client describes exactly what it wants in the request body, rather than hitting many resource URLs.
In GraphQL, what is a 'query' used for?
- Defining the schema
- Creating new data
- Reading (fetching) data
- Deleting data
Answer: Reading (fetching) data. A query reads data. Operations that change data (create, update, delete) are mutations.
Which operation type changes data on the server?
- fragment
- mutation
- subscription
- query
Answer: mutation. Mutations create, update, or delete data. Queries are read-only by convention.
What is a resolver?
- A caching layer
- The HTTP router
- A database table
- A function that returns the value for a field
Answer: A function that returns the value for a field. Each field is backed by a resolver function that produces its value, often by reading a database or calling another service.
What problem does GraphQL solve compared to REST?
- Over-fetching and under-fetching of data
- It makes PHP run faster
- It removes the need for a database
- It encrypts traffic automatically
Answer: Over-fetching and under-fetching of data. REST endpoints return fixed shapes, causing over-fetching (too much) or under-fetching (needing multiple round trips). GraphQL lets the client request exactly the fields it needs.
Which PHP library is the de-facto core engine for building GraphQL servers?
- symfony/console
- webonyx/graphql-php
- phpunit/phpunit
- guzzlehttp/guzzle
Answer: webonyx/graphql-php. webonyx/graphql-php is the reference PHP implementation of the GraphQL spec, and most higher-level tools build on it.
Which package is the popular GraphQL solution specifically for Laravel?
- Eloquent
- Blade
- Horizon
- Lighthouse
Answer: Lighthouse. Lighthouse lets you build a GraphQL server in Laravel using a schema-first, directive-driven approach on top of webonyx/graphql-php.
What is the SDL in GraphQL?
- A database driver
- A query caching protocol
- The Schema Definition Language used to describe types and fields
- A PHP extension
Answer: The Schema Definition Language used to describe types and fields. The Schema Definition Language (SDL) is the typed, human-readable syntax for declaring object types, fields, queries, and mutations.
In the type 'type User { id: ID! name: String! }', what does the '!' mean?
- The field is private
- The field is non-nullable (required)
- The field is deprecated
- The field is a list
Answer: The field is non-nullable (required). A trailing ! marks a field as non-nullable: the server guarantees it will always return a value of that type.
Why is GraphQL strongly typed via its schema?
- So the schema documents the API and validates every query before execution
- To replace the need for HTTPS
- So clients can skip authentication
- To slow down responses
Answer: So the schema documents the API and validates every query before execution. The typed schema is a contract: it self-documents the API, powers tooling and autocompletion, and lets the server validate a query's shape before any resolver runs.
Continue this course
- Previous: Getting Started with Laravel
- Next: Real-Time PHP with WebSockets — Push live updates with WebSockets, Ratchet and Laravel Reverb broadcasting
- Quick reference: PHP cheat sheet
Frequently asked questions
What is the difference between GraphQL and REST?
REST exposes many endpoints, one per resource, and each returns a fixed shape — so you often over-fetch (get fields you don't need) or under-fetch (make several requests to assemble a screen). GraphQL exposes a single endpoint and a typed schema; the client sends a query describing exactly the fields and relationships it wants, and gets precisely that back in one round trip. REST is simpler and benefits from HTTP caching; GraphQL shines when clients need flexible, nested data.
What is a resolver and where does it run?
A resolver is a server-side function attached to a field in your schema. When a query asks for that field, the resolver runs and returns its value — typically by reading a database, calling an internal service, or following a relationship. Resolvers run on the server, compose naturally (a User resolver can call a posts resolver for nested data), and are where you enforce authorization and shape the data the client receives.
Which PHP libraries do I use to build a GraphQL API?
The foundation is webonyx/graphql-php, the reference PHP implementation of the GraphQL specification — it parses queries, validates them against your schema, and executes resolvers. For Laravel, Lighthouse builds on top of it with a schema-first, directive-driven workflow so you write less boilerplate. There are also clients and frameworks like API Platform that can serve GraphQL. For learning the fundamentals, webonyx/graphql-php is the place to start.
Why does GraphQL use a single endpoint instead of many?
Because the query itself describes what you want, the server doesn't need a different URL per resource — one endpoint (commonly POST /graphql) can answer any request the schema allows. This keeps the API surface tiny and stable: adding new fields or types doesn't add new routes, clients evolve their queries without the server changing URLs, and versioning is usually handled by deprecating fields rather than spinning up /v2 endpoints.
Does GraphQL replace my database or my framework?
No. GraphQL is only a query language and a runtime for your API layer — it sits in front of whatever data sources you already have. Your resolvers still read from a database (often via an ORM like Eloquent or Doctrine), call other services, or hit caches. You can run GraphQL alongside an existing REST API, and inside a framework like Laravel or Symfony. Think of it as a flexible, typed gateway to your data, not a storage engine.