All posts
Eloquent hay Query Builder
laravel

Eloquent hay Query Builder

Hao Nguyen K.'s avatarHao Nguyen K.
Table of Contents8 sections

Câu chuyện bắt đầu từ một cái dashboard "ì ạch"

Hồi mới làm Laravel, mình có thói quen dùng Eloquent cho mọi thứ. Nghe có vẻ hợp lý — Eloquent đẹp, dễ đọc, và cảm giác rất "Laravel". Cho đến một ngày bị phàn nàn: "Sao cái trang thống kê load lâu vậy?"

Mình thấy một trang dashboard đang chạy... 847 câu query.

Không phải bug. Không phải thiếu index. Chỉ đơn giản là dùng Eloquent ở những chỗ không nên dùng.

Từ hôm đó, mình bắt đầu thực sự hiểu sự khác biệt giữa Eloquent và Query Builder — không phải về mặt lý thuyết, mà về mặt khi nào nên dùng cái nào.


Trước hết: chúng khác nhau ở điểm gì?

Nhiều bạn nghĩ Eloquent và Query Builder là hai thứ hoàn toàn tách biệt. Thực ra không phải vậy.

Eloquent gọi Query Builder bên trong. Khi bạn viết User::where('active', 1)->get(), Eloquent thực ra đang dùng Query Builder để tạo câu SQL, rồi sau đó làm thêm một bước nữa: hydrate từng dòng dữ liệu thành một Model object.

Bước hydration đó bao gồm: khởi tạo object PHP, chạy qua $casts, áp dụng $appends, kiểm tra $hidden, $visible, và bất kỳ global scope nào bạn đã định nghĩa.

Với 20 bản ghi — không ai thấy sự khác biệt. Với 100.000 dòng — đó là lý do server của bạn bắt đầu khóc.

Query Builder thì khác: nó nói chuyện thẳng với database, trả về stdClass objects, và không làm gì thêm. Nhanh hơn, nhẹ hơn, nhưng cũng thiếu đi tất cả những "phép màu" mà Eloquent mang lại.


Kẻ thù thầm lặng: vấn đề N+1

Đây là thứ đã "hại" mình nhiều nhất — và cũng là lý do chính khiến nhiều developer mang tiếng oan "Eloquent chậm".

// Code này trông ổn, nhưng là bẫy
$posts = Post::all();

foreach ($posts as $post) {
    echo $post->author->name; // Mỗi vòng lặp = 1 query mới
}

Nếu có 200 bài viết, đoạn code trên thực hiện 201 câu query: 1 để lấy tất cả posts, rồi 200 câu nữa để lấy author của từng post. Laravel không báo lỗi. App vẫn chạy. Chỉ là chậm dần theo thời gian, đến khi production "sập" mà không ai hiểu tại sao.

Fix thì đơn giản:

// Eager loading: chỉ 2 query, bất kể có bao nhiêu bài viết
$posts = Post::with('author')->get();

Nhưng có một điều mình học được theo cách "đau đớn": eager loading không phải lúc nào cũng là câu trả lời đúng. Nếu bạn eager load một quan hệ có hàng chục nghìn bản ghi liên quan, bạn đổi vấn đề N+1 lấy vấn đề tràn RAM. Đó là lúc bạn cần cân nhắc dùng JOIN trong Query Builder thay vì with().

Một tip nhỏ: bật Model::preventLazyLoading() trong môi trường development. Nó sẽ throw exception ngay khi bạn vô tình lazy load — giúp bắt lỗi từ sớm thay vì phát hiện trên production.


Khi nào mình chọn Eloquent?

Sau nhiều dự án, mình có một nguyên tắc đơn giản: dùng Eloquent khi bạn đang làm việc với "đối tượng nghiệp vụ".

1. CRUD thông thường với quan hệ

Tạo, sửa, xóa — Eloquent sinh ra để làm những việc này. Timestamps tự động, mass assignment protection, soft deletes, observers — tất cả đều được xử lý tự động.

// Tạo đơn hàng kèm items — sạch và rõ ràng
$order = Order::create([...]);
$order->items()->createMany($items);

2. Khi cần Model Events hoặc Observers

Bạn cần gửi email sau khi user đăng ký? Tự động tạo log khi record bị xóa? Đây là "lãnh địa" của Eloquent. Query Builder không kích hoạt những sự kiện này.

3. Accessors, Mutators và Casting

// Model tự động format dữ liệu
protected $casts = [
    'settings' => 'array',
    'is_active' => 'boolean',
    'created_at' => 'datetime',
];

