GraphQL — Schema, Resolvers, Authorization, Complexity, and the N+1 Problem
Write a small GraphQL schema (type, Query, Mutation) with matching PHP resolvers; use variables and fragments in queries; enforce authorization inside resolvers rather than only at the transport layer; limit query complexity to prevent abuse; and recognize and fix the N+1 query problem with DataLoader-style batching.
A REST endpoint returns a fixed shape decided by the server; a client that only needs an order's status still gets the whole order object, and a client that needs an order PLUS its customer's name has to make two round trips (or the server has to grow a bespoke ?expand= parameter for every combination anyone might want). GraphQL inverts this: the client sends a query describing exactly the shape of data it wants, across however many related types, and the server resolves precisely that shape in one round trip. That flexibility is genuinely powerful, but it shifts real engineering burden onto the server -- resolvers have to enforce authorization per-field since there's no longer one fixed endpoint to gate, and a naive resolver graph will issue a database query per related object unless it's deliberately batched.
Schema, Queries, Mutations, and Resolvers
A GraphQL schema declares types (type Order { id: ID!, status: OrderStatus!, customer: Customer! }), an enum for fixed value sets, a Query type listing every read operation available, and a Mutation type listing every write operation -- the schema is the whole contract, analogous to an OpenAPI spec but native to GraphQL's own type system and enforced by the GraphQL engine itself rather than a separate validation layer.
A resolver is the PHP function that actually produces the value for one field. Crucially, resolvers compose: the resolver for Query.order fetches the order row; the resolver for Order.customer (given the parent Order object it was resolved from) fetches that order's customer separately. The GraphQL engine walks the query the client sent and calls exactly the resolvers needed for the fields actually requested -- nothing more -- which is both GraphQL's core efficiency win over REST over-fetching and, as covered below, the exact mechanism behind the N+1 problem when it's naive about it.
• type -- declares a shape (Order, Customer) with typed, nullable-or-required fields. • Query -- every readable root field the API exposes (order(id), orders(status)). • Mutation -- every writable root field (createOrder, cancelOrder). • A resolver = one PHP function producing one field's value; resolvers compose down the query's shape.
Variables and Fragments
A query should never interpolate raw values into its text (the same injection risk as raw SQL string interpolation) -- instead it declares typed variables in its header and references them by name, with the actual values sent separately as a JSON variables object alongside the query string. This also lets clients cache and reuse the exact same query text across different calls with different values.
A fragment is a named, reusable selection of fields that can be spread into multiple queries -- e.g. a OrderSummaryFields fragment listing id, status, total_cents that both a 'list orders' query and a 'get one order' query can spread in, keeping the field list in exactly one place instead of copy-pasted across every query that needs it.
• Variables: query GetOrder($id: ID!) { order(id: $id) { ... } } -- values sent separately, never string-interpolated into the query. • Fragments: fragment OrderFields on Order { id status total_cents } -- reused with ...OrderFields in multiple queries. • Both are client-side query authoring tools; the server sees the fully-expanded query after fragment resolution.
Authorization at the Resolver Level
REST can gate an entire endpoint with one auth check before the handler runs. GraphQL has no equivalent single choke point for a given TYPE, because the same Order type might be reached from many different root queries and nested inside many different parent objects -- so authorization has to live inside each resolver that touches sensitive data, checking the current viewer against the specific object being resolved, not just against the operation name.
A common pattern is to pass the authenticated viewer through a shared 'context' object available to every resolver in the request, and have each sensitive resolver check it explicitly -- e.g. the Order.customer.email resolver checking that the current viewer either IS that customer or holds an admin role before returning the email, even though the outer order(id) query itself was already authorized to view the order's basic fields.
• No single endpoint-level gate exists in GraphQL the way there is in REST -- the same type can be reached from many paths. • Authorization must be checked per-resolver, against the specific object being resolved, using a shared request context carrying the viewer. • A field can be 'more sensitive' than its parent object -- e.g. an order is viewable but its customer's email requires an extra check.
Query Complexity Limiting
Because a client controls the shape of what it asks for, nothing stops a malicious or careless client from writing a deeply nested query -- orders, each with line items, each with a product, each with reviews, each with a reviewer, each with THEIR orders -- that fans out into an enormous amount of server work from a single, small-looking request. REST's fixed endpoints don't have this problem because the server, not the client, decides how much work each request does.
GraphQL servers defend against this by assigning a cost to each field (often proportional to how many items a list field can return) and rejecting any query whose total computed complexity exceeds a configured budget, before executing any resolver at all. Depth limiting (rejecting queries nested past N levels) is a simpler, complementary defense against the same class of attack.
• A deeply nested query can fan out into massive server work from one small-looking request -- unlike REST's fixed-cost endpoints. • Assign a cost per field (higher for list fields) and reject any query whose total exceeds a budget, before execution. • Depth limiting is a simpler complementary guard against unbounded nesting.
The N+1 Problem and DataLoader-Style Batching
If a query asks for 50 orders and each order's customer field, a naive resolver graph runs 1 query to fetch the 50 orders, then calls the Order.customer resolver separately FOR EACH order, each firing its own SELECT * FROM customers WHERE id = ? -- 1 + 50 = 51 queries for data that could have been fetched in 2. This is the N+1 problem, and it's the single most common GraphQL performance bug, because it's invisible in a query that returns only a few results and catastrophic in one that returns many.
The fix is batching: instead of each resolver hitting the database immediately, it registers the ID it needs with a shared per-request batching loader (the pattern popularized as 'DataLoader') and returns a promise-like placeholder; the loader waits until the current tick of resolution finishes collecting all the IDs anyone asked for, then fires ONE query (WHERE id IN (...)) for all of them at once, and hands each waiting resolver its specific result. The resolver code barely changes -- it just asks the loader for one ID -- but the actual database traffic drops from N+1 queries to 2.
• N+1: 1 query for the list + N separate queries, one per related object resolved individually -- invisible at small scale, catastrophic at large scale. • Fix: a per-request batching loader (DataLoader pattern) that collects all requested IDs across resolvers, then issues ONE WHERE id IN (...) query. • Batching must be scoped per-request (not global/shared across requests) to avoid leaking one user's batched data into another's.
A GraphQL Schema (SDL)
! marks a field non-nullable -- the server guarantees it, or the whole query returns an error at that point. Query and Mutation are just regular types whose fields are the API's root operations. Enums restrict a field to a known set of values, checked by the engine itself.
type Order {
id: ID!
status: OrderStatus!
totalCents: Int!
customer: Customer!
}
type Customer {
id: ID!
name: String!
email: String! # sensitive — checked per-resolver
}
enum OrderStatus {
PENDING
SHIPPED
DELIVERED
CANCELLED
}
type Query {
order(id: ID!): Order
orders(status: OrderStatus): [Order!]!
}
type Mutation {
cancelOrder(id: ID!): Order!
}Code Examples
The Query.order resolver checks the viewer against the specific order being fetched (not just 'is logged in'), and the nested Customer.email resolver applies its OWN, stricter check -- authorization is enforced at every level a client's query could actually reach sensitive data through. $ctx (RequestContext) is built once per request from the authenticated session and passed to every resolver the engine calls, so each resolver has consistent access to who is asking without re-deriving it.
if ($order->customerId !== $ctx->viewer->id && !$ctx->viewer->isAdmin) {
Instead of each order's customer resolver querying the database immediately and individually, it registers its customer ID with a shared loader living on the request context; the loader batches every ID collected across all 50 orders into a single WHERE id IN (...) query. Expected result: 2 total queries (orders + one batched customers query) instead of 51 for a 50-order response
'SELECT * FROM customers WHERE id IN (' . implode(',', array_fill(0, count($ids), '?')) . ')'
Common mistake: Writing the naive version -- resolveOrderCustomer() calling Database::query('...WHERE id = ?', [$order->customerId]) directly -- which works correctly but fires one query per order and silently becomes a serious performance problem the moment result sets grow.
<?php
declare(strict_types=1);
final class OrderResolvers
{
public static function order(mixed $root, array $args, RequestContext $ctx): ?Order
{
$order = OrderRepository::find((int) $args['id']);
if ($order === null) {
return null;
}
// Authorization: viewer must own the order OR be an admin
if ($order->customerId !== $ctx->viewer->id && !$ctx->viewer->isAdmin) {
throw new GraphQLAuthorizationException('Not authorized to view this order');
}
return $order;
}
// Field resolver for Order.customer.email — extra check beyond the parent
public static function customerEmail(Customer $customer, array $args, RequestContext $ctx): string
{
if ($customer->id !== $ctx->viewer->id && !$ctx->viewer->isAdmin) {
throw new GraphQLAuthorizationException('Not authorized to view this email');
}
return $customer->email;
}
}<?php
declare(strict_types=1);
final class CustomerLoader
{
/** @var array<int, true> IDs collected during this request's resolution tick */
private array $pending = [];
/** @var array<int, Customer>|null cached results, populated after batchLoad() */
private ?array $loaded = null;
public function load(int $customerId): Customer
{
$this->pending[$customerId] = true;
// In a real DataLoader, this returns a deferred value resolved after batching;
// simplified here to show the batching call itself.
if ($this->loaded === null) {
$this->batchLoad();
}
return $this->loaded[$customerId];
}
private function batchLoad(): void
{
$ids = array_keys($this->pending);
// ONE query for every customer ID collected across ALL orders in this response,
// instead of one query per order.
$rows = Database::query(
'SELECT * FROM customers WHERE id IN (' . implode(',', array_fill(0, count($ids), '?')) . ')',
$ids
);
$this->loaded = [];
foreach ($rows as $row) {
$this->loaded[$row['id']] = Customer::fromRow($row);
…N+1 vs batched resolution
Batching collapses N individual lookups into one query, keyed by the full set of IDs actually requested.
Naive (N+1): Batched (DataLoader):
Query orders -> 1 query Query orders -> 1 query
order[0].customer -> query collect customerIds: [5,9,5,12,...]
order[1].customer -> query (wait for tick to finish)
order[2].customer -> query batchLoad(ids) -> 1 query WHERE id IN (...)
... (N times) hand each order its matching customer
= 1 + N queries = 2 queries totalImportant Rules
Never string-interpolate values into a GraphQL query -- always send them as typed variables, the same discipline as parameterized SQL.
Check authorization inside every resolver that can reach sensitive data, against the specific object being resolved -- not just once at the top of the request.
Assign per-field complexity costs and reject over-budget queries before executing any resolver.
Batch related-object lookups per request with a DataLoader-style loader; never let a list field trigger one query per item.
Scope batching loaders to a single request -- a loader shared across requests can leak one user's data into another's response.
Why GitHub and Shopify both expose GraphQL alongside REST
GitHub and Shopify both maintain a GraphQL API specifically for clients that need to fetch deeply related data (a repo, its issues, each issue's comments and reactions; a shop, its products, each product's variants and inventory) in one round trip instead of chaining many REST calls. Both platforms enforce strict query cost limits and require batching for exactly the reasons in this lesson -- their public GraphQL docs publish the cost formula so third-party developers can predict whether their own queries will be rejected before running them.
Authorizing only at the top-level Query.order resolver and assuming every nested field it returns is automatically safe to expose.
Add an explicit authorization check inside any nested resolver (like Customer.email) that returns data more sensitive than its parent object.
A client can reach the same sensitive field through a different path in the graph than the one you tested; per-field authorization is the only guarantee that holds regardless of how a field is reached.
Resolving Order.customer with a direct per-order database query and only noticing the slowdown once a client requests hundreds of orders in one query.
Use a per-request batching loader for every one-to-many or many-to-one resolver from the start, regardless of current result-set size.
N+1 is invisible in development and staging with small test datasets and only shows up as real latency once production traffic returns large result sets -- it is far cheaper to batch from day one than to retrofit it under an incident.
Why can't GraphQL rely on a single endpoint-level authorization check the way a typical REST endpoint can? A. GraphQL does not support authentication at all B. The same type can be reached from many different query paths, so a check at only the root query misses fields reached through nested resolvers C. GraphQL queries cannot carry authentication tokens D. Authorization in GraphQL must be done entirely on the client
B. The same type can be reached from many different query paths, so a check at only the root query misses fields reached through nested resolvers — Because a type like Customer can be nested under many different parent objects and root queries, authorization has to be enforced inside each resolver that can expose sensitive data, not just once at the top.
What does a DataLoader-style batching pattern primarily solve? A. It encrypts GraphQL responses in transit B. It collects IDs requested across many resolvers in one tick and issues a single batched query instead of one query per item (the N+1 problem) C. It automatically generates the GraphQL schema from a database D. It limits how deeply a query can be nested
B. It collects IDs requested across many resolvers in one tick and issues a single batched query instead of one query per item (the N+1 problem) — Batching collapses what would be N individual per-item queries (the N+1 problem) into a single WHERE id IN (...) query covering every ID collected during that request's resolution.
A resolver for Order.lineItems returns a list of LineItem objects, and each LineItem has a resolver for its product field that currently does Database::query('SELECT * FROM products WHERE id = ?', [$lineItem->productId]). Rewrite this as a batched resolver using the ProductLoader pattern shown for CustomerLoader above.
class ProductLoader { private array $pending = []; private ?array $loaded = null; public function load(int $id): Product { $this->pending[$id] = true; if ($this->loaded === null) $this->batchLoad(); return $this->loaded[$id]; } private function batchLoad(): void { $ids = array_keys($this->pending); $rows = Database::query('SELECT * FROM products WHERE id IN (' . implode(',', array_fill(0, count($ids), '?')) . ')', $ids); foreach ($rows as $r) $this->loaded[$r['id']] = Product::fromRow($r); } } Then LineItem.product resolver calls $ctx->productLoader->load($lineItem->productId).
Follow the same two-part shape: a load($productId) method that collects the pending ID and triggers batchLoad(), and a batchLoad() that issues one WHERE id IN (...) query for every collected ID.