Skip to main content
  1. Languages/
  2. PHP Guides/

Mastering GraphQL in Laravel with Lighthouse: A Practical Guide

Jeff Taakey
Author
Jeff Taakey
21+ Year CTO & Multi-Cloud Architect.

If you have been working with REST APIs for the better part of the last decade, you know the drill: multiple endpoints, over-fetching data you don’t need, under-fetching data you do need, and the endless cycle of versioning.

In the 2025 PHP landscape, GraphQL has solidified its place as the superior alternative for complex, data-driven applications. It provides a flexible and efficient approach to client-server communication. While raw PHP implementations exist, if you are in the Laravel ecosystem, there is one clear winner: Lighthouse.

Lighthouse is a schema-first framework that integrates seamlessly with Laravel/Eloquent. It removes the boilerplate usually associated with GraphQL, allowing you to define your schema and let the framework handle the heavy lifting.

In this guide, we will build a production-ready GraphQL API for a blogging platform. We’ll cover setup, schema design, relationships, mutations, and critical performance optimizations.

Prerequisites & Environment
#

Before we dive into the code, ensure your environment meets the modern standards expected for high-performance PHP development.

  • PHP: Version 8.2 or higher (we utilize modern typing features).
  • Composer: The latest v2.x.
  • Framework: Laravel 11 or 12.
  • Database: MySQL 8.0+ or PostgreSQL 14+.
  • Tools: Postman, Insomnia, or Altair for testing GraphQL queries.

We assume you have a basic understanding of Laravel’s Eloquent ORM and general API concepts.

Step 1: Project Initialization
#

Let’s start by spinning up a fresh Laravel project. If you are adding this to an existing project, skip the first command.

# Create a new Laravel project
composer create-project laravel/laravel blog-api

# Navigate into the directory
cd blog-api

# Install Lighthouse
composer require nuwave/lighthouse

# Publish the default schema and configuration
php artisan vendor:publish --tag=lighthouse-schema
php artisan vendor:publish --tag=lighthouse-config

We also highly recommend installing GraphiQL, a visual IDE for exploring your API.

composer require mll-lab/laravel-graphiql

Now, if you visit /graphiql in your browser (assuming your server is running), you should see the playground interface.

Step 2: Understanding the Schema-First Approach
#

Lighthouse is “schema-first.” This means you define what you want in a .graphql file, and Lighthouse figures out how to get it based on directives. This is a massive productivity booster compared to defining types in PHP classes.

Open graphql/schema.graphql. You will see some default examples. Let’s clear that file and visualize what we are about to build.

The Architecture
#

Here is how Lighthouse processes a request. It sits between the client and Laravel’s Eloquent ORM.

sequenceDiagram participant Client participant Lighthouse as Lighthouse (GraphQL) participant Model as Eloquent Model participant DB as Database Client->>Lighthouse: POST /graphql (Query) Note over Lighthouse: Validates against Schema Lighthouse->>Lighthouse: Parse Directives (@all, @find) Lighthouse->>Model: Build Query Model->>DB: SQL Select DB-->>Model: Result Set Model-->>Lighthouse: Collection/Object Lighthouse-->>Client: JSON Response

Step 3: Defining Models and Migrations
#

To demonstrate a real-world scenario, we need a User and a Post.

Run the following commands to generate the models and migrations:

php artisan make:model Post -m

Update your migrations to add some structure.

User Migration:

// database/migrations/xxxx_create_users_table.php
public function up(): void
{
    Schema::create('users', function (Blueprint $table) {
        $table->id();
        $table->string('name');
        $table->string('email')->unique();
        $table->timestamps();
    });
}

Post Migration:

// database/migrations/xxxx_create_posts_table.php
public function up(): void
{
    Schema::create('posts', function (Blueprint $table) {
        $table->id();
        $table->foreignId('user_id')->constrained()->onDelete('cascade');
        $table->string('title');
        $table->text('content');
        $table->boolean('is_published')->default(false);
        $table->timestamps();
    });
}

Post Model (app/Models/Post.php):

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Post extends Model
{
    use HasFactory;

    protected $fillable = ['title', 'content', 'is_published', 'user_id'];

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}

User Model (app/Models/User.php): Ensure you add the relationship:

public function posts()
{
    return $this->hasMany(Post::class);
}

Run your migrations:

php artisan migrate

Step 4: The Magic of Directives
#

Now comes the fun part. We will map these Eloquent models to GraphQL types using Lighthouse directives. Directives start with @ and tell Lighthouse how to resolve the data.

Open graphql/schema.graphql and define your schema:

"A user of the application."
type User {
    id: ID!
    name: String!
    email: String!
    created_at: DateTime!
    updated_at: DateTime!
    "Get all posts written by the user."
    posts: [Post!]! @hasMany
}

"A blog post."
type Post {
    id: ID!
    title: String!
    content: String!
    is_published: Boolean!
    "The author of the post."
    author: User! @belongsTo(relation: "user")
    created_at: DateTime!
    updated_at: DateTime!
}

type Query {
    "Get a specific user by ID."
    user(id: ID! @eq): User @find

    "List all users."
    users: [User!]! @all

    "List all posts, with optional pagination."
    posts: [Post!]! @paginate(defaultCount: 10)
}

Unpacking the Syntax
#

Directive Function Why it’s useful
@all Fetches all records from the model corresponding to the return type. Zero boilerplate for lists.
@find Fetches a single record. Automatically handles find($id) logic.
@eq Adds a WHERE column = value clause. Easy filtering by ID or other fields.
@hasMany Resolves an Eloquent hasMany relationship. Automatically fetches related data.
@belongsTo Resolves an Eloquent belongsTo relationship. Links child to parent.
@paginate implementing Laravel’s pagination. Essential for large datasets.

Step 5: Handling Mutations (Creating Data)
#

Reading data is great, but APIs need to modify data too. In GraphQL, these are called Mutations.

Add the following to your schema.graphql:

type Mutation {
    createUser(
        name: String! @rules(apply: ["required", "min:3"])
        email: String! @rules(apply: ["required", "email", "unique:users,email"])
    ): User! @create

    createPost(
        user_id: ID! @rules(apply: ["exists:users,id"])
        title: String! @rules(apply: ["required", "min:5"])
        content: String!
        is_published: Boolean = false
    ): Post! @create

    updatePost(
        id: ID!
        title: String
        content: String
        is_published: Boolean
    ): Post @update

    deletePost(id: ID!): Post @delete
}

Validation
#

Notice the @rules directive. Lighthouse leverages Laravel’s native validation engine. If the validation fails, Lighthouse automatically formats a standard GraphQL error response containing the validation messages. You don’t need to write a single Validator::make line in a controller.

Testing the Mutation
#

Open GraphiQL (/graphiql) and run this mutation:

mutation {
  createUser(name: "John Doe", email: "[email protected]") {
    id
    name
  }
}

Step 6: Custom Resolvers (When Directives Aren’t Enough)
#

Directives cover about 80% of CRUD use cases. However, sometimes you need complex business logic. Let’s say we want a computed field on the User type called post_count.

  1. Modify Schema: Add the field to the User type in schema.graphql:

    type User {
        # ... existing fields
        post_count: Int! @method(name: "postCount")
    }

    Note: We could also use a dedicated PHP class resolver, but utilizing model methods is cleaner for simple logic.

  2. Update Model: Update app/Models/User.php:

    public function postCount(): int
    {
        // In a real app, you might cache this or use withCount()
        return $this->posts()->count();
    }

For more complex logic, you can define a dedicated class.

Schema:

type Query {
    latestPost: Post @field(resolver: "App\\GraphQL\\Queries\\LatestPost")
}

Resolver Class (app/GraphQL/Queries/LatestPost.php):

namespace App\GraphQL\Queries;

use App\Models\Post;

final class LatestPost
{
    /**
     * @param  null  $_
     * @param  array{}  $args
     */
    public function __invoke($_, array $args)
    {
        return Post::where('is_published', true)
                   ->latest()
                   ->first();
    }
}

Step 7: Performance and The N+1 Problem
#

This is the most critical section for mid-to-senior developers. GraphQL is notorious for the N+1 problem.

Imagine this query:

{
  users {
    name
    posts {
      title
    }
  }
}

If you have 20 users, Eloquent naturally runs:

  1. SELECT * FROM users (1 query)
  2. SELECT * FROM posts WHERE user_id = ? (20 queries!)

This kills performance.

The Lighthouse Solution
#

Lighthouse solves this with the BatchLoader. However, you must ensure you are using relationships correctly.

To force eager loading explicitly in complex scenarios, you can use the @with directive (requires enabling in config or creating a scope), but mostly, Lighthouse’s @hasMany and @belongsTo directives act intelligently.

For deeper optimization, use the Laravel Debugbar or Telescope to inspect queries.

Best Practice: Always define relationships in the schema using relationship directives rather than field resolvers that trigger database calls manually.

Security Considerations
#

Before deploying to production, secure your API.

  1. Query Complexity: A malicious user could request a deeply nested query (User -> Posts -> Author -> Posts…) to crash your server. Configure config/lighthouse.php:
    'security' => [
        'max_query_complexity' => 100,
        'max_query_depth' => 5,
    ],
  2. Disable Introspection: In production, you don’t want to expose your entire schema structure to the world. Set lighthouse.security.disable_introspection to true based on your environment.

Conclusion
#

Building GraphQL APIs in PHP doesn’t have to be a struggle of parsing strings and managing resolvers manually. Laravel Lighthouse brings the elegance of Laravel to the precision of GraphQL.

We’ve covered:

  1. Setting up a schema-first environment.
  2. Mapping Eloquent models to GraphQL types.
  3. Handling complex relationships and mutations.
  4. Solving performance bottlenecks.

As we move through 2025, the demand for strongly typed, self-documenting APIs is only increasing. Lighthouse is currently the most robust tool in the PHP ecosystem to meet that demand.

Next Steps:

  • Explore Subscriptions in Lighthouse for real-time data updates.
  • Implement Passport or Sanctum for API authentication within GraphQL.
  • Look into Federation if you are building microservices.

Happy coding!


Did you find this guide helpful? Check out our other deep dives into PHP performance tuning and Laravel architecture on PHP DevPro.