Một request như GET /api/users/1?include=posts.comments.author trông khá vô hại: client muốn lấy user, bài viết, bình luận và tác giả trong cùng tài liệu JSON:API. Nhưng với Laravel 13.36, chính đường include lồng này có thể biến thành N+1. Fixture nhỏ trong bài ghi nhận 11 truy vấn cho 1 user, 3 post và 6 comment.

Laravel 13.37 đã sửa cách tải những relationship được yêu cầu trên cả collection. Cùng fixture, cùng request và cùng response JSON chỉ còn 4 truy vấn. Kết quả chạy trên PHP 8.4.24, SQLite 3.53.2; đây là regression test cô lập của automation, không phải benchmark production hay trải nghiệm cá nhân của Nghĩa.

Khi nào nested include biến thành N+1?

Tài liệu Laravel 13 về JSON:API Resources cho phép resource khai báo relationship và client chọn tải chúng bằng query parameter include. Với path có dấu chấm, mỗi phần là một tầng quan hệ. JSON:API 1.1 gọi response đó là compound document: tài nguyên trung gian cũng phải xuất hiện để giữ full linkage.

GET /api/users/1?include=posts.comments.author
Accept: application/vnd.api+json

Vấn đề ở Laravel 13.36 không nằm ở cú pháp dấu chấm. Resource vẫn trả đúng cây dữ liệu, nhưng framework lần lượt tải quan hệ con cho từng model liên quan. Trong fixture, ba post tạo ba query lấy comment; sáu comment lại tạo sáu query lấy author. Cộng query lấy user và posts ban đầu là 11.

Đây là first-party Illuminate\Http\Resources\JsonApi\JsonApiResource được Laravel bổ sung trong nhánh 13.x, không phải package cộng đồng có tên gần giống. Nếu dự án dùng Laravel JSON:API của bên thứ ba, hãy đọc changelog và profiler của package đó; số phiên bản trong bài không áp dụng tự động.

Fixture được dựng để nhìn query, không đo tốc độ

Hai project dùng lockfile riêng cho laravel/framework 13.36.0 và 13.37.0. Phần còn lại giữ nguyên: PHP 8.4.24, SQLite 3.53.2, một user, ba post, mỗi post có hai comment và cả sáu comment trỏ về cùng một author. Test đăng ký resource rõ ràng ở từng tầng:

final class UserResource extends JsonApiResource
{
    protected array $attributes = ['name'];
    protected array $relationships = [
        'posts' => PostResource::class,
    ];
}

final class PostResource extends JsonApiResource
{
    protected array $attributes = ['title'];
    protected array $relationships = [
        'comments' => CommentResource::class,
    ];
}

final class CommentResource extends JsonApiResource
{
    protected array $attributes = ['body'];
    protected array $relationships = [
        'author' => UserResource::class,
    ];
}

Query log chỉ được bật sau khi tạo schema và seed, ngay trước HTTP request. Như vậy số đếm không lẫn câu lệnh tạo bảng hay insert dữ liệu. Test cũng ghi HTTP status, số phần tử included và SHA-256 của JSON đã serialize. Timing không được dùng làm assertion vì Windows local và SQLite không đại diện latency production.

Cách khóa phiên bản này cùng tinh thần với bài checklist test Laravel trên PHP 8.6 RC3: chỉ thay đúng dependency cần so, còn dữ liệu và lệnh test phải giống nhau. Nếu vừa nâng framework vừa đổi database hoặc seed, biểu đồ đẹp mấy cũng khó nói truy vấn giảm nhờ đâu.

13.36 chạy 11 query, 13.37 còn 4

Biểu đồ fixture cho thấy Laravel 13.36 dùng 11 query và Laravel 13.37 dùng 4 query với cùng nested include
Fixture 1 user, 3 post, 6 comment: 13.37 gom comments và author theo collection thay vì lặp trên từng model.
Case13.3613.37HTTPincluded
Không có include1 query1 query2000
include=posts2 query2 query2003
posts.comments.author11 query4 query20010
Quan hệ đã eager-load sẵn4 query4 query20010

Điểm đáng chú ý là hai control đầu không đổi. Không có include thì framework chỉ lấy user; include một tầng thì thêm đúng một query lấy collection posts. Bản vá tập trung vào các tầng tiếp theo: thay vì lặp loadMissing() trên từng model, Laravel gom related model theo class và tải relationship được khai báo cho cả Eloquent collection.

PR #62000 đưa ví dụ lớn hơn: 20 post, mỗi post 5 comment, giảm từ 122 xuống 4 query. Đó là số của tác giả PR. Con số 11 → 4 trong bài là kết quả fixture riêng; hai bộ dữ liệu không được trộn thành một benchmark.

Query log 13.37 cho thấy đúng bốn nhóm: lấy user, lấy toàn bộ posts theo user_id, lấy toàn bộ comments bằng post_id in (1, 2, 3), rồi lấy author bằng một id in (...). Đây mới là phần chứng minh “load theo collection”, thay vì chỉ thấy tổng số giảm.

Response có thật sự giữ nguyên?

So sánh response Laravel 13.36 và 13.37 có cùng SHA-256 và 10 resource included dù query giảm từ 11 xuống 4
HTTP status, số resource included và hash JSON đều khớp; phép thử không dùng latency làm bằng chứng.