Thay vì xử lý thủ công ở nhiều chỗ, bạn định nghĩa một lần trong Model, dùng mãi mãi.

4. Eloquent Scopes để tái sử dụng logic

// Định nghĩa một lần
public function scopeActive($query) {
    return $query->where('is_active', 1)->whereNull('deleted_at');
}

// Dùng ở khắp nơi
$users = User::active()->get();
$admins = User::active()->where('role', 'admin')->get();

Khi nào mình chuyển sang Query Builder?

1. Báo cáo, thống kê, aggregate

Đây là trường hợp điển hình nhất. Khi bạn cần SUM, COUNT, GROUP BY, HAVING — Query Builder là lựa chọn tự nhiên vì cú pháp gần với SQL thật, dễ debug hơn.

// Doanh thu theo trạng thái đơn hàng
$stats = DB::table('orders')
    ->select('status', DB::raw('COUNT(*) as total'), DB::raw('SUM(amount) as revenue'))
    ->whereBetween('created_at', [$startDate, $endDate])
    ->groupBy('status')
    ->having('total', '>', 10)
    ->get();

Bạn có thể làm điều này với Eloquent, nhưng với Query Builder, câu query trông giống SQL hơn — dễ copy sang tool để kiểm tra, dễ giải thích cho người khác.

2. Bulk insert / import dữ liệu lớn

// Sai: vòng lặp với Eloquent — N câu query cho N bản ghi
foreach ($data as $row) {
    Product::create($row); // Mỗi dòng = 1 INSERT
}

// Đúng: 1 câu query duy nhất
DB::table('products')->insert($data);

// Hoặc dùng upsert cho 100k+ records
DB::table('products')->upsert($data, ['sku'], ['name', 'price', 'updated_at']);

Khi import CSV 50.000 dòng, sự khác biệt giữa hai cách này có thể là 30 giây vs 2 phút. Mình đã thấy job bị timeout vì dùng cách sai.

3. Xử lý dataset lớn với chunking

// Query Builder chunking — nhẹ hơn vì không hydrate model
DB::table('users')
    ->orderBy('id')
    ->chunk(1000, function ($users) {
        foreach ($users as $user) {
            // xử lý từng batch
        }
    });

4. Count, delete đơn giản không cần model

// Không có lý do gì để load Model chỉ để đếm
$count = DB::table('sessions')->where('user_id', $userId)->count();

// Hoặc xóa hàng loạt
DB::table('activity_logs')->where('created_at', '<', now()->subMonths(3))->delete();

Dùng Eloquent cho những việc này là "dùng dao mổ trâu để gọt bút chì".


Kỹ thuật kết hợp: ->toBase()

Đây là thứ mình ước mình biết sớm hơn.

Có những lúc bạn muốn dùng Eloquent scopes và constraints (vì logic đã được định nghĩa sẵn trong Model), nhưng không muốn tốn chi phí hydrate từng dòng thành object. ->toBase() sinh ra để giải quyết đúng vấn đề này:

// Dùng Eloquent scopes nhưng kết quả trả về như Query Builder
$users = User::active()
    ->verified()
    ->select('id', 'name', 'email')
    ->toBase() // Bỏ qua model hydration
    ->get();

Kết quả là stdClass objects thay vì User models — nhanh hơn, nhẹ hơn, nhưng vẫn tận dụng được toàn bộ logic scopes bạn đã định nghĩa.


Framework ra quyết định nhanh

Khi đứng trước một task, mình tự hỏi theo thứ tự sau:

Có cần Model Events, Observers, hay lifecycle hooks không? → Có → Eloquent

Có cần làm việc với quan hệ (relationships)? → Có, dataset vừa → Eloquent với with() → Có, dataset lớn với nhiều JOIN → Cân nhắc Query Builder với join thủ công

Đây có phải bulk operation (insert/update nhiều nghìn records)? → Có → Query Builder

Đây có phải query báo cáo/thống kê với aggregate? → Có → Query Builder

Chỉ cần count, check tồn tại, hay delete đơn giản? → Query Builder

Còn lại? → Eloquent — đây vẫn là default hợp lý cho 80% trường hợp


Tổng kết

Sau tất cả, mình nhận ra: không phải Eloquent chậm — mà là Eloquent dùng sai chỗ mới chậm. Cũng đừng vì sợ Eloquent mà viết Query Builder ở khắp nơi — bạn sẽ kết thúc với một đống code khó đọc, khó maintain, và không kém phần "chậm" theo nghĩa khác.

Công cụ tốt nhất là công cụ đúng với công việc đang làm.