Xây dựng GraphQL API trong Laravel với Lighthouse

· 14 min read

REST API đã phục vụ tốt trong nhiều năm, nhưng nó có những hạn chế rõ ràng: over-fetching (lấy thừa dữ liệu), under-fetching (phải gọi nhiều endpoint), và thiếu type safety. GraphQL giải quyết tất cả bằng cách cho client chỉ định chính xác dữ liệu cần lấy.

Lighthouse là package phổ biến nhất để xây dựng GraphQL server trong Laravel. Nó tích hợp chặt với Eloquent, tận dụng được toàn bộ ecosystem Laravel (auth, policies, validation, events).

REST vs GraphQL — Hiểu rõ trước khi chọn

Vấn đề của REST

Giả sử bạn có trang profile hiển thị thông tin user, danh sách posts, và số followers:

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, ... }, ...]

Vấn đề 1: Over-fetching. API /users/1 trả về bio, created_at, phone... mà trang profile mobile không cần.

Vấn đề 2: Under-fetching. Cần 3 requests cho 1 trang. Trên mạng chậm, UI phải loading 3 lần.

GraphQL: 1 request, đúng dữ liệu cần

query {
  user(id: 1) {
    name
    avatar
    posts(first: 5) {
      title
      publishedAt
    }
    followersCount
  }
}

1 request. Chỉ nhận đúng fields cần thiết. Không thừa, không thiếu.

Khi nào nên dùng GraphQL?

Dùng GraphQL Giữ REST
Frontend phức tạp (SPA, mobile) CRUD đơn giản
Nhiều loại client (web, iOS, Android) API public, cần cache HTTP
Data relationships phức tạp Webhooks, file upload
Team frontend muốn tự chủ Microservices internal
Real-time subscriptions Ứng dụng SEO-heavy (server-rendered)

Cài đặt Lighthouse

composer require nuwave/lighthouse

# Publish schema file
php artisan vendor:publish --tag=lighthouse-schema

# Publish config (tùy chọn)
php artisan vendor:publish --tag=lighthouse-config

Sau khi cài, Lighthouse tạo file schema tại graphql/schema.graphql — đây là nơi bạn khai báo toàn bộ API.

IDE Support

# GraphQL Playground (giao diện test API)
composer require mll-lab/laravel-graphql-playground

# Truy cập tại: http://your-app.test/graphql-playground

Schema — Trái tim của GraphQL

Khai báo Types

# graphql/schema.graphql

"Người dùng trong hệ thống"
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")
}

"Bài viết blog"
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")
}

"Tag/nhãn cho bài viết"
type Tag {
    id: ID!
    name: String!
    slug: String!
    posts: [Post!]! @belongsToMany
    postsCount: Int! @count(relation: "posts")
}

"Bình luận"
type Comment {
    id: ID!
    body: String!
    author: User! @belongsTo
    post: Post! @belongsTo
    createdAt: DateTime! @rename(attribute: "created_at")
}

Giải thích cú pháp:

  • ! = không null (required). String! = luôn có giá trị. String = có thể null.
  • [Post!]! = array không null, chứa các Post không null. [] sẽ là empty array, không bao giờ null.
  • @hasMany, @belongsTo, @belongsToMany = Lighthouse directives mapping với Eloquent relationships. Lighthouse tự động resolve.
  • @count(relation: "followers") = Tự động chạy withCount('followers'), không cần viết resolver.
  • @method(name: "getReadingTime") = Gọi method trên model Eloquent.
  • @rename(attribute: "created_at") = Map field createdAt (camelCase) sang column created_at (snake_case).

Queries — Đọc dữ liệu

# graphql/schema.graphql (tiếp)