Ở cả hai phiên bản, case nested trả HTTP 200, có 10 resource trong mảng included và cùng hash 35f962ab…ef10. Ba post, sáu comment và một author duy nhất đều còn đủ. Hash giống nhau không chứng minh mọi API ngoài đời sẽ bất biến, nhưng nó khóa đúng contract của fixture này: thay đổi số query không được âm thầm đổi resource identifier, attribute hoặc shape quan hệ.

Positive control còn được so với route đã eager-load sẵn posts.comments.author. Route đó dùng 4 query ở cả hai bản và tạo cùng hash với nested include tự tải. Nói gọn: sau bản vá, đường include động hội tụ về kết quả và số query của eager loading chủ động trong case thử này.

Một control khác gửi include=posts.comments.unknown. Laravel 13.37 không truy vấn relation unknown, gom posts/comments thành 3 query và vẫn trả 200. Trong khi đó, đặc tả JSON:API nói server không nhận ra relationship path phải trả 400. Nếu contract của dự án cần tuân thủ điểm này, hãy validate danh sách include ở boundary; đừng coi việc framework bỏ qua leaf không khai báo là validation đã hoàn tất.

Bản vá làm gì và không làm gì?

Commit trong PR thêm một bước đệ quy trên collection: xác định resource class của related model, lấy các relationship mà resource thực sự khai báo, gọi loadMissing() cho cả nhóm, rồi tiếp tục với phần path còn lại. Quan hệ không có trong resource bị loại trước khi framework thử tải. Release Laravel 13.37.0 phát hành ngày 16/10/2026 có PR này; tới ngày kiểm tra 21/10, 13.38.0 đã là patch mới hơn.

Bản vá không hứa xóa mọi N+1 trong API Resource. Query trong accessor, policy, closure của toRelationships(), toAttributes(), controller hoặc callback tự viết vẫn có thể chạy theo từng model. Nó cũng không tự đặt giới hạn relationship depth, kiểm authorization theo nghiệp vụ hay biến một include quá rộng thành rẻ về bộ nhớ.

Vì vậy, đừng ghim production ở 13.37 chỉ vì tiêu đề bài. Nếu constraint tương thích, nên lên patch hiện hành của nhánh 13 và chạy test ứng dụng. Patch nhỏ vẫn có thể đụng code path quan trọng; bài Laravel Boost: Test xanh chưa đủ cũng nhắc lý do cần review contract, convention và dữ liệu đầu ra chứ không chỉ nhìn một dòng test pass.

Regression test nên khóa những gì?

Một assertion “query nhỏ hơn 10” khá mong manh nếu seed thay đổi. Tốt hơn là khóa theo ba lớp: response contract, query shape và giới hạn nghiệp vụ. Với fixture cố định, có thể assert đúng 4 query. Với test lớn hơn, hãy ghi baseline có chủ đích và buộc reviewer cập nhật khi thêm relationship.

DB::flushQueryLog();
DB::enableQueryLog();

$response = $this->getJson(
    '/api/users/1?include=posts.comments.author'
)->assertOk()
  ->assertJsonCount(10, 'included');

expect(DB::getQueryLog())->toHaveCount(4);
expect(hash('sha256', $response->getContent()))
    ->toBe($knownContractHash);

Ở ứng dụng thật, hash toàn response có thể đổi vì timestamp, pagination link hoặc thứ tự. Khi đó hãy normalize trường động, hoặc snapshot đúng phần data và included. Query listener cũng chỉ nên bật trong test/diagnostic; đừng ghi SQL kèm dữ liệu nhạy cảm vào log production.

Nên thêm negative controls cho request không include, include một tầng, include không được phép, độ sâu tối đa, pagination và authorization. Nếu policy chỉ cho xem comment public, việc gom collection không được làm lộ comment private. Nếu API có sparse fieldset, contract test cần bao phủ cả trường bị loại. Giảm query là tốt; giảm luôn hàng rào dữ liệu thì tới công chuyện.

Nâng patch an toàn theo thứ tự nào?

  1. Ghi exact framework version và lưu lockfile hiện tại; đừng dựa vào constraint ^13.0.
  2. Chọn một endpoint có include lồng thật, tạo seed nhỏ nhưng đại diện đủ cardinality.
  3. Chạy snapshot/contract test và query-count test trước khi nâng.
  4. Nâng lên patch 13.x hiện hành tương thích, chạy lại cùng lệnh và so query SQL chứ không chỉ tổng số.
  5. Kiểm allowed relationships, max depth, authorization và pagination trước khi đưa ra production.

Nếu dự án đang ở Laravel 12 hoặc framework cũ hơn nhiều, đây không phải lý do để nhảy major vội. Hãy đối chiếu support policy và breaking changes; bài kiểm tra đường nâng Laravel 12 lên 13 có quy trình dry-run dependency và test database phù hợp hơn cho bước đó.

Chốt lại, bản sửa 13.37 đáng lấy khi API dùng first-party JSON:API Resource và cho include quan hệ lồng. Fixture đã chứng minh một case cụ thể giảm 11 xuống 4 query mà response giữ nguyên. Phần còn lại vẫn thuộc về ứng dụng: khóa contract, giới hạn include và đo chính endpoint của mình trước khi gọi vấn đề N+1 là đã xong.