Xây dựng GraphQL API trong Laravel với Lighthouse
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ạywithCount('followers'), không cần viết resolver.@method(name: "getReadingTime")= Gọi method trên model Eloquent.@rename(attribute: "created_at")= Map fieldcreatedAt(camelCase) sang columncreated_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@eqtrên argument để map vàoWHERE slug = ?.@paginate: Tự động tạo pagination. Client nhậndata,paginatorInfo(total, currentPage, lastPage...).@auth: Trả về user đang authenticated. Không cần viết resolver.@where(operator: "like"): Tự động thêmWHERE name LIKE ?khi client truyền argumentname.@orderBy: Cho phép client chọn thứ tự sắp xếp.@scope(name: "withTag"): Gọi scopescopeWithTag()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 middlewareauthtrong REST.@can(ability: "create"): Sử dụng Laravel Policy.PostPolicy::create()phải returntrue.@can(ability: "update", find: "id"): Tìm Post theoid, rồi checkPostPolicy::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ị:
- Bắt đầu với Queries +
@paginate,@find - Thêm Mutations + validation + auth
- Tối ưu N+1 (thường Lighthouse đã xử lý)
- Thêm Subscriptions nếu cần real-time
- Production: schema cache, persisted queries, disable introspection