Building GraphQL APIs in Laravel with Lighthouse
REST APIs have served us well for years, but they have clear limitations: over-fetching (getting more data than needed), under-fetching (needing multiple endpoints), and lack of type safety. GraphQL solves all of these by letting clients specify exactly the data they need.
Lighthouse is the most popular package for building GraphQL servers in Laravel. It integrates tightly with Eloquent and leverages the entire Laravel ecosystem (auth, policies, validation, events).
REST vs GraphQL — Understand Before You Choose
The Problem with REST
Imagine you have a profile page showing user info, a list of posts, and follower count:
REST: 3 HTTP requests
GET /api/users/1 → { id, name, email, avatar, bio, created_at, ... }
GET /api/users/1/posts → [{ id, title, body, tags, ... }, ...]
GET /api/users/1/followers → [{ id, name, ... }, ...]
Problem 1: Over-fetching. The /users/1 API returns bio, created_at, phone... which the mobile profile page doesn't need.
Problem 2: Under-fetching. 3 requests for 1 page. On slow networks, the UI must show loading states 3 times.
GraphQL: 1 Request, Exact Data Needed
query {
user(id: 1) {
name
avatar
posts(first: 5) {
title
publishedAt
}
followersCount
}
}
1 request. Only the exact fields needed. Nothing more, nothing less.
When Should You Use GraphQL?
| Use GraphQL | Keep REST |
|---|---|
| Complex frontend (SPA, mobile) | Simple CRUD |
| Multiple client types (web, iOS, Android) | Public API, needs HTTP caching |
| Complex data relationships | Webhooks, file uploads |
| Frontend team wants autonomy | Internal microservices |
| Real-time subscriptions | SEO-heavy apps (server-rendered) |
Installing Lighthouse
composer require nuwave/lighthouse
# Publish schema file
php artisan vendor:publish --tag=lighthouse-schema
# Publish config (optional)
php artisan vendor:publish --tag=lighthouse-config
After installation, Lighthouse creates a schema file at graphql/schema.graphql — this is where you declare your entire API.
IDE Support
# GraphQL Playground (API testing interface)
composer require mll-lab/laravel-graphql-playground
# Access at: http://your-app.test/graphql-playground
Schema — The Heart of GraphQL
Defining Types
# graphql/schema.graphql
"A user in the system"
type User {
id: ID!
name: String!
email: String!
avatar: String
bio: String
posts: [Post!]! @hasMany
comments: [Comment!]! @hasMany
followers: [User!]! @belongsToMany
followersCount: Int! @count(relation: "followers")
createdAt: DateTime! @rename(attribute: "created_at")
updatedAt: DateTime! @rename(attribute: "updated_at")
}
"A blog post"
type Post {
id: ID!
title: String!
slug: String!
body: String!
excerpt: String
publishedAt: DateTime @rename(attribute: "published_at")
isPublished: Boolean! @rename(attribute: "is_published")
author: User! @belongsTo
tags: [Tag!]! @belongsToMany
comments: [Comment!]! @hasMany
commentsCount: Int! @count(relation: "comments")
readingTime: Int! @method(name: "getReadingTime")
createdAt: DateTime! @rename(attribute: "created_at")
}
"A tag/label for posts"
type Tag {
id: ID!
name: String!
slug: String!
posts: [Post!]! @belongsToMany
postsCount: Int! @count(relation: "posts")
}
"A comment"
type Comment {
id: ID!
body: String!
author: User! @belongsTo
post: Post! @belongsTo
createdAt: DateTime! @rename(attribute: "created_at")
}
Syntax explanation:
!= non-null (required).String!= always has a value.String= can be null.[Post!]!= non-null array containing non-null Posts.[]will be an empty array, never null.@hasMany,@belongsTo,@belongsToMany= Lighthouse directives mapping to Eloquent relationships. Lighthouse resolves them automatically.@count(relation: "followers")= Automatically runswithCount('followers'), no resolver needed.@method(name: "getReadingTime")= Calls a method on the Eloquent model.@rename(attribute: "created_at")= Maps fieldcreatedAt(camelCase) to columncreated_at(snake_case).
Queries — Reading Data
# graphql/schema.graphql (continued)
type Query {
"Get a user by ID"
user(id: ID! @eq): User @find
"Get the currently authenticated user"
me: User @auth
"List users with pagination"
users(
name: String @where(operator: "like")
): [User!]! @paginate(defaultCount: 15)
"Get a post by slug"
post(slug: String! @eq): Post @find
"List published posts"
posts(
orderBy: _ @orderBy(columns: ["created_at", "title"])
hasTag: String @scope(name: "withTag")
): [Post!]! @paginate(
defaultCount: 10
scopes: ["published"]
)
"Search posts"
searchPosts(query: String! @search): [Post!]! @paginate(defaultCount: 10)
"All tags"
tags: [Tag!]! @all
}
Directive explanations:
@find: Find a single record by condition. Combined with@eqon the argument to map toWHERE slug = ?.@paginate: Automatically generates pagination. The client receivesdata,paginatorInfo(total, currentPage, lastPage...).@auth: Returns the currently authenticated user. No resolver needed.@where(operator: "like"): Automatically addsWHERE name LIKE ?when the client passes thenameargument.@orderBy: Lets clients choose sorting order.@scope(name: "withTag"): CallsscopeWithTag()on the Post model.@search: Integrates with Laravel Scout for full-text search.
Example Client Queries
# Get a paginated list of posts
query {
posts(first: 10, page: 1, orderBy: [{ column: CREATED_AT, order: DESC }]) {
data {
title
slug
excerpt
publishedAt
author {
name
avatar
}
tags {
name
}
commentsCount
}
paginatorInfo {
currentPage
lastPage
total
hasMorePages
}
}
}
# Get a single post's details
query {
post(slug: "laravel-caching-tips") {
title
body
readingTime
publishedAt
author {
name
bio
}
tags {
name
slug
}
comments {
body
author {
name
}
createdAt
}
}
}
# Get current user info
query {
me {
name
email
posts(first: 5) {
data {
title
commentsCount
}
}
}
}
Mutations — Creating, Updating, Deleting Data
# graphql/schema.graphql (continued)
type Mutation {
"Create a new post"
createPost(input: CreatePostInput! @spread): Post!
@guard
@can(ability: "create", model: "App\\Models\\Post")
"Update a post"
updatePost(id: ID!, input: UpdatePostInput! @spread): Post!
@guard
@can(ability: "update", find: "id")
"Delete a post"
deletePost(id: ID! @whereKey): Post!
@guard
@can(ability: "delete", find: "id")
@delete
"Add a comment"
createComment(input: CreateCommentInput!): Comment!
@guard
"Login"
login(input: LoginInput!): AuthPayload!
"Register"
register(input: RegisterInput!): AuthPayload!
}
input CreatePostInput {
title: String! @rules(apply: ["required", "string", "max:255"])
body: String! @rules(apply: ["required", "string", "min:100"])
excerpt: String @rules(apply: ["nullable", "string", "max:500"])
tags: [ID!] @rules(apply: ["array"])
is_published: Boolean
}
input UpdatePostInput {
title: String @rules(apply: ["sometimes", "string", "max:255"])
body: String @rules(apply: ["sometimes", "string", "min:100"])
excerpt: String @rules(apply: ["nullable", "string", "max:500"])
tags: [ID!]
is_published: Boolean
}
input CreateCommentInput {
post_id: ID! @rules(apply: ["required", "exists:posts,id"])
body: String! @rules(apply: ["required", "string", "min:10", "max:2000"])
}
input LoginInput {
email: String! @rules(apply: ["required", "email"])
password: String! @rules(apply: ["required", "string"])
}
input RegisterInput {
name: String! @rules(apply: ["required", "string", "max:255"])
email: String! @rules(apply: ["required", "email", "unique:users,email"])
password: String! @rules(apply: ["required", "string", "min:8", "confirmed"])
password_confirmation: String!
}
type AuthPayload {
token: String!
user: User!
}
Key directive explanations:
@guard: Requires authentication. Equivalent to theauthmiddleware in REST.@can(ability: "create"): Uses Laravel Policy.PostPolicy::create()must returntrue.@can(ability: "update", find: "id"): Finds the Post byid, then checksPostPolicy::update($user, $post).@rules(apply: [...]): Validation rules identical to Laravel. Validation errors are formatted as standard GraphQL errors.@spread: "Spreads" the input object into individual arguments. Instead of receiving['input' => [...]], the resolver gets a flat array.@delete: Automatically deletes the model and returns the deleted model.
Custom Resolvers for Complex Mutations
Some mutations need more complex logic than directives can provide:
// app/GraphQL/Mutations/CreateComment.php
namespace App\GraphQL\Mutations;
use App\Models\Comment;
use App\Models\Post;
use App\Notifications\NewCommentNotification;
use Illuminate\Support\Facades\Auth;
final class CreateComment
{
public function __invoke(mixed $root, array $args): Comment
{
$input = $args['input'];
$comment = Comment::create([
'body' => $input['body'],
'post_id' => $input['post_id'],
'user_id' => Auth::id(),
]);
// Notify post author
$post = Post::find($input['post_id']);
if ($post->user_id !== Auth::id()) {
$post->author->notify(new NewCommentNotification($comment));
}
return $comment;
}
}
Declare in schema:
type Mutation {
createComment(input: CreateCommentInput!): Comment!
@guard
@field(resolver: "App\\GraphQL\\Mutations\\CreateComment")
}
Authentication Resolver
// app/GraphQL/Mutations/Login.php
namespace App\GraphQL\Mutations;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use GraphQL\Error\Error;
final class Login
{
public function __invoke(mixed $root, array $args): array
{
$user = User::where('email', $args['input']['email'])->first();
if (!$user || !Hash::check($args['input']['password'], $user->password)) {
throw new Error('Invalid credentials.');
}
return [
'token' => $user->createToken('api')->plainTextToken,
'user' => $user,
];
}
}
Solving the N+1 Query Problem
This is the biggest challenge in GraphQL. When clients query nested relationships:
query {
posts(first: 10) {
data {
title
author { name } # N+1: 1 query per post
tags { name } # Another N+1!
}
}
}
Without handling this, it runs: 1 (posts) + 10 (authors) + 10 (tags) = 21 queries for 10 posts.
Lighthouse Automatic Batch Loading
Lighthouse uses the DataLoader pattern (batching) automatically for directives like @belongsTo, @hasMany. It groups all author IDs and runs a single query:
-- Instead of 10 separate queries:
SELECT * FROM users WHERE id = 1;
SELECT * FROM users WHERE id = 2;
...
-- Lighthouse runs 1 query:
SELECT * FROM users WHERE id IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
Result: 1 (posts) + 1 (authors batch) + 1 (tags batch) = 3 queries.
Manual Eager Loading When Needed
type Query {
posts: [Post!]! @paginate @with(relation: "author.profile")
}
@with is equivalent to Post::with('author.profile'). Use when you always need that relationship.
Monitoring Queries
// config/lighthouse.php
'query_log' => [
'enable' => env('LIGHTHOUSE_QUERY_LOG', false),
'channel' => 'daily',
],
Subscriptions — Real-time Updates
# graphql/schema.graphql
type Subscription {
"Receive notifications when a new comment is added to a post"
commentAdded(postId: ID!): Comment!
"Receive notifications when a post is published"
postPublished: Post!
}
Broadcasting from a Mutation
// app/GraphQL/Mutations/CreateComment.php
use Nuwave\Lighthouse\Execution\Utils\Subscription;
final class CreateComment
{
public function __invoke(mixed $root, array $args): Comment
{
$comment = Comment::create([/* ... */]);
// Broadcast to all subscribers
Subscription::broadcast('commentAdded', $comment);
return $comment;
}
}
Subscription Filter
// app/GraphQL/Subscriptions/CommentAdded.php
namespace App\GraphQL\Subscriptions;
use Illuminate\Http\Request;
use Nuwave\Lighthouse\Schema\Types\GraphQLSubscription;
use Nuwave\Lighthouse\Subscriptions\Subscriber;
final class CommentAdded extends GraphQLSubscription
{
/**
* Check who is allowed to subscribe
*/
public function authorize(Subscriber $subscriber, Request $request): bool
{
return true; // Or check auth
}
/**
* Filter: only send to subscribers interested in this post
*/
public function filter(Subscriber $subscriber, mixed $root): bool
{
return $subscriber->args['postId'] === $root->post_id;
}
}
Lighthouse Configuration
// config/lighthouse.php
return [
'route' => [
'uri' => '/graphql',
'middleware' => [
\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
],
],
'guard' => 'sanctum',
'namespaces' => [
'models' => ['App\\Models'],
'queries' => 'App\\GraphQL\\Queries',
'mutations' => 'App\\GraphQL\\Mutations',
'subscriptions' => 'App\\GraphQL\\Subscriptions',
],
// Limit query depth (anti-abuse)
'security' => [
'max_query_depth' => 10,
'max_query_complexity' => 200,
'disable_introspection' => env('LIGHTHOUSE_DISABLE_INTROSPECTION', false),
],
'pagination' => [
'default_count' => 15,
'max_count' => 100,
],
];
Security settings explained:
max_query_depth: 10: Prevents deeply nested queries:user → posts → comments → author → posts → .... An attacker could create infinite-depth queries to DDoS your server.max_query_complexity: 200: Each field has a "cost". Queries with total cost > 200 are rejected.disable_introspection: Disable introspection in production. Introspection lets anyone view your entire schema — useful for development but exposes information in production.
Testing the GraphQL API
// tests/Feature/GraphQL/PostQueryTest.php
namespace Tests\Feature\GraphQL;
use App\Models\Post;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PostQueryTest extends TestCase
{
use RefreshDatabase;
public function test_can_query_published_posts(): void
{
$author = User::factory()->create();
$posts = Post::factory()
->count(3)
->for($author, 'author')
->published()
->create();
$response = $this->graphQL(/** @lang GraphQL */ '
query {
posts(first: 10) {
data {
title
slug
author {
name
}
}
paginatorInfo {
total
}
}
}
');
$response->assertJson([
'data' => [
'posts' => [
'paginatorInfo' => [
'total' => 3,
],
],
],
]);
$response->assertJsonCount(3, 'data.posts.data');
}
public function test_can_create_post_when_authenticated(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->graphQL(/** @lang GraphQL */ '
mutation($input: CreatePostInput!) {
createPost(input: $input) {
id
title
slug
}
}
', [
'input' => [
'title' => 'My Test Post',
'body' => str_repeat('This is a test post body. ', 10),
'is_published' => true,
],
]);
$response->assertJson([
'data' => [
'createPost' => [
'title' => 'My Test Post',
'slug' => 'my-test-post',
],
],
]);
$this->assertDatabaseHas('posts', [
'title' => 'My Test Post',
'user_id' => $user->id,
]);
}
public function test_cannot_create_post_when_not_authenticated(): void
{
$response = $this->graphQL(/** @lang GraphQL */ '
mutation {
createPost(input: {
title: "Unauthorized Post"
body: "Should fail"
}) {
id
}
}
');
$response->assertGraphQLErrorMessage('Unauthenticated.');
}
public function test_validates_post_input(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->graphQL(/** @lang GraphQL */ '
mutation {
createPost(input: {
title: ""
body: "short"
}) {
id
}
}
');
$response->assertGraphQLValidationError('title', 'The title field is required.');
}
}
Performance Tips
1. Persisted Queries
Instead of sending the full query string with every request, the client sends a hash:
# Normal request (sends full query)
POST /graphql
{"query": "{ posts { data { title slug } } }"}
# Persisted query (sends hash)
POST /graphql
{"extensions": {"persistedQuery": {"sha256Hash": "abc123..."}}}
2. Response Caching
composer require nuwave/lighthouse-cache
type Query {
# Cache for 1 hour
posts: [Post!]! @paginate @cache(maxAge: 3600)
# Cache per user
me: User @auth @cache(maxAge: 300, private: true)
}
3. Schema Caching (Production)
# Cache schema for production
php artisan lighthouse:cache
# Clear cache on deploy
php artisan lighthouse:clear-cache
Complete Directory Structure
graphql/
├── schema.graphql # Root schema
├── user.graphql # User type & queries
├── post.graphql # Post type & queries
└── comment.graphql # Comment type & queries
app/GraphQL/
├── Mutations/
│ ├── CreateComment.php
│ ├── CreatePost.php
│ └── Login.php
├── Queries/
│ └── SearchPosts.php
├── Subscriptions/
│ └── CommentAdded.php
└── Validators/
└── CreatePostValidator.php
Split the schema into multiple files:
# graphql/schema.graphql
#import user.graphql
#import post.graphql
#import comment.graphql
Conclusion
GraphQL + Lighthouse is a powerful combo for Laravel APIs. The biggest advantages:
- Schema-first: Declare your API with clear SDL, easy to read and understand
- Directives: 80% of the logic is handled by directives, less code than REST
- Type safety: Clients know exactly what data they'll receive
- Performance: Automatic DataLoader, caching, persisted queries
Recommended roadmap:
- Start with Queries +
@paginate,@find - Add Mutations + validation + auth
- Optimize N+1 (Lighthouse usually handles this already)
- Add Subscriptions if you need real-time
- Production: schema cache, persisted queries, disable introspection