type Query {
    "Lấy user theo ID"
    user(id: ID! @eq): User @find

    "Lấy user đang đăng nhập"
    me: User @auth

    "Danh sách users với pagination"
    users(
        name: String @where(operator: "like")
    ): [User!]! @paginate(defaultCount: 15)

    "Lấy post theo slug"
    post(slug: String! @eq): Post @find

    "Danh sách posts đã publish"
    posts(
        orderBy: _ @orderBy(columns: ["created_at", "title"])
        hasTag: String @scope(name: "withTag")
    ): [Post!]! @paginate(
        defaultCount: 10
        scopes: ["published"]
    )

    "Tìm kiếm posts"
    searchPosts(query: String! @search): [Post!]! @paginate(defaultCount: 10)

    "Tất cả tags"
    tags: [Tag!]! @all
}

Giải thích các directives:

  • @find: Tìm 1 record theo điều kiện. Kết hợp @eq trên argument để map vào WHERE slug = ?.
  • @paginate: Tự động tạo pagination. Client nhận data, paginatorInfo (total, currentPage, lastPage...).
  • @auth: Trả về user đang authenticated. Không cần viết resolver.
  • @where(operator: "like"): Tự động thêm WHERE name LIKE ? khi client truyền argument name.
  • @orderBy: Cho phép client chọn thứ tự sắp xếp.
  • @scope(name: "withTag"): Gọi scope scopeWithTag() trên model Post.
  • @search: Tích hợp Laravel Scout cho full-text search.

Ví dụ queries từ client

# Lấy danh sách posts với pagination
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
    }
  }
}

# Lấy chi tiết 1 post
query {
  post(slug: "laravel-caching-tips") {
    title
    body
    readingTime
    publishedAt
    author {
      name
      bio
    }
    tags {
      name
      slug
    }
    comments {
      body
      author {
        name
      }
      createdAt
    }
  }
}

# Lấy thông tin user hiện tại
query {
  me {
    name
    email
    posts(first: 5) {
      data {
        title
        commentsCount
      }
    }
  }
}

Mutations — Tạo, sửa, xóa dữ liệu

# graphql/schema.graphql (tiếp)

type Mutation {
    "Tạo bài viết mới"
    createPost(input: CreatePostInput! @spread): Post!
        @guard
        @can(ability: "create", model: "App\\Models\\Post")

    "Cập nhật bài viết"
    updatePost(id: ID!, input: UpdatePostInput! @spread): Post!
        @guard
        @can(ability: "update", find: "id")

    "Xóa bài viết"
    deletePost(id: ID! @whereKey): Post!
        @guard
        @can(ability: "delete", find: "id")
        @delete

    "Thêm comment"
    createComment(input: CreateCommentInput!): Comment!
        @guard

    "Đăng nhập"
    login(input: LoginInput!): AuthPayload!

    "Đăng ký"
    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!
}

Giải thích directives quan trọng:

  • @guard: Yêu cầu authenticated. Tương đương middleware auth trong REST.
  • @can(ability: "create"): Sử dụng Laravel Policy. PostPolicy::create() phải return true.
  • @can(ability: "update", find: "id"): Tìm Post theo id, rồi check PostPolicy::update($user, $post).
  • @rules(apply: [...]): Validation rules giống hệt Laravel. Lỗi validation được format chuẩn GraphQL errors.
  • @spread: "Mở" input object ra thành individual arguments. Thay vì nhận ['input' => [...]], resolver nhận flat array.
  • @delete: Tự động xóa model và trả về model đã xóa.

Custom Resolver cho Mutations phức tạp

Một số mutations cần logic phức tạp hơn directives:

// 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;
    }
}

Khai báo trong 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('Thông tin đăng nhập không chính xác.');
        }

        return [
            'token' => $user->createToken('api')->plainTextToken,
            'user'  => $user,
        ];
    }
}

Giải quyết N+1 Query Problem

Đây là vấn đề lớn nhất của GraphQL. Khi client query nested relationships:

query {
  posts(first: 10) {
    data {
      title
      author { name }       # N+1: 1 query cho mỗi post
      tags { name }          # Thêm N+1 nữa!
    }
  }
}

Nếu không xử lý, sẽ chạy: 1 (posts) + 10 (authors) + 10 (tags) = 21 queries cho 10 posts.

Lighthouse tự động Batch Loading

