Saat membangun aplikasi dengan Laravel, kamu tidak selalu harus menggunakan Eloquent ORM. Untuk query yang lebih kompleks atau ketika performa menjadi prioritas, Query Builder adalah pilihan yang tepat. Query Builder menyediakan antarmuka yang nyaman dan aman untuk membangun query SQL tanpa menulis SQL mentah secara langsung.
Di panduan ini kita bahas Query Builder dari dasar sampai pemakaian di proyek nyata: select, where, insert/update/delete, join, agregasi, pagination, raw expression, memproses data besar, transaksi, cara melihat SQL yang dihasilkan, sampai kesalahan yang paling sering bikin bug. Semua contoh ditulis untuk Laravel 10 sampai 12 dengan PHP 8.x dan MySQL/MariaDB.
Apa Itu Query Builder?
Query Builder adalah lapisan abstraksi di atas PDO yang disediakan Laravel melalui facade DB. Dengan Query Builder, kamu bisa membangun query secara berantai (method chaining), dan Laravel akan menangani escaping data secara otomatis sehingga terhindar dari SQL Injection.
Perlindungan itu bekerja karena Query Builder memakai parameter binding PDO. Nilai yang kamu kirim lewat where('email', $email) tidak ditempel ke string SQL, melainkan dikirim terpisah sebagai parameter. Jadi meskipun isi $email berasal dari input pengguna yang aneh-aneh, database tetap memperlakukannya sebagai data, bukan perintah.
Satu hal yang perlu diingat sejak awal: hasil get() dari Query Builder adalah Illuminate\Support\Collection yang berisi objek stdClass, bukan model Eloquent. Artinya kamu mengakses kolom dengan $user->name, tetapi tidak ada relasi, accessor, cast, atau event model. Karena tidak ada proses "menghidupkan" model, Query Builder biasanya lebih ringan untuk data yang banyak.
Query Builder vs Eloquent vs Raw SQL
Ketiganya sama-sama sah dipakai di Laravel. Yang penting tahu kapan memilih yang mana:
| Aspek | Query Builder | Eloquent | Raw SQL (DB::select) |
|---|---|---|---|
| Hasil | Collection berisi stdClass | Collection berisi model | Array berisi stdClass |
| Relasi, cast, event | Tidak ada | Ada | Tidak ada |
| Timestamps otomatis | Tidak, isi sendiri | Otomatis | Tidak |
| Keamanan binding | Otomatis | Otomatis | Harus pakai ? secara manual |
| Cocok untuk | Laporan, join banyak tabel, data besar | CRUD sehari-hari, logika bisnis per model | Query sangat spesifik database |
Dalam praktiknya banyak proyek mencampur keduanya: Eloquent untuk fitur CRUD dan relasi antar model, lalu Query Builder untuk halaman laporan atau dashboard yang menggabungkan banyak tabel. Menariknya, Eloquent sendiri dibangun di atas Query Builder, jadi hampir semua method di artikel ini juga bisa dipakai di model, misalnya User::where(...)->get().
Mengambil Data (Select)
Cara paling dasar adalah mengambil seluruh baris dari sebuah tabel:
use Illuminate\Support\Facades\DB;
// Ambil semua data dari tabel users
$users = DB::table('users')->get();
// Ambil kolom tertentu saja
$users = DB::table('users')->select('name', 'email')->get();
// Ambil satu baris pertama
$user = DB::table('users')->where('email', 'john@example.com')->first();
// Ambil nilai satu kolom saja
$email = DB::table('users')->where('id', 1)->value('email');
Ada beberapa method lain yang sering dipakai dan bikin kode lebih ringkas:
// Cari berdasarkan primary key "id"
$user = DB::table('users')->find(3);
// Ambil satu kolom menjadi daftar sederhana
$emails = DB::table('users')->pluck('email');
// Daftar pasangan id => name, cocok untuk <select> di form
$options = DB::table('categories')->pluck('name', 'id');
// Cek apakah data ada, tanpa mengambil barisnya
if (DB::table('users')->where('email', $email)->exists()) {
// email sudah terdaftar
}
// Urutkan data
$posts = DB::table('posts')->orderBy('title')->get();
$posts = DB::table('posts')->latest()->get(); // ORDER BY created_at DESC
$posts = DB::table('posts')->oldest()->get(); // ORDER BY created_at ASC
// Hindari duplikat
$cities = DB::table('users')->select('city')->distinct()->get();
Perhatikan bahwa first() dan find() mengembalikan null jika data tidak ditemukan. Selalu cek hasilnya sebelum mengakses properti, atau kamu akan bertemu error "Attempt to read property on null".
Filtering dengan Where
Query Builder menyediakan banyak variasi method where untuk memfilter data:
// Where biasa
$users = DB::table('users')->where('status', 'active')->get();
// Where dengan operator
$users = DB::table('users')->where('age', '>=', 18)->get();
// Multiple where (AND)
$users = DB::table('users')
->where('status', 'active')
->where('role', 'admin')
->get();
// Where OR
$users = DB::table('users')
->where('role', 'admin')
->orWhere('role', 'editor')
->get();
// Where IN
$users = DB::table('users')->whereIn('id', [1, 2, 3])->get();
// Where NULL
$users = DB::table('users')->whereNull('deleted_at')->get();
// Where Between (kolom DATETIME: sertakan jam akhir hari)
$orders = DB::table('orders')
->whereBetween('created_at', ['2024-01-01 00:00:00', '2024-12-31 23:59:59'])
->get();
Catatan soal whereBetween: kalau kolom created_at bertipe DATETIME dan kamu hanya menulis '2024-12-31', MySQL membacanya sebagai 2024-12-31 00:00:00. Akibatnya pesanan yang dibuat pada tanggal 31 Desember setelah tengah malam tidak ikut terhitung. Karena itu contoh di atas menyertakan jam akhir hari.
Variasi where lain yang berguna:
// Pencarian teks dengan LIKE
$products = DB::table('products')->where('name', 'like', '%laptop%')->get();
// Kebalikan IN dan NULL
$users = DB::table('users')->whereNotIn('role', ['banned', 'guest'])->get();
$users = DB::table('users')->whereNotNull('email_verified_at')->get();
// Filter berdasarkan bagian tanggal
$today = DB::table('orders')->whereDate('created_at', today())->get();
$march = DB::table('orders')->whereMonth('created_at', 3)->whereYear('created_at', 2025)->get();
// Membandingkan dua kolom
$updated = DB::table('posts')->whereColumn('updated_at', '>', 'created_at')->get();
Mengelompokkan kondisi OR dengan closure
Ini sumber bug yang sangat umum. Misalnya kamu ingin mencari user aktif yang role-nya admin atau editor. Kalau ditulis begini:
// SALAH: hasilnya status = active AND role = admin OR role = editor
$users = DB::table('users')
->where('status', 'active')
->where('role', 'admin')
->orWhere('role', 'editor')
->get();
Karena AND diproses lebih dulu daripada OR, semua editor ikut terambil walaupun statusnya tidak aktif. Solusinya, bungkus kondisi OR dalam closure supaya Laravel menambahkan tanda kurung:
// BENAR: status = active AND (role = admin OR role = editor)
$users = DB::table('users')
->where('status', 'active')
->where(function ($query) {
$query->where('role', 'admin')
->orWhere('role', 'editor');
})
->get();
Kondisi dinamis dengan when()
Method when() hanya menjalankan callback jika nilai pertama bernilai true. Ini sangat praktis untuk filter opsional dari form, jadi kamu tidak perlu menumpuk if:
$status = $request->input('status');
$users = DB::table('users')
->when($status, function ($query, $status) {
$query->where('status', $status);
})
->get();
Insert, Update, dan Delete
Query Builder juga mendukung operasi tulis ke database:
// Insert satu baris
DB::table('products')->insert([
'name' => 'Laptop Gaming',
'price' => 15000000,
'created_at' => now(),
'updated_at' => now(),
]);
// Insert dan ambil ID yang baru dibuat
$id = DB::table('products')->insertGetId([
'name' => 'Monitor 4K',
'price' => 5000000,
]);
// Update data
DB::table('products')
->where('id', $id)
->update(['price' => 4500000]);
// Increment/Decrement kolom angka
DB::table('products')->where('id', 1)->increment('stock', 10);
DB::table('products')->where('id', 1)->decrement('stock', 2);
// Delete
DB::table('products')->where('id', $id)->delete();
Berbeda dengan Eloquent, Query Builder tidak mengisi created_at dan updated_at secara otomatis. Contoh insertGetId di atas akan menyimpan kedua kolom itu sebagai NULL (atau error jika kolomnya NOT NULL tanpa default). Biasakan menambahkan now() sendiri seperti pada contoh insert pertama.
Untuk kebutuhan yang lebih spesifik, ada beberapa method tambahan:
// Insert banyak baris sekaligus dalam satu query
DB::table('tags')->insert([
['name' => 'php', 'created_at' => now(), 'updated_at' => now()],
['name' => 'laravel', 'created_at' => now(), 'updated_at' => now()],
]);
// Lewati baris yang bentrok dengan unique index
DB::table('tags')->insertOrIgnore([
['name' => 'php'],
]);
// Update jika ada, insert jika belum ada
DB::table('settings')->updateOrInsert(
['key' => 'site_name'], // kondisi pencarian
['value' => 'Toko Online Saya'] // nilai yang di-set
);
// Upsert massal: kolom "sku" harus punya unique index
DB::table('products')->upsert(
[
['sku' => 'LP-001', 'name' => 'Laptop Gaming', 'price' => 15000000],
['sku' => 'MN-002', 'name' => 'Monitor 4K', 'price' => 4500000],
],
['sku'], // kolom unik penentu
['name', 'price'] // kolom yang di-update jika sudah ada
);
Hati-hati dengan update() dan delete() tanpa where. Kode DB::table('products')->delete() akan menghapus seluruh isi tabel tanpa konfirmasi. Biasakan menulis kondisi where lebih dulu, baru method tulisnya.
Join Tabel
Query Builder mendukung berbagai jenis JOIN untuk menggabungkan beberapa tabel:
$orders = DB::table('orders')
->join('users', 'orders.user_id', '=', 'users.id')
->join('products', 'orders.product_id', '=', 'products.id')
->select(
'orders.id',
'users.name as customer_name',
'products.name as product_name',
'orders.total'
)
->where('orders.status', 'completed')
->orderBy('orders.created_at', 'desc')
->get();
join() menghasilkan INNER JOIN, jadi baris yang tidak punya pasangan di tabel lain akan hilang. Kalau kamu tetap ingin menampilkan data walau relasinya kosong, misalnya user yang belum pernah order, pakai leftJoin(). Untuk kondisi join yang lebih dari satu, gunakan closure:
// Semua user beserta jumlah order yang statusnya completed
$users = DB::table('users')
->leftJoin('orders', function ($join) {
$join->on('users.id', '=', 'orders.user_id')
->where('orders.status', '=', 'completed');
})
->select('users.id', 'users.name', DB::raw('COUNT(orders.id) as total_order'))
->groupBy('users.id', 'users.name')
->get();
Selalu beri prefix nama tabel (users.name, orders.id) saat melakukan join. Tanpa prefix, kolom seperti id atau created_at yang ada di kedua tabel akan memicu error "Column is ambiguous", atau lebih parah, nilainya saling menimpa di hasil akhir.
Aggregasi dan Grouping
Untuk kebutuhan pelaporan, Query Builder menyediakan fungsi agregasi:
// Hitung total baris
$count = DB::table('users')->where('status', 'active')->count();
// Jumlah, rata-rata, min, max
$total = DB::table('orders')->sum('total');
$average = DB::table('orders')->avg('total');
$max = DB::table('orders')->max('total');
$min = DB::table('orders')->min('total');
// Group By dengan having
$summary = DB::table('orders')
->select('user_id', DB::raw('SUM(total) as total_belanja'))
->groupBy('user_id')
->having('total_belanja', '>', 500000)
->get();
Memakai alias seperti total_belanja di dalam HAVING berjalan normal di MySQL dan MariaDB. Kalau proyekmu suatu saat pindah ke PostgreSQL, ganti dengan havingRaw('SUM(total) > ?', [500000]) karena PostgreSQL tidak mengenali alias di HAVING.
Pagination dan Limit
Membatasi dan mem-paginate hasil query sangat mudah:
Berlangganan gratis. Artikel & tutorial coding terbaru langsung ke email kamu. Tanpa spam.
// Limit dan offset manual
$users = DB::table('users')->limit(10)->offset(20)->get();
// Pagination otomatis Laravel
$users = DB::table('users')->paginate(15);
// Simple pagination (prev/next saja)
$users = DB::table('users')->simplePaginate(15);
// Cursor pagination, efisien untuk tabel sangat besar
$users = DB::table('users')->orderBy('id')->cursorPaginate(15);
// Pertahankan parameter filter (?q=...&status=...) di link halaman
$users = DB::table('users')->paginate(15)->withQueryString();
Di view Blade, cukup panggil {{ $users->links() }} untuk menampilkan navigasi halaman. paginate() menjalankan query tambahan COUNT(*) untuk mengetahui total halaman, sedangkan simplePaginate() dan cursorPaginate() tidak. Untuk tabel berisi jutaan baris, perbedaan ini cukup terasa.
Raw Expression
Ketika kamu membutuhkan ekspresi SQL khusus yang tidak didukung Query Builder secara langsung, gunakan DB::raw():
$users = DB::table('users')
->select(DB::raw('COUNT(*) as total, status'))
->groupBy('status')
->get();
// whereRaw untuk kondisi SQL mentah
$users = DB::table('users')
->whereRaw('YEAR(created_at) = ?', [2024])
->get();
// selectRaw dan orderByRaw juga menerima binding
$products = DB::table('products')
->selectRaw('name, price * ? as harga_plus_pajak', [1.11])
->orderByRaw('price DESC')
->get();
Inilah satu-satunya bagian Query Builder yang tidak otomatis aman. Isi DB::raw() dan whereRaw() ditempel apa adanya ke SQL. Jangan pernah menggabungkan input pengguna langsung ke string seperti whereRaw("name = '$nama'"). Selalu pakai placeholder ? dan kirim nilainya di parameter kedua seperti contoh di atas.
Memproses Data dalam Jumlah Besar
Memanggil get() pada tabel berisi ratusan ribu baris akan memuat semuanya ke memori sekaligus dan bisa memicu error "Allowed memory size exhausted". Untuk pekerjaan seperti export atau sinkronisasi data, proses sedikit demi sedikit:
// Proses per 500 baris, aman walau data di-update di dalam loop
DB::table('users')
->where('newsletter', true)
->chunkById(500, function ($users) {
foreach ($users as $user) {
// kirim email, tulis ke file, dll.
}
});
// Versi lazy: tetap per-chunk di belakang layar, tapi bisa di-foreach biasa
foreach (DB::table('orders')->lazyById(1000) as $order) {
// proses $order
}
Gunakan chunkById() alih-alih chunk() jika di dalam callback kamu meng-update kolom yang juga dipakai sebagai filter. chunk() memakai offset, sehingga ketika baris berubah di tengah jalan, sebagian data bisa terlewat.
Transaksi Database
Saat beberapa query harus berhasil semua atau gagal semua, misalnya membuat order sekaligus mengurangi stok, bungkus dalam transaksi:
DB::transaction(function () use ($productId, $userId, $qty) {
$product = DB::table('products')
->where('id', $productId)
->lockForUpdate()
->first();
if (! $product || $product->stock < $qty) {
throw new \RuntimeException('Stok tidak cukup');
}
DB::table('orders')->insert([
'user_id' => $userId,
'product_id' => $productId,
'qty' => $qty,
'total' => $product->price * $qty,
'status' => 'pending',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('products')->where('id', $productId)->decrement('stock', $qty);
});
Jika ada exception di dalam closure, semua perubahan otomatis di-rollback. lockForUpdate() mengunci baris produk sampai transaksi selesai, sehingga dua pembeli yang checkout bersamaan tidak bisa sama-sama mengambil stok terakhir.
Melihat SQL yang Dihasilkan (Debugging)
Kalau hasil query tidak sesuai harapan, langkah pertama adalah melihat SQL aslinya:
$query = DB::table('users')->where('status', 'active')->where('age', '>', 18);
// SQL dengan placeholder: select * from `users` where `status` = ? and `age` > ?
echo $query->toSql();
// SQL dengan nilai binding sudah terisi (Laravel 10.15+)
echo $query->toRawSql();
// Tampilkan SQL + binding lalu hentikan eksekusi
$query->dd();
// Catat semua query yang berjalan dalam satu request
DB::enableQueryLog();
// ... jalankan query ...
dump(DB::getQueryLog());
Contoh Kasus Nyata: Halaman Produk dengan Filter
Berikut contoh controller lengkap yang menggabungkan join, where dinamis, sorting, dan pagination. Polanya sama dengan yang biasa dipakai untuk fitur filter data di Laravel:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class ProductController extends Controller
{
public function index(Request $request)
{
// Whitelist kolom sort supaya tidak bisa diisi sembarangan
$sortable = ['name', 'price', 'created_at'];
$sort = in_array($request->input('sort'), $sortable, true)
? $request->input('sort')
: 'created_at';
$direction = $request->input('dir') === 'asc' ? 'asc' : 'desc';
$products = DB::table('products')
->leftJoin('categories', 'products.category_id', '=', 'categories.id')
->select(
'products.id',
'products.name',
'products.price',
'products.stock',
'categories.name as category_name'
)
->when($request->filled('q'), function ($query) use ($request) {
$keyword = '%' . $request->input('q') . '%';
$query->where(function ($q) use ($keyword) {
$q->where('products.name', 'like', $keyword)
->orWhere('products.sku', 'like', $keyword);
});
})
->when($request->filled('category'), function ($query) use ($request) {
$query->where('products.category_id', $request->integer('category'));
})
->when($request->filled('min_price'), function ($query) use ($request) {
$query->where('products.price', '>=', $request->integer('min_price'));
})
->orderBy('products.' . $sort, $direction)
->paginate(15)
->withQueryString();
return view('products.index', compact('products'));
}
}
Perhatikan dua hal. Pertama, pencarian name atau sku dibungkus closure supaya OR-nya tidak "bocor" ke filter lain. Kedua, nama kolom untuk orderBy divalidasi dengan whitelist. Nilai di where memang aman berkat binding, tetapi nama kolom tidak di-bind, jadi jangan pernah meneruskan input pengguna mentah ke orderBy() atau select().
Contoh laporan penjualan per bulan
$laporan = DB::table('orders')
->selectRaw("DATE_FORMAT(created_at, '%Y-%m') as bulan")
->selectRaw('COUNT(*) as jumlah_order')
->selectRaw('SUM(total) as omzet')
->where('status', 'completed')
->whereBetween('created_at', ['2025-01-01 00:00:00', '2025-12-31 23:59:59'])
->groupBy('bulan')
->orderBy('bulan')
->get();
DATE_FORMAT adalah fungsi khusus MySQL/MariaDB. Filter tanggal sengaja memakai whereBetween dengan rentang, bukan whereYear(), karena membungkus kolom dengan fungsi seperti YEAR(created_at) membuat MySQL tidak bisa memakai index pada kolom tersebut.
Kesalahan Umum dan Solusinya
- Lupa memanggil
get().DB::table('users')->where(...)masih berupa objek builder, belum ada query yang dijalankan. Akhiri denganget(),first(),paginate(), atau method eksekusi lain. - Memperlakukan hasil seperti model. Memanggil
$user->postsatau$user->save()pada hasil Query Builder akan gagal, karena hasilnyastdClass. Gunakan Eloquent jika butuh fitur model. - OR tanpa closure. Hasil query jadi lebih banyak dari seharusnya. Bungkus kelompok OR dalam
where(function ($q) { ... }). - Timestamps NULL. Query Builder tidak mengisinya otomatis. Tambahkan
'created_at' => now()dan'updated_at' => now()saat insert. - Error "SELECT list is not in GROUP BY clause". Muncul karena mode
ONLY_FULL_GROUP_BYMySQL. Pastikan semua kolom non-agregat diselectjuga masuk kegroupBy. - Query di dalam loop (N+1). Menjalankan
DB::table(...)->first()di dalamforeachuntuk setiap baris membuat ratusan query. Ganti dengan satu query memakaijoinatauwhereIn. - Tidak bisa konek database sama sekali. Jika yang muncul adalah error koneksi, masalahnya ada di konfigurasi
.envatau servernya, bukan di query. Cek lagi nilaiDB_HOST,DB_PORT,DB_DATABASE,DB_USERNAME, danDB_PASSWORD, lalu jalankanphp artisan config:clear.
Tips Performa dan Keamanan
- Ambil kolom yang dibutuhkan saja dengan
select(), hindariselect *pada tabel yang punya kolom teks besar. - Tambahkan index pada kolom yang sering dipakai di
where,join, danorderBy, misalnyauser_id,status, dancreated_at. - Pakai
exists()untuk mengecek keberadaan data, bukancount() > 0atauget(). - Pencarian
LIKE '%kata%'dengan wildcard di depan tidak bisa memakai index biasa. Untuk data besar, pertimbangkan FULLTEXT index. - Gunakan
chunkById()ataulazyById()untuk data besar, dan cache hasil laporan yang jarang berubah. Teknik lainnya ada di artikel cara optimasi performa Laravel. - Selalu pakai placeholder
?di method raw, dan whitelist nama kolom yang berasal dari input pengguna.
FAQ
Apakah Query Builder lebih cepat daripada Eloquent?
Umumnya lebih ringan karena tidak membuat objek model untuk setiap baris. Selisihnya baru terasa ketika data yang diambil banyak, misalnya ribuan baris untuk laporan. Untuk CRUD biasa dengan beberapa puluh baris, perbedaannya kecil, jadi pilih yang membuat kode paling mudah dibaca.
Apakah Query Builder aman dari SQL Injection?
Ya, selama kamu memakai method biasa seperti where, insert, dan update, karena nilainya dikirim lewat parameter binding. Risiko muncul ketika menempelkan input pengguna langsung ke DB::raw(), whereRaw(), atau memakai input sebagai nama kolom.
Kenapa hasil get() tidak bisa memanggil relasi?
Karena Query Builder mengembalikan stdClass, bukan model. Relasi seperti $user->posts hanya ada di Eloquent. Di Query Builder, ambil data terkait dengan join atau query kedua memakai whereIn.
Apa beda first(), find(), dan value()?
first() mengambil baris pertama yang cocok dengan kondisi, find($id) adalah jalan pintas untuk mencari berdasarkan kolom id, dan value('kolom') langsung mengembalikan isi satu kolom dari baris pertama. Ketiganya mengembalikan null jika data tidak ditemukan.
Bagaimana mengolah hasil query setelah diambil?
Hasil get() adalah Collection, jadi kamu bisa memakai method seperti map, filter, groupBy, dan sum. Panduannya ada di artikel cara menggunakan Laravel Collection. Meski begitu, filter sebanyak mungkin di database lebih dulu, baru olah sisanya di Collection.
Kesimpulan
Query Builder Laravel adalah alat yang sangat powerful untuk berinteraksi dengan database secara fleksibel. Dengan method chaining yang intuitif, kamu bisa membangun query kompleks mulai dari select, join, agregasi, hingga insert dan update, semuanya dengan perlindungan dari SQL Injection secara otomatis. Gunakan Query Builder ketika kamu butuh kendali penuh atas query tanpa overhead Eloquent, terutama untuk query pelaporan atau query yang melibatkan banyak tabel. Ingat tiga kebiasaan penting: bungkus kondisi OR dengan closure, isi timestamps sendiri saat insert, dan jangan pernah menempelkan input pengguna ke raw expression. Dengan itu, query kamu akan tetap rapi, cepat, dan aman.