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-configWe also highly recommend installing GraphiQL, a visual IDE for exploring your API.
composer require mll-lab/laravel-graphiqlNow, 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.
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 -mUpdate 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 migrateStep 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: "john@example.com") {
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.
-
Modify Schema: Add the field to the
Usertype inschema.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.
-
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:
SELECT * FROM users(1 query)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.
- 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, ], - Disable Introspection: In production, you don’t want to expose your entire schema structure to the world. Set
lighthouse.security.disable_introspectiontotruebased 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:
- Setting up a schema-first environment.
- Mapping Eloquent models to GraphQL types.
- Handling complex relationships and mutations.
- 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.