Lighthouse sử dụng DataLoader pattern (batching) tự động cho các directives như @belongsTo, @hasMany. Nó nhóm tất cả author IDs lại và chạy 1 query:

-- Thay vì 10 queries riêng lẻ:
SELECT * FROM users WHERE id = 1;
SELECT * FROM users WHERE id = 2;
...

-- Lighthouse chạy 1 query:
SELECT * FROM users WHERE id IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

Kết quả: 1 (posts) + 1 (authors batch) + 1 (tags batch) = 3 queries.

Eager Loading thủ công khi cần

type Query {
    posts: [Post!]! @paginate @with(relation: "author.profile")
}

@with tương đương Post::with('author.profile'). Dùng khi bạn luôn cần 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 {
    "Nhận thông báo khi có comment mới trên post"
    commentAdded(postId: ID!): Comment!

    "Nhận thông báo khi post được publish"
    postPublished: Post!
}

Broadcast từ 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 tới tất cả 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
{
    /**
     * Kiểm tra ai được subscribe
     */
    public function authorize(Subscriber $subscriber, Request $request): bool
    {
        return true; // Hoặc kiểm tra auth
    }

    /**
     * Lọc: chỉ gửi tới subscribers quan tâm post này
     */
    public function filter(Subscriber $subscriber, mixed $root): bool
    {
        return $subscriber->args['postId'] === $root->post_id;
    }
}

Cấu hình Lighthouse

// 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',
    ],

    // Giới hạn query depth (chống abuse)
    'security' => [
        'max_query_depth' => 10,
        'max_query_complexity' => 200,
        'disable_introspection' => env('LIGHTHOUSE_DISABLE_INTROSPECTION', false),
    ],

    'pagination' => [
        'default_count' => 15,
        'max_count' => 100,
    ],
];

Giải thích Security settings:

  • max_query_depth: 10: Ngăn queries quá sâu: user → posts → comments → author → posts → .... Kẻ tấn công có thể tạo query vô hạn để DDoS server.
  • max_query_complexity: 200: Mỗi field có "cost". Query tổng cost > 200 bị reject.
  • disable_introspection: Tắt introspection ở production. Introspection cho phép xem toàn bộ schema — hữu ích cho dev nhưng lộ thông tin ở production.

Testing 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

Thay vì gửi toàn bộ query string mỗi request, client gửi hash:

# Request bình thường (gửi full query)
POST /graphql
{"query": "{ posts { data { title slug } } }"}

# Persisted query (gửi hash)
POST /graphql
{"extensions": {"persistedQuery": {"sha256Hash": "abc123..."}}}

2. Response Caching

composer require nuwave/lighthouse-cache
type Query {
    # Cache 1 giờ
    posts: [Post!]! @paginate @cache(maxAge: 3600)

    # Cache theo user
    me: User @auth @cache(maxAge: 300, private: true)
}

3. Schema Caching (Production)

# Cache schema cho production
php artisan lighthouse:cache

# Clear cache khi deploy
php artisan lighthouse:clear-cache

Cấu trúc thư mục hoàn chỉnh

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

Chia schema thành nhiều file:

# graphql/schema.graphql
#import user.graphql
#import post.graphql
#import comment.graphql

Kết luận

GraphQL + Lighthouse là combo mạnh mẽ cho Laravel API. Ưu điểm lớn nhất:

  • Schema-first: Khai báo API bằng SDL rõ ràng, đọc hiểu dễ
  • Directives: 80% logic xử lý bằng directives, ít code hơn REST
  • Type safety: Client biết chính xác dữ liệu trả về
  • Performance: DataLoader tự động, caching, persisted queries

Lộ trình khuyến nghị:

  1. Bắt đầu với Queries + @paginate, @find
  2. Thêm Mutations + validation + auth
  3. Tối ưu N+1 (thường Lighthouse đã xử lý)
  4. Thêm Subscriptions nếu cần real-time
  5. Production: schema cache, persisted queries, disable introspection

Bình luận