Building GraphQL APIs in Laravel with Lighthouse

· 12 min read

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 runs withCount('followers'), no resolver needed.
  • @method(name: "getReadingTime") = Calls a method on the Eloquent model.
  • @rename(attribute: "created_at") = Maps field createdAt (camelCase) to column created_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 @eq on the argument to map to WHERE slug = ?.
  • @paginate: Automatically generates pagination. The client receives data, paginatorInfo (total, currentPage, lastPage...).
  • @auth: Returns the currently authenticated user. No resolver needed.
  • @where(operator: "like"): Automatically adds WHERE name LIKE ? when the client passes the name argument.
  • @orderBy: Lets clients choose sorting order.
  • @scope(name: "withTag"): Calls scopeWithTag() 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 the auth middleware in REST.
  • @can(ability: "create"): Uses Laravel Policy. PostPolicy::create() must return true.
  • @can(ability: "update", find: "id"): Finds the Post by id, then checks PostPolicy::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:

  1. Start with Queries + @paginate, @find
  2. Add Mutations + validation + auth
  3. Optimize N+1 (Lighthouse usually handles this already)
  4. Add Subscriptions if you need real-time
  5. Production: schema cache, persisted queries, disable introspection

Comments