APIs

GraphQL for PHP Developers: Creating Flexible APIs

Build a GraphQL API in PHP using Lighthouse and webonyx/graphql-php. Covers schema design, resolvers, N+1 prevention, and when to choose GraphQL over REST.

GraphQL query and response diagram with PHP backend

GraphQL lets API consumers request exactly the data they need in a single round-trip. For PHP developers accustomed to REST, the shift is conceptual more than technical. You are still writing PHP resolvers that hit a database—you are just letting the client describe the response shape instead of building fixed endpoints.

The question is not whether GraphQL is better than REST. It is whether your specific use case benefits from client-defined queries. If your API serves a mobile app and a web dashboard that need different data from the same resources, GraphQL probably saves you from maintaining multiple endpoint variations. If you have one frontend and simple CRUD, REST is simpler.

Setting up GraphQL in PHP

Option 1: webonyx/graphql-php (framework-agnostic)

1
composer require webonyx/graphql-php

This is the foundational PHP GraphQL library. Everything else (including Lighthouse) builds on it.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Definition\Type;
use GraphQL\Type\Schema;
use GraphQL\GraphQL;

$articleType = new ObjectType([
'name' => 'Article',
'fields' => [
'id' => Type::nonNull(Type::id()),
'title' => Type::nonNull(Type::string()),
'body' => Type::string(),
'published_at' => Type::string(),
],
]);

$queryType = new ObjectType([
'name' => 'Query',
'fields' => [
'article' => [
'type' => $articleType,
'args' => [
'id' => Type::nonNull(Type::id()),
],
'resolve' => function ($root, array $args): ?array {
return DB::table('articles')->find($args['id']);
},
],
],
]);

$schema = new Schema(['query' => $queryType]);

$rawInput = file_get_contents('php://input');
$input = json_decode($rawInput, true);

$result = GraphQL::executeQuery($schema, $input['query'], null, null, $input['variables'] ?? null);
echo json_encode($result->toArray());

This works but requires significant manual wiring for larger APIs.

Option 2: Lighthouse (Laravel)

1
2
composer require nuwave/lighthouse
php artisan vendor:publish --tag=lighthouse-schema

Lighthouse lets you define your API in .graphql schema files and maps them to Eloquent models automatically:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# graphql/schema.graphql

type Query {
article(id: ID! @eq): Article @find
articles(
title: String @where(operator: "like")
orderBy: _ @orderBy(columns: ["created_at", "title"])
): [Article!]! @paginate
}

type Article {
id: ID!
title: String!
body: String!
published_at: DateTime
author: User! @belongsTo
comments: [Comment!]! @hasMany
}

type User {
id: ID!
name: String!
email: String!
articles: [Article!]! @hasMany
}

That schema file replaces hundreds of lines of PHP configuration. Lighthouse generates the resolvers, handles pagination, and integrates with Laravel’s authorization system.

Schema design

Types

Think of GraphQL types as the nouns of your API:

1
2
3
4
5
6
7
8
9
10
11
12
type Article {
id: ID!
title: String!
slug: String!
body: String!
excerpt: String
published_at: DateTime
author: User!
category: Category!
tags: [Tag!]!
comments(first: Int!, page: Int): CommentPaginator
}

The ! suffix means non-nullable. String! guarantees the field always returns a string. [Tag!]! means the field returns a non-null array of non-null Tag objects.

Queries

1
2
3
4
5
6
7
8
9
10
11
type Query {
article(id: ID!): Article
article_by_slug(slug: String!): Article

articles(
category: String
tag: String
first: Int! = 25
page: Int
): ArticlePaginator!
}

Mutations

1
2
3
4
5
6
7
8
9
10
11
12
type Mutation {
createArticle(input: CreateArticleInput!): Article! @guard
updateArticle(id: ID!, input: UpdateArticleInput!): Article! @guard
deleteArticle(id: ID!): Article! @guard @can(ability: "delete")
}

input CreateArticleInput {
title: String!
body: String!
category_id: ID!
tags: [String!]
}

Resolving N+1 queries

This is the single biggest performance problem in GraphQL APIs. Consider this query:

1
2
3
4
5
6
7
8
9
10
{
articles(first: 25) {
data {
title
author {
name
}
}
}
}

Without optimization, this executes 1 query for articles + 25 queries for each author. With 25 articles, that is 26 database queries for one API call.

Solution: DataLoader / batch loading

Lighthouse handles this automatically with its @belongsTo directive, which uses Laravel’s eager loading:

1
2
3
type Article {
author: User! @belongsTo # Lighthouse batches this automatically
}

With webonyx/graphql-php, you implement batch loading manually:

1
2
3
4
5
6
7
8
9
10
11
12
use GraphQL\Deferred;

'author' => [
'type' => $userType,
'resolve' => function (array $article) {
// Collect all author IDs, then batch-load
UserLoader::queue($article['author_id']);
return new Deferred(function () use ($article) {
return UserLoader::load($article['author_id']);
});
},
],

Authentication and authorization

Per-field authorization with Lighthouse

1
2
3
4
5
6
type User {
id: ID!
name: String!
email: String! @can(ability: "viewEmail")
admin_notes: String @guard(with: ["admin"])
}

The email field is only visible if the authenticated user passes the viewEmail policy check. admin_notes requires admin authentication.

Custom resolver authorization

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class ArticleMutator
{
public function create($root, array $args, GraphQLContext $context): Article
{
$user = $context->user();

if (!$user->can('create', Article::class)) {
throw new AuthorizationException('Not authorized to create articles.');
}

return Article::create([
'title' => $args['input']['title'],
'body' => $args['input']['body'],
'author_id' => $user->id,
]);
}
}

Query complexity and security

GraphQL’s flexibility is also its attack surface. A malicious client can craft deeply nested queries that overwhelm your server:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Malicious query
{
articles {
data {
author {
articles {
data {
author {
articles {
data { title }
}
}
}
}
}
}
}
}

Defenses

1
2
3
4
5
6
7
8
9
// Query depth limiting
use GraphQL\Validator\Rules\QueryDepth;

$rules = [new QueryDepth(7)]; // Max 7 levels deep

// Query complexity limiting
use GraphQL\Validator\Rules\QueryComplexity;

$rules[] = new QueryComplexity(500); // Max 500 complexity points

In Lighthouse, configure in config/lighthouse.php:

1
2
3
4
5
'security' => [
'max_query_complexity' => 500,
'max_query_depth' => 7,
'disable_introspection' => env('LIGHTHOUSE_DISABLE_INTROSPECTION', false),
],

Disable introspection in production unless you have a specific need for it.

Common mistakes

Exposing your entire database schema: GraphQL types should represent your API contract, not your table structure. Create dedicated types that hide internal columns.

Not paginating: Unlike REST where you might forget pagination on one endpoint, GraphQL makes it easy to request all records. Use @paginate in Lighthouse or implement cursor-based pagination manually.

Over-fetching in resolvers: Just because a client can request a field does not mean your resolver should always compute it. Use lazy resolution for expensive fields.

Treating GraphQL as a REST replacement everywhere: GraphQL adds complexity. For simple APIs with one consumer, REST is less code, easier to cache, and simpler to debug.

Production tradeoffs

Caching: REST responses are trivially cacheable with HTTP caching. GraphQL POST requests are not. You need application-level caching (persisted queries, Redis response cache) or move to GET-based persisted queries.

Error handling: GraphQL always returns HTTP 200, even for errors. The actual error information is in the response body. This confuses monitoring tools that rely on HTTP status codes.

Documentation: GraphQL’s schema is self-documenting via introspection. But the documentation only describes structure, not behavior. You still need to document business rules, rate limits, and authentication requirements separately.

Team learning curve: Developers familiar with REST need time to think in terms of types and resolvers instead of endpoints. Budget for this.

FAQ

Can I use GraphQL and REST together?

Yes, and many production APIs do. Serve REST endpoints for simple operations and add a GraphQL endpoint for complex, flexible queries.

Is GraphQL faster than REST?

Not inherently. The network is often one round-trip either way. GraphQL reduces over-fetching (requesting fields you do not need) and under-fetching (making multiple requests to assemble related data). Whether that translates to speed depends on your specific data patterns.

Should I use subscriptions in PHP?

PHP’s request-response model makes subscriptions awkward. If you need real-time updates, use a dedicated WebSocket service (Node.js, Go) or Laravel Reverb. GraphQL subscriptions in PHP work with Swoole or ReactPHP but add significant operational complexity.

Next steps

If you are on Laravel, install Lighthouse and define a small schema for one resource. The experience of writing a .graphql file and having it just work with your Eloquent models is the fastest way to understand whether GraphQL fits your project.

For the REST alternative approach, see the REST API best practices guide. The PDO tutorial covers the database fundamentals that underpin both API approaches.