Pelajari kesalahan fatal Laravel Eloquent yang sering terjadi saat vibe coding tanpa pemahaman fundamental. Artikel ini membahas studi kasus nyata dari pengalaman saya review projek student yang menyebabkan website lemot parah, CPU spike 100% terus-menerus, dan tagihan hosting membengkak hingga 5x lipat. Lengkap dengan penjelasan teknis, solusi step-by-step, dan best practices untuk menghindari masalah yang sama di projek kamu.
Bagian 1: The Phone Call — Malam yang Tidak Terlupakan
Jam 11 malam, HP saya bunyi. WhatsApp dari salah satu student BuildWithAngga.
Biasanya chat malam-malam isinya pertanyaan tentang error kecil atau minta review tugas. Tapi kali ini berbeda. Ada 7 pesan berturut-turut, dan yang terakhir:
"Kak Angga, tolong... website klien saya down. Hosting minta upgrade ke dedicated server. Klien mau minta ganti rugi. Saya bingung harus ngapain."
Saya langsung telepon.
Di ujung sana, suara Raka — nama samarannya — terdengar panik. Dia cerita situasinya:
"Ini projek pertama saya yang lumayan besar, Kak. Sistem inventory untuk UMKM. Udah jalan 2 bulan, tiba-tiba minggu ini website jadi super lemot. Terus tadi sore hosting kirim email, katanya CPU usage 100% terus-terusan. Mereka suspend account saya kalau tidak upgrade dalam 24 jam."
"Data di sistem itu berapa banyak?" tanya saya.
"Sekitar 500 user aktif, Kak. Products-nya... mungkin 50 ribu-an. Transaksi udah ada 30 ribu lebih."
500 user. 50 ribu products. Di shared hosting.
Seharusnya itu bukan masalah. Saya pernah handle sistem dengan traffic 10x lebih besar di server yang sama. Ada yang salah dengan code-nya.
"Kirim akses repository-nya. Saya cek dulu."
Lima menit kemudian, saya sudah clone repo-nya dan buka di Cursor.
Dan apa yang saya temukan dalam 30 menit pertama membuat saya paham kenapa website dengan 500 user aktif bisa makan resource seperti melayani 50.000 user sekaligus.
The Symptoms: Tanda-Tanda Masalah
Sebelum masuk ke technical details, ini symptoms yang Raka laporkan — dan yang mungkin kamu juga pernah alami:
| Symptom | Yang Terjadi | Dampak ke Bisnis |
|---|---|---|
| Response time 15-30 detik | Setiap halaman butuh waktu sangat lama untuk load | User frustasi, banyak yang refresh atau tinggalkan |
| CPU usage 100% constant | Processor server kerja maksimal terus-menerus | Hosting throttle atau suspend account |
| RAM usage maksimal | Memory server habis terpakai | Out of memory errors, crash |
| Database connections maxed | Semua koneksi database terpakai | Connection timeout, queries gagal |
| Tagihan hosting naik 5x | Dari Rp 500rb ke Rp 2.5jt/bulan | Klien komplain, minta ganti rugi |
Kalau kamu pernah mengalami satu atau lebih dari symptoms di atas, kemungkinan besar ada masalah fundamental di cara code kamu interact dengan database.
Dan dalam kasus Raka, masalahnya bukan satu — tapi kombinasi dari beberapa kesalahan fatal yang saling compound.
Preview: Apa yang Akan Kita Bahas
Di artikel ini, saya akan breakdown:
- Apa yang salah — Findings dari investigation code Raka
- Kenapa bisa terjadi — Root cause dari vibe coding tanpa fundamental
- Technical deep-dive — Eloquent, Query Builder, dan kapan pakai yang mana
- The fixes — Step-by-step cara repair dengan before/after code
- Prevention — Checklist supaya kamu tidak mengalami hal yang sama
Ini bukan artikel untuk menyalahkan siapapun. Raka adalah developer yang hardworking — dia cuma tidak tahu apa yang tidak dia tahu. Dan itu yang berbahaya.
Let's dive in.
Bagian 2: The Investigation — Membongkar Code yang Bermasalah
Hal pertama yang saya lakukan setelah clone repository: enable query logging.
// Di awal request, saya tambahkan ini
DB::enableQueryLog();
// Load halaman dashboard seperti biasa
// ...
// Di akhir, cek berapa queries yang jalan
$queries = DB::getQueryLog();
dd(count($queries));
Output yang muncul:
2847
Dua ribu delapan ratus empat puluh tujuh queries.
Untuk load SATU halaman dashboard.
Saya refresh, angkanya konsisten di kisaran 2.800-3.000 queries per page load. Ini sudah red flag besar. Dashboard yang well-optimized biasanya butuh 10-20 queries maksimal.
Tapi dari mana 2.847 queries itu datang?
Finding #1: N+1 Query Hell
Saya buka DashboardController.php. Dan langsung ketemu masalah pertama:
// ❌ CODE YANG SAYA TEMUKAN
public function index()
{
// Load SEMUA products (50,000 rows!)
$products = Product::all();
// Loop setiap product
foreach ($products as $product) {
// Setiap akses relationship = 1 query baru
$categoryName = $product->category->name; // Query!
$supplierName = $product->supplier->company; // Query!
$totalStock = $product->stocks->sum('qty'); // Query!
}
return view('dashboard.index', compact('products'));
}
Mari kita hitung:
- 1 query untuk
Product::all()→ load 50,000 products - 50,000 queries untuk
$product->category->name - 50,000 queries untuk
$product->supplier->company - 50,000 queries untuk
$product->stocks->sum('qty')
Total: 1 + 50,000 + 50,000 + 50,000 = 150,001 queries
Untuk SATU method di SATU controller.
Dan ini baru dashboard. Belum halaman lain.
Finding #2: Memory Killer di Export Function
Selanjutnya saya cek fitur export report yang katanya sering timeout:
// ❌ CODE YANG SAYA TEMUKAN
public function exportTransactions()
{
// Load SEMUA transaksi sekaligus ke memory
$transactions = Transaction::all(); // 30,000+ records
$data = [];
foreach ($transactions as $trx) {
$data[] = [
'id' => $trx->id,
'date' => $trx->created_at->format('Y-m-d'),
'customer' => $trx->customer->name, // N+1 lagi!
'total' => $trx->total,
'items_count' => $trx->items->count(), // N+1 lagi!
];
}
return Excel::download(new TransactionsExport($data), 'transactions.xlsx');
}
Masalahnya:
Transaction::all()load 30,000+ records langsung ke memory- Setiap record butuh ~1-2KB memory → 30,000 × 2KB = 60MB minimum hanya untuk variable
$transactions - Belum termasuk N+1 untuk
customerdanitems - Array
$dataduplicate data lagi → tambah 60MB+ - Total memory bisa tembus 200-500MB untuk satu request
Shared hosting biasanya limit memory per request di 128-256MB. Pantas saja timeout dan out of memory.
Finding #3: Wrong Tool for Simple Jobs
Di bagian statistics dashboard:
// ❌ CODE YANG SAYA TEMUKAN
public function getStatistics()
{
// Untuk dapat COUNT, dia load SEMUA data dulu
$totalProducts = Product::all()->count(); // Load 50k, baru count
$totalOrders = Order::all()->count(); // Load 30k, baru count
$totalRevenue = Order::all()->sum('total'); // Load 30k lagi, baru sum
$lowStock = Product::all()->filter(function ($p) {
return $p->stock < 10;
})->count(); // Load 50k, filter di PHP
return [
'products' => $totalProducts,
'orders' => $totalOrders,
'revenue' => $totalRevenue,
'low_stock' => $lowStock,
];
}
Setiap ::all() load SEMUA data ke memory, baru diproses di PHP.
Padahal database bisa langsung return angkanya dengan satu query ringan. Ini seperti minta restoran kirim semua bahan makanan ke rumah, baru kamu hitung ada berapa telur — instead of tanya langsung "ada berapa telur?"
Finding #4: Bulk Operations tanpa Chunking
Di fitur kirim newsletter:
// ❌ CODE YANG SAYA TEMUKAN
public function sendNewsletter(Request $request)
{
$users = User::where('subscribed', true)->get(); // 8,000 users
foreach ($users as $user) {
// Kirim email satu-satu
Mail::to($user->email)->send(new NewsletterMail($request->content));
}
return back()->with('success', 'Newsletter sent!');
}
Masalahnya:
- Load 8,000 users sekaligus ke memory
- Memory tidak pernah di-release selama loop
- Setiap iterasi, memory usage naik terus
- Di tengah jalan: out of memory, email berhenti setengah jalan
- Tidak ada tracking mana yang sudah terkirim
The Verdict: Summary of Disasters
Setelah 2 jam investigation, ini summary yang saya compile:
| Location | Problem | Query Count | Memory Impact |
|---|---|---|---|
| Dashboard | N+1 pada products + relations | ~150,000/load | High |
| Statistics | all()->count() pattern | 4 heavy queries | Very High |
| Export | No chunking, N+1 | ~90,000/export | Critical (OOM) |
| Search | LIKE tanpa index | Full table scan | Medium |
| Newsletter | No chunking | 8,000 queries | Critical (OOM) |
| Product List | No pagination | Loads all | High |
Total queries per typical user session: 200,000+
Tidak heran server menyerah.
Bagian 3: Root Cause — Ketika Vibe Coding Tanpa Fundamental
Setelah selesai investigation, saya telepon Raka lagi.
"Dek, ini code dapat dari mana?"
"Dari ChatGPT kak. Saya kasih prompt feature yang mau dibuat, terus dia generate. Kadang dari GitHub Copilot juga. Kalau kerjanya ya saya pakai langsung."
"Testing-nya gimana sebelum deploy?"
"Saya test di local, Kak. Lancar-lancar aja."
"Local database-nya berapa records?"
Jeda sebentar.
"...Sekitar 10-20 products buat testing, Kak. Users juga cuma 3-4."
Dan di situlah masalahnya.
The Fundamental Problem
Code yang di-generate AI memang syntactically correct dan functionally working. Kalau kamu test dengan 10 products, semuanya jalan lancar. Response time bagus. Tidak ada error.
Tapi AI tidak tahu:
- Production database kamu akan punya 50,000 products
- Server kamu shared hosting dengan memory limit 256MB
- User akan akses secara concurrent
- Export akan dijalankan untuk data 3 tahun sekaligus
AI generate code yang "works" untuk happy path. Bukan code yang "scales" untuk production reality.
"AI bisa generate code yang syntactically correct dan functionally working. Tapi AI tidak tahu production environment kamu, data volume kamu, atau constraint hosting kamu. Tanpa fundamental knowledge, kamu tidak bisa evaluate apakah code dari AI itu optimal atau time bomb yang menunggu meledak."
What AI Got Wrong (dan Kenapa)
Ini pattern yang saya sering lihat dari code yang di-generate AI tanpa review:
| Prompt ke AI | AI Generate | Yang Seharusnya | Kenapa AI Miss |
|---|---|---|---|
| "Get all products with category" | Product::all() + foreach loop | Product::with('category')->paginate() | AI tidak tahu jumlah data |
| "Calculate total revenue" | Order::all()->sum('total') | Order::sum('total') | AI default ke pattern verbose |
| "Send email to all users" | User::all() + foreach Mail | User::chunk(100) + queue | AI tidak consider memory |
| "Count products" | Product::all()->count() | Product::count() | AI tidak optimize |
| "Get low stock products" | all()->filter() | where('stock', '<', 10) | AI process di PHP, bukan SQL |
AI cenderung generate code yang:
- Explicit dan verbose (lebih mudah dipahami)
- Uses high-level abstractions (Eloquent untuk semuanya)
- Process di application layer (PHP) bukan database layer
- Tidak consider scale atau performance
Ini bukan salah AI-nya. AI tidak punya context tentang production environment. Yang salah adalah deploy code tanpa understand implikasinya.
The Knowledge Gap
Raka bukan developer yang malas atau tidak capable. Dia sudah bisa:
- Setup Laravel project dari scratch
- Implementasi authentication
- Buat CRUD untuk berbagai entity
- Deploy ke shared hosting
Tapi dia tidak tahu:
- Perbedaan Eloquent dan Query Builder
- Apa itu N+1 problem dan cara detect-nya
- Kapan harus pakai
chunk(),cursor(), ataulazy() - Bagaimana database indexes bekerja
- Memory management di PHP
Ini knowledge gap yang tidak terlihat sampai production bermasalah.
Analogi yang Mungkin Membantu
Bayangkan kamu bisa nyetir mobil — gas, rem, belok, parkir. Semua lancar.
Tapi kamu tidak paham cara kerja mesin. Tidak tahu kapan harus ganti oli. Tidak ngerti kenapa AC kadang tidak dingin. Tidak paham bunyi-bunyi aneh dari kap mesin.
Selama jalan mulus, tidak masalah. Mobil jalan, kamu sampai tujuan.
Tapi suatu hari:
- Mobil overheat di tengah tol
- Transmisi bermasalah karena tidak pernah service
- Aki tekor karena ada yang konslet
Kamu stuck. Tidak tahu harus ngapain. Harus panggil derek dan bayar mahal untuk sesuatu yang sebenarnya bisa di-prevent.
Vibe coding tanpa fundamental itu sama.
Code-nya jalan. Feature-nya works. Sampai suatu hari production tiba-tiba lemot, server crash, dan klien telepon marah-marah.
Dan kamu tidak tahu harus mulai dari mana untuk fix.
The Uncomfortable Truth
Ini yang perlu kita akui:
Vibe coding dengan AI adalah productivity multiplier yang luar biasa — tapi hanya kalau kamu punya fundamental untuk evaluate output-nya.
Kalau tidak:
- Kamu tidak tahu code itu optimal atau tidak
- Kamu tidak tahu ada potential issues atau tidak
- Kamu tidak bisa debug ketika production bermasalah
- Kamu bergantung 100% pada AI yang tidak tahu context-mu
Raka spend 2 minggu bikin sistem dengan vibe coding. Kemudian spend 3 malam begadang fixing production issues. Plus almost lost the client. Plus bayar hosting upgrade yang tidak perlu.
2 minggu "saved" by vibe coding. 3 minggu+ lost untuk fixing dan learning the hard way.
Kalau dari awal dia invest 1-2 minggu untuk paham Laravel fundamentals dengan proper, entire situation ini bisa di-avoid.
"Setiap line code yang kamu deploy adalah tanggung jawab kamu — bukan AI yang generate. Kamu yang kena kalau server crash. Kamu yang harus jelaskan ke klien. Kamu yang harus fix jam 2 pagi. Invest waktu untuk paham fundamental. Shortcut tanpa understanding = technical debt yang akan ditagih dengan bunga."
Tapi Ini Bukan untuk Menyalahkan
Let me be clear: Saya tidak menulis ini untuk menyalahkan Raka atau siapapun yang vibe coding.
AI-assisted development adalah future. Saya sendiri pakai Cursor + Claude setiap hari. Productivity gain-nya real.
Tapi yang saya tekankan adalah: AI adalah tool, bukan pengganti knowledge.
Sama seperti:
- Calculator tidak menggantikan pemahaman matematika
- Google Maps tidak menggantikan kemampuan navigasi
- Spell checker tidak menggantikan kemampuan menulis
AI code assistant tidak menggantikan pemahaman programming fundamentals.
The sweet spot adalah: Fundamental knowledge + AI assistance = Maximum productivity dengan minimum risk.
Di bagian selanjutnya, kita akan deep dive ke technical knowledge yang seharusnya Raka (dan mungkin kamu) punya sebelum production deployment.
Next: Bagian 4 — Laravel Query Fundamentals
Kita akan bahas kapan pakai Eloquent, kapan pakai DB Query Builder, dan kapan pakai Raw SQL. Ini foundation yang akan menentukan apakah aplikasi kamu bisa scale atau akan jadi time bomb.
Bagian 4: Laravel Query Fundamentals — Yang Harus Dipahami
Sekarang kita masuk ke technical meat dari artikel ini. Bagian ini yang akan membedakan kamu sebagai developer yang "bisa bikin jalan" dengan developer yang "bisa bikin jalan dengan baik".
Laravel menyediakan beberapa cara untuk interact dengan database. Masing-masing punya trade-off:
SPECTRUM OF LARAVEL DATABASE ACCESS:
Eloquent Model ←————————————————————→ Raw SQL
[Heavy/Feature-rich] [Light/Manual]
Model::query() DB::table() DB::select() DB::statement()
↓ ↓ ↓ ↓
Full ORM Query Builder Raw Select Raw Statement
Relations No hydration Plain arrays No return
Scopes No events Full control DDL operations
Events Lighter Lightest Lightest
Casts
Mari kita breakdown satu per satu.
1. Eloquent Model — Model::query()
Eloquent adalah ORM (Object-Relational Mapping) Laravel. Ini cara paling "Laravel" untuk interact dengan database.
Kapan Pakai Eloquent:
- CRUD operations dengan jumlah data kecil-menengah (1-100 records)
- Butuh relationships (
with(),belongsTo,hasMany) - Butuh model features (scopes, casts, events, observers)
- Single record operations (
find(),first(),findOrFail()) - Butuh mutators dan accessors
// ✅ GOOD: Eloquent untuk single record dengan relations
$order = Order::query()
->with(['customer', 'items.product']) // Eager load relations
->findOrFail($id);
// ✅ GOOD: Eloquent dengan scopes dan eager loading
$products = Product::query()
->active() // Custom scope
->inStock() // Custom scope
->with('category') // Eager load
->latest()
->paginate(20);
// ✅ GOOD: Eloquent untuk create dengan events
$product = Product::create([
'name' => 'New Product',
'price' => 100000,
]);
// Events fired: creating, created
// Observers triggered
// Casts applied
Cost Analysis Eloquent:
| Feature | Performance Cost | Notes |
|---|---|---|
| Model Hydration | High | Creates full PHP object per row |
| Events/Observers | Medium | Fires on every create/update/delete |
| Attribute Casting | Low-Medium | Transforms on access |
$with (default eager load) | Variable | Auto loads relations |
$appends | Medium | Computes accessors on toArray/toJson |
| Timestamps | Low | Auto-managed created_at/updated_at |
Kapan JANGAN Pakai Eloquent:
- Load ribuan records sekaligus
- Simple aggregations (COUNT, SUM, AVG)
- Bulk updates/inserts
- Complex reporting queries
- Ketika tidak butuh model features sama sekali
2. DB Query Builder — DB::table()
Query Builder adalah layer di bawah Eloquent. Tidak ada model hydration, tidak ada events, tidak ada casts. Just pure query building.
Kapan Pakai DB::table():
- Aggregations (COUNT, SUM, AVG, MIN, MAX)
- Bulk operations (insert/update ratusan atau ribuan rows)
- Reporting queries yang complex
- Ketika tidak butuh model features
- Performance-critical operations
// ✅ GOOD: DB::table untuk aggregates
$stats = DB::table('orders')
->selectRaw("
COUNT(*) as total_orders,
COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed,
COUNT(CASE WHEN status = 'pending' THEN 1 END) as pending,
COALESCE(SUM(total), 0) as revenue,
AVG(total) as average_order
")
->whereYear('created_at', now()->year)
->whereMonth('created_at', now()->month)
->first();
// Result: Single stdClass object dengan semua stats
// Memory: Minimal (just one row returned)
// Queries: 1
// ✅ GOOD: DB::table untuk bulk update
DB::table('products')
->where('stock', 0)
->update([
'status' => 'out_of_stock',
'updated_at' => now(),
]);
// ✅ GOOD: DB::table untuk bulk insert
$chunks = array_chunk($productsData, 500);
foreach ($chunks as $chunk) {
DB::table('products')->insert($chunk);
}
Comparison: Eloquent vs DB::table untuk COUNT
// ❌ TERRIBLE: Load semua ke memory, count di PHP
$count = Product::all()->count();
// Memory: ~50MB untuk 50k products
// Time: 3-5 seconds
// ⚠️ BETTER: Eloquent count (tapi still some overhead)
$count = Product::count();
// Memory: Minimal
// Time: 50-100ms
// ✅ BEST: Direct DB count
$count = DB::table('products')->count();
// Memory: Minimal
// Time: 30-50ms
// No model overhead sama sekali
3. Raw Queries — selectRaw(), whereRaw(), DB::select()
Kadang kamu butuh SQL expressions yang tidak bisa di-express dengan Query Builder. Di sinilah raw queries berguna.
Kapan Pakai Raw Queries:
- CASE WHEN statements
- Complex mathematical operations
- Database-specific functions
- Subqueries yang complex
- HAVING dengan conditions complex
- Custom ORDER BY logic
// ✅ selectRaw untuk CASE WHEN
$report = DB::table('orders')
->selectRaw("
DATE(created_at) as date,
SUM(CASE WHEN status = 'completed' THEN total ELSE 0 END) as revenue,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed_count,
SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) as cancelled_count,
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending_count
")
->whereBetween('created_at', [$startDate, $endDate])
->groupBy(DB::raw('DATE(created_at)'))
->orderBy('date', 'desc')
->get();
// ✅ whereRaw untuk complex conditions
$products = Product::query()
->whereRaw('price > (SELECT AVG(price) FROM products)')
->whereRaw('stock BETWEEN ? AND ?', [10, 100])
->get();
// ✅ orderByRaw untuk custom sorting
$products = Product::query()
->orderByRaw("FIELD(status, 'active', 'pending', 'inactive')")
->get();
// ✅ havingRaw untuk aggregate conditions
$categories = DB::table('products')
->select('category_id', DB::raw('COUNT(*) as product_count'))
->groupBy('category_id')
->havingRaw('COUNT(*) > ?', [10])
->get();
Full Raw Query untuk Very Complex Needs:
// Ketika Query Builder sudah tidak cukup
$results = DB::select("
SELECT
p.id,
p.name,
c.name as category_name,
COALESCE(SUM(oi.quantity), 0) as total_sold,
COALESCE(SUM(oi.quantity * oi.price), 0) as total_revenue
FROM products p
LEFT JOIN categories c ON c.id = p.category_id
LEFT JOIN order_items oi ON oi.product_id = p.id
LEFT JOIN orders o ON o.id = oi.order_id
AND o.status = 'completed'
AND o.created_at BETWEEN ? AND ?
GROUP BY p.id, p.name, c.name
HAVING total_sold > 0
ORDER BY total_sold DESC
LIMIT 10
", [$startDate, $endDate]);
// Returns: Array of stdClass objects
Quick Reference: Decision Matrix
Ini table yang bisa kamu bookmark untuk quick reference:
| Data Size | Operation | Recommended | Code Example |
|---|---|---|---|
| 1-100 rows | Display dengan relations | Model::with()->get() | Product::with('category')->get() |
| 1-100 rows | Single record | Model::find() | Product::findOrFail($id) |
| 1-100 rows | Counts per record | withCount() | Category::withCount('products') |
| 100-10k rows | Display in paginated table | Model::paginate() | Product::paginate(25) |
| 100-10k rows | Aggregate numbers | DB::table()->selectRaw() | See examples above |
| 100-10k rows | Bulk update | DB::table()->update() | DB::table('x')->where()->update() |
| 10k+ rows | Process/transform each | chunk() atau lazy() | Model::chunk(500, fn) |
| 10k+ rows | Read-only iteration | cursor() | Model::cursor() |
| 10k+ rows | Bulk insert | DB::table()->insert() batches | Insert in chunks of 500-1000 |
| 10k+ rows | Export to file | cursor() + streaming | Stream response |
| Any size | Complex SQL expressions | selectRaw() / DB::select() | CASE WHEN, subqueries |
The Golden Rules
Setelah bertahun-tahun kerja dengan Laravel, ini rules yang saya pegang:
RULE 1: Default ke yang paling ringan yang masih meet requirements
DB::table() > Eloquent kalau tidak butuh model features
RULE 2: Jangan pernah load semua data kalau cuma butuh agregat
->count() bukan ->all()->count()
->sum('column') bukan ->all()->sum()
RULE 3: Selalu eager load relationships yang akan diakses
->with('relation') SEBELUM ->get()
RULE 4: Paginate atau chunk untuk large datasets
Jangan pernah ::all() untuk table yang bisa grow
RULE 5: Let database do the heavy lifting
Filter, sort, aggregate di SQL — bukan di PHP
"Eloquent untuk FEATURES — relations, scopes, events, casts. DB::table untuk PERFORMANCE — aggregates, bulk operations, reporting. Raw SQL ketika butuh complex expressions. Default ke yang paling ringan yang still meet your requirements."
Bagian 5: N+1 Problem Deep Dive — Pembunuh Performance #1
Dari semua masalah yang saya temukan di code Raka, N+1 adalah yang paling destructive. Ini juga masalah paling umum yang saya temukan di code review.
Apa Itu N+1 Problem?
N+1 adalah pattern dimana kamu:
- Execute 1 query untuk get main data (N records)
- Execute N additional queries untuk get related data — satu per record
Kalau N = 1,000 records, kamu execute 1,001 queries. Kalau N = 50,000 records, kamu execute 50,001 queries.
Visual Explanation:
❌ N+1 PROBLEM IN ACTION:
═══════════════════════════════════════════════════════════════
$products = Product::all();
foreach ($products as $product) {
echo $product->category->name; // 👈 Triggers query setiap loop!
}
Query #1: SELECT * FROM products;
→ Returns 1000 rows
Query #2: SELECT * FROM categories WHERE id = 5; (for product 1)
Query #3: SELECT * FROM categories WHERE id = 3; (for product 2)
Query #4: SELECT * FROM categories WHERE id = 5; (for product 3)
Query #5: SELECT * FROM categories WHERE id = 7; (for product 4)
...
Query #1001: SELECT * FROM categories WHERE id = 2; (for product 1000)
TOTAL: 1,001 queries untuk display 1000 products dengan category name
═══════════════════════════════════════════════════════════════
✅ WITH EAGER LOADING:
═══════════════════════════════════════════════════════════════
$products = Product::with('category')->get();
foreach ($products as $product) {
echo $product->category->name; // 👈 Already loaded, no query!
}
Query #1: SELECT * FROM products;
→ Returns 1000 rows
Query #2: SELECT * FROM categories WHERE id IN (5, 3, 7, 2, ...);
→ Returns all needed categories in ONE query
TOTAL: 2 queries untuk hasil yang SAMA
═══════════════════════════════════════════════════════════════
PERFORMANCE DIFFERENCE:
• N+1: 1001 queries × ~5ms average = ~5 seconds
• Eager Load: 2 queries × ~10ms average = ~20 milliseconds
• Improvement: 250x faster
Code Examples: Before dan After
Scenario 1: Basic Relationship
// ❌ BAD: N+1 Problem
$products = Product::all();
foreach ($products as $product) {
echo $product->name;
echo $product->category->name; // Query setiap iterasi!
echo $product->supplier->company; // Query lagi setiap iterasi!
}
// Total: 1 + N + N = 1 + 1000 + 1000 = 2001 queries
// ───────────────────────────────────────────────────────────
// ✅ GOOD: Eager Loading
$products = Product::with(['category', 'supplier'])->get();
foreach ($products as $product) {
echo $product->name;
echo $product->category->name; // Already loaded
echo $product->supplier->company; // Already loaded
}
// Total: 1 + 1 + 1 = 3 queries
Scenario 2: Nested Relationships
// ❌ BAD: Nested N+1
$orders = Order::all();
foreach ($orders as $order) {
echo $order->customer->name; // N+1 level 1
foreach ($order->items as $item) { // N+1 level 2
echo $item->product->name; // N+1 level 3
echo $item->product->category->name; // N+1 level 4
}
}
// Queries explode exponentially!
// ───────────────────────────────────────────────────────────
// ✅ GOOD: Nested Eager Loading
$orders = Order::with([
'customer',
'items.product.category' // Dot notation untuk nested
])->get();
foreach ($orders as $order) {
echo $order->customer->name; // Already loaded
foreach ($order->items as $item) { // Already loaded
echo $item->product->name; // Already loaded
echo $item->product->category->name; // Already loaded
}
}
// Total: 4 queries regardless of data size
Scenario 3: Conditional Eager Loading
// ✅ Load hanya reviews dengan rating tinggi, limit 5
$products = Product::with(['reviews' => function ($query) {
$query->where('rating', '>=', 4)
->latest()
->limit(5);
}])->get();
// ✅ Load hanya active items
$orders = Order::with(['items' => function ($query) {
$query->where('status', 'active');
}])->get();
Aggregates tanpa Load: withCount, withSum, withAvg
Kadang kamu tidak butuh load seluruh relation — cuma butuh aggregate-nya.
// ❌ BAD: Load semua products hanya untuk count
$categories = Category::all();
foreach ($categories as $category) {
echo $category->products()->count(); // N+1!
}
// ───────────────────────────────────────────────────────────
// ✅ GOOD: withCount — count jadi column
$categories = Category::withCount('products')->get();
foreach ($categories as $category) {
echo $category->products_count; // Sudah ada, no extra query
}
// ✅ withSum — sum jadi column
$customers = Customer::withSum('orders', 'total')->get();
// Access: $customer->orders_sum_total
// ✅ withAvg — average jadi column
$products = Product::withAvg('reviews', 'rating')->get();
// Access: $product->reviews_avg_rating
// ✅ Multiple aggregates sekaligus
$users = User::query()
->withCount('orders') // orders_count
->withSum('orders', 'total') // orders_sum_total
->withAvg('orders', 'total') // orders_avg_total
->withMax('orders', 'created_at') // orders_max_created_at
->get();
Mendeteksi N+1 di Code Kamu
Method 1: Query Log (Quick Check)
// Tambahkan di awal request/test
DB::enableQueryLog();
// ... jalankan code yang mau di-check ...
// Lihat hasilnya
$queries = DB::getQueryLog();
dd([
'total_queries' => count($queries),
'queries' => $queries
]);
Method 2: Laravel Debugbar (Recommended untuk Development)
composer require barryvdh/laravel-debugbar --dev
Debugbar akan muncul di browser dan menunjukkan:
- Total query count
- Duplicate queries (indicator N+1)
- Query time
- Memory usage
Method 3: Strict Mode — Prevent Lazy Loading (Laravel 9+)
// Di App\\Providers\\AppServiceProvider boot()
use Illuminate\\Database\\Eloquent\\Model;
public function boot()
{
// Throw exception kalau ada lazy loading
Model::preventLazyLoading(!app()->isProduction());
// Atau kalau mau log instead of exception
Model::handleLazyLoadingViolationUsing(function ($model, $relation) {
logger()->warning("N+1 detected: {$model}::{$relation}");
});
}
Dengan strict mode, setiap kali code kamu trigger lazy loading (N+1), Laravel akan throw exception di development. Ini cara paling efektif untuk catch N+1 sebelum production.
Real Fix dari Case Study Raka
Ini transformasi yang saya lakukan untuk dashboard Raka:
// ❌ BEFORE: 150,001 queries
public function index()
{
$products = Product::all();
foreach ($products as $product) {
$product->category->name;
$product->supplier->company;
$product->stocks->sum('qty');
}
return view('dashboard', compact('products'));
}
// ───────────────────────────────────────────────────────────
// ✅ AFTER: 3 queries
public function index()
{
$products = Product::query()
->with(['category', 'supplier']) // Eager load relations
->withSum('stocks', 'qty') // Aggregate tanpa load
->latest()
->paginate(25); // Paginate, jangan load semua
return view('dashboard', compact('products'));
}
// Results:
// Query 1: SELECT * FROM products ORDER BY created_at DESC LIMIT 25
// Query 2: SELECT * FROM categories WHERE id IN (...)
// Query 3: SELECT product_id, SUM(qty) FROM stocks WHERE product_id IN (...) GROUP BY product_id
// Total: 3 queries, <50ms, ~2MB memory
Bagian 6: Memory Management — Chunk, Cursor, Lazy
Setelah N+1, masalah terbesar kedua di code Raka adalah memory management. Setiap kali load data besar, server kehabisan memory.
The Memory Problem
Ketika kamu call User::all() pada table dengan 100,000 rows:
$users = User::all();
PHP akan:
- Execute query ke database
- Fetch SEMUA 100,000 rows
- Create 100,000 Eloquent model objects
- Store semuanya di memory
Kalau setiap Eloquent model butuh ~1KB memory:
100,000 rows × 1KB = 100MB memory
Hanya untuk SATU variable.
Belum termasuk:
- Memory untuk processing
- Memory untuk relationships yang ter-load
- Memory untuk response/view rendering
Shared hosting biasanya limit memory 128-256MB per request. Satu query bisa langsung habiskan quota.
Tiga Solusi: Chunk, Cursor, Lazy
Laravel menyediakan tiga cara untuk handle large datasets tanpa memory explosion:
| Method | Cara Kerja | Memory Usage | Best For |
|---|---|---|---|
chunk($size, $callback) | Multiple queries, batch processing | Low (batch size) | Processing + updating records |
chunkById($size, $callback) | Multiple queries by ID | Low (batch size) | Safe updates during iteration |
cursor() | Single query, PHP generator | Very low (1 row) | Read-only streaming |
lazy($size) | Single query, LazyCollection | Very low (1 row) | Read-only with collection methods |
chunk() — Batch Processing
chunk() membagi data menjadi batches dan process satu batch pada satu waktu:
// ❌ BAD: Load semua 100k users sekaligus
$users = User::all();
foreach ($users as $user) {
$this->sendEmail($user);
}
// Memory: 100MB+, kemungkinan crash
// ───────────────────────────────────────────────────────────
// ✅ GOOD: Process 500 users per batch
User::where('subscribed', true)
->chunk(500, function ($users) {
foreach ($users as $user) {
$this->sendEmail($user);
}
// Memory di-release setelah setiap batch
});
// Behind the scenes:
// Query 1: SELECT * FROM users WHERE subscribed = 1 LIMIT 500 OFFSET 0
// [process 500 users, release memory]
// Query 2: SELECT * FROM users WHERE subscribed = 1 LIMIT 500 OFFSET 500
// [process 500 users, release memory]
// Query 3: SELECT * FROM users WHERE subscribed = 1 LIMIT 500 OFFSET 1000
// ... continues until all processed
Stop Processing Early:
User::chunk(500, function ($users) {
foreach ($users as $user) {
if ($this->shouldStop()) {
return false; // Stop chunking
}
$this->process($user);
}
});
chunkById() — Safe for Updates
Ada gotcha dengan chunk(): kalau kamu UPDATE atau DELETE records selama iteration, offset bisa kacau dan skip records.
chunkById() solve ini dengan pagination berbasis ID:
// ⚠️ PROBLEM dengan chunk() saat update:
// Kalau kamu update 'status' yang ada di WHERE clause,
// offset akan bergeser dan beberapa records bisa ke-skip
// ✅ chunkById() — aman untuk updates
User::where('status', 'pending')
->chunkById(500, function ($users) {
foreach ($users as $user) {
$user->update(['status' => 'processed']);
}
});
// Behind the scenes:
// Query 1: SELECT * FROM users WHERE status = 'pending' AND id > 0 ORDER BY id LIMIT 500
// Query 2: SELECT * FROM users WHERE status = 'pending' AND id > 500 ORDER BY id LIMIT 500
// Pagination berdasarkan ID terakhir, bukan offset
cursor() — Minimum Memory
cursor() menjalankan SATU query tapi stream hasilnya menggunakan PHP Generator. Hanya 1 row di memory pada satu waktu:
// Memory stays flat regardless of data size
foreach (User::where('active', true)->cursor() as $user) {
$this->processUser($user);
// $user di-release, next iteration load user berikutnya
}
Comparison dengan chunk():
| Aspect | chunk() | cursor() |
|---|---|---|
| Queries | Multiple (LIMIT/OFFSET) | Single query |
| Memory per iteration | Batch size × model size | 1 × model size |
| Database connection | Released between batches | Held open during iteration |
| Best for | Processing + updates | Read-only operations |
| Caveat | Offset issues on update | Long-running connection |
lazy() — Best of Both Worlds
lazy() mirip dengan cursor() tapi return LazyCollection yang punya semua collection methods:
// lazy() dengan collection methods
User::where('active', true)
->lazy(500)
->filter(fn ($user) => $user->hasVerifiedEmail())
->map(fn ($user) => $user->email)
->each(fn ($email) => $this->sendTo($email));
// Cleaner syntax untuk complex operations
$stats = Product::where('status', 'active')
->lazy()
->groupBy('category_id')
->map(fn ($products) => [
'count' => $products->count(),
'avg_price' => $products->avg('price'),
]);
Real Fix dari Case Study: Export Function
Ini transformasi untuk fitur export yang selalu timeout:
// ❌ BEFORE: Out of memory
public function exportTransactions()
{
$transactions = Transaction::all(); // Load 100k records
$data = [];
foreach ($transactions as $trx) {
$data[] = [
'id' => $trx->id,
'date' => $trx->created_at->format('Y-m-d'),
'customer' => $trx->customer->name, // N+1!
'total' => $trx->total,
];
}
return Excel::download(new TransactionsExport($data), 'transactions.xlsx');
}
// Problems: Memory explosion, N+1, array duplication
// ───────────────────────────────────────────────────────────
// ✅ AFTER: Memory-efficient streaming
public function exportTransactions()
{
return response()->streamDownload(function () {
// Open output stream
$handle = fopen('php://output', 'w');
// Write header
fputcsv($handle, ['ID', 'Date', 'Customer', 'Total']);
// Stream data dengan cursor
Transaction::query()
->select(['id', 'created_at', 'customer_id', 'total'])
->with('customer:id,name') // Eager load, select specific columns
->cursor()
->each(function ($trx) use ($handle) {
fputcsv($handle, [
$trx->id,
$trx->created_at->format('Y-m-d'),
$trx->customer->name,
$trx->total,
]);
});
fclose($handle);
}, 'transactions.csv', [
'Content-Type' => 'text/csv',
]);
}
// Result: Memory flat, no timeout, handles any data size
Decision Framework: Choosing the Right Method
┌─────────────────────────────────────────────────────────────┐
│ NEED TO UPDATE/DELETE DURING ITERATION? │
└─────────────────────────────┬───────────────────────────────┘
│
┌────────┴────────┐
│ │
YES NO
│ │
▼ ▼
┌────────────────┐ ┌─────────────────────────────┐
│ chunkById() │ │ DATA SIZE > 10k ROWS? │
│ │ └──────────────┬──────────────┘
│ Safe updates │ │
│ ID-based │ ┌────────┴────────┐
│ pagination │ │ │
└────────────────┘ YES NO
│ │
▼ ▼
┌─────────────────┐ ┌───────────┐
│ cursor() atau │ │ get() │
│ lazy() │ │ (normal) │
│ │ └───────────┘
│ Pick cursor() │
│ for simplicity │
│ │
│ Pick lazy() │
│ if need │
│ collection │
│ methods │
└─────────────────┘
Quick Tips
// TIP 1: Selalu specify columns yang dibutuhkan saja
User::select(['id', 'name', 'email'])->cursor()
// Lebih ringan daripada select *
// TIP 2: Gunakan lazy() untuk transformations
User::lazy()->map(...)->filter(...)->values()
// Collection methods available
// TIP 3: chunk dengan queue untuk long-running tasks
User::chunk(100, function ($users) {
foreach ($users as $user) {
ProcessUser::dispatch($user); // Queue job, don't process inline
}
});
// TIP 4: Monitor memory dalam loop
User::cursor()->each(function ($user) {
$this->process($user);
if (memory_get_usage() > 100 * 1024 * 1024) { // 100MB
gc_collect_cycles(); // Force garbage collection
}
});
Next: Bagian 7 — Aggregates & Subqueries
Di bagian selanjutnya, kita akan bahas cara menggunakan database untuk heavy lifting — agregasi, subqueries, dan computed columns yang dilakukan di SQL, bukan di PHP.
Bagian 7: Aggregates & Subqueries — Let Database Do the Heavy Lifting
Salah satu pattern yang paling sering saya lihat di code yang bermasalah: processing di PHP yang seharusnya dilakukan di database.
Database engines seperti MySQL dioptimize untuk operasi seperti COUNT, SUM, AVG, GROUP BY. Mereka bisa process jutaan rows dalam milliseconds. PHP? Tidak.
Stop Loading Data Just to Count
Ini pattern yang saya temukan di code Raka — dan mungkin di code kamu juga:
// ❌ TERRIBLE: Load 50k products ke memory untuk hitung jumlahnya
$count = Product::all()->count();
// Steps: Query 50k rows → Transfer ke PHP → Create 50k objects → Count di PHP
// Memory: ~50MB
// Time: 3-5 seconds
// ───────────────────────────────────────────────────────────
// ✅ GOOD: Database langsung return angka
$count = Product::count();
// Steps: Database COUNT(*) → Return single number
// Memory: ~1KB
// Time: 30-50ms
Side-by-side comparison:
| Operation | Bad Pattern | Good Pattern | Improvement |
|---|---|---|---|
| Count | ::all()->count() | ::count() | ~100x faster |
| Sum | ::all()->sum('price') | ::sum('price') | ~100x faster |
| Average | ::all()->avg('price') | ::avg('price') | ~100x faster |
| Min/Max | ::all()->min('price') | ::min('price') | ~100x faster |
| Filter + Count | ::all()->where()->count() | ::where()->count() | ~100x faster |
DB::table() untuk Complex Aggregates
Ketika butuh multiple aggregates atau complex calculations, DB::table() dengan selectRaw() adalah pilihan terbaik:
// ❌ BAD: Multiple heavy queries
$totalProducts = Product::all()->count();
$activeProducts = Product::where('status', 'active')->get()->count();
$totalValue = Product::all()->sum('price');
$avgPrice = Product::all()->avg('price');
// 4 queries, each loading potentially thousands of rows
// ───────────────────────────────────────────────────────────
// ✅ GOOD: Single lightweight query
$stats = DB::table('products')
->selectRaw("
COUNT(*) as total_products,
SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) as active_products,
SUM(CASE WHEN status = 'inactive' THEN 1 ELSE 0 END) as inactive_products,
COALESCE(SUM(price), 0) as total_value,
COALESCE(AVG(price), 0) as avg_price,
MIN(price) as min_price,
MAX(price) as max_price
")
->first();
// Access: $stats->total_products, $stats->active_products, etc.
// ONE query, returns ONE row dengan semua data yang dibutuhkan
Dashboard Statistics — The Right Way
Ini real transformation untuk statistics di dashboard Raka:
// ❌ BEFORE: Heavy, slow, memory-hungry
public function getStatistics()
{
return [
'total_products' => Product::all()->count(),
'low_stock' => Product::all()->filter(fn($p) => $p->stock < 10)->count(),
'total_orders' => Order::all()->count(),
'pending_orders' => Order::where('status', 'pending')->get()->count(),
'revenue_today' => Order::whereDate('created_at', today())->get()->sum('total'),
'revenue_month' => Order::whereMonth('created_at', now()->month)->get()->sum('total'),
];
}
// Problems: 6 heavy queries, loads thousands of rows, processes di PHP
// ───────────────────────────────────────────────────────────
// ✅ AFTER: Single optimized query
public function getStatistics()
{
return DB::table('products')
->selectRaw("
(SELECT COUNT(*) FROM products) as total_products,
(SELECT COUNT(*) FROM products WHERE stock < 10) as low_stock,
(SELECT COUNT(*) FROM orders) as total_orders,
(SELECT COUNT(*) FROM orders WHERE status = 'pending') as pending_orders,
(SELECT COALESCE(SUM(total), 0) FROM orders WHERE DATE(created_at) = CURDATE()) as revenue_today,
(SELECT COALESCE(SUM(total), 0) FROM orders WHERE MONTH(created_at) = MONTH(CURDATE()) AND YEAR(created_at) = YEAR(CURDATE())) as revenue_month
")
->first();
}
// Result: 1 query, ~10ms, minimal memory
withCount, withSum, withAvg — Per-Record Aggregates
Kadang kamu butuh aggregate untuk SETIAP record (bukan total keseluruhan). Di sinilah withCount() dan friends berguna:
// ❌ BAD: N+1 untuk count
$categories = Category::all();
foreach ($categories as $category) {
echo $category->name . ': ' . $category->products()->count();
// Query setiap iterasi!
}
// ───────────────────────────────────────────────────────────
// ✅ GOOD: withCount
$categories = Category::withCount('products')->get();
foreach ($categories as $category) {
echo $category->name . ': ' . $category->products_count;
// Sudah ada sebagai attribute
}
// Query yang dijalankan:
// SELECT categories.*, (SELECT COUNT(*) FROM products WHERE category_id = categories.id) as products_count FROM categories
All aggregate methods:
$results = User::query()
->withCount('orders') // orders_count
->withCount(['orders as completed_orders' => function ($q) {
$q->where('status', 'completed');
}]) // completed_orders
->withSum('orders', 'total') // orders_sum_total
->withAvg('orders', 'total') // orders_avg_total
->withMin('orders', 'created_at') // orders_min_created_at
->withMax('orders', 'created_at') // orders_max_created_at
->withExists('orders') // orders_exists (boolean)
->get();
Subqueries dengan addSelect()
Untuk computed columns yang lebih complex, gunakan addSelect() dengan subquery:
// Get users dengan tanggal order terakhir mereka
$users = User::query()
->addSelect([
'latest_order_date' => Order::select('created_at')
->whereColumn('user_id', 'users.id')
->latest()
->limit(1)
])
->addSelect([
'total_spent' => Order::selectRaw('COALESCE(SUM(total), 0)')
->whereColumn('user_id', 'users.id')
->where('status', 'completed')
])
->get();
// Access:
// $user->latest_order_date
// $user->total_spent
// Semua computed di SQL, bukan di PHP
// Single query dengan subqueries
Real example — Top selling products:
$products = Product::query()
->addSelect([
'total_sold' => OrderItem::selectRaw('COALESCE(SUM(quantity), 0)')
->whereColumn('product_id', 'products.id')
->whereHas('order', fn($q) => $q->where('status', 'completed'))
])
->addSelect([
'revenue' => OrderItem::selectRaw('COALESCE(SUM(quantity * price), 0)')
->whereColumn('product_id', 'products.id')
->whereHas('order', fn($q) => $q->where('status', 'completed'))
])
->orderByDesc('total_sold')
->limit(10)
->get();
Reporting Query — Complete Example
Ini contoh report yang complex tapi efficient:
public function getMonthlySalesReport($year)
{
return DB::table('orders')
->selectRaw("
MONTH(created_at) as month,
MONTHNAME(created_at) as month_name,
COUNT(*) as total_orders,
COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_orders,
COUNT(CASE WHEN status = 'cancelled' THEN 1 END) as cancelled_orders,
COUNT(CASE WHEN status = 'pending' THEN 1 END) as pending_orders,
COALESCE(SUM(CASE WHEN status = 'completed' THEN total END), 0) as revenue,
COALESCE(AVG(CASE WHEN status = 'completed' THEN total END), 0) as avg_order_value,
COUNT(DISTINCT customer_id) as unique_customers
")
->whereYear('created_at', $year)
->groupByRaw('MONTH(created_at), MONTHNAME(created_at)')
->orderBy('month')
->get();
}
// Returns: 12 rows (satu per bulan), semua metrics sudah computed
// Single query, runs in milliseconds
Bagian 8: The Fix — Before & After Comparison
Setelah dua malam refactoring, ini hasil transformasi code Raka:
Summary of All Fixes
| Component | Before | After | Improvement |
|---|---|---|---|
| Dashboard queries | 150,001 | 3 | 50,000x fewer |
| Statistics queries | 6 heavy | 1 light | 6x fewer, 100x faster |
| Export function | Out of memory | Streams smoothly | No memory issues |
| Newsletter send | Crash at 5k | Handles 50k+ | 10x capacity |
| Page load time | 15-30 seconds | 0.5-1 second | 30x faster |
| Memory per request | 512MB+ | 45MB | 91% reduction |
| CPU usage | 100% constant | 10-20% normal | 80% reduction |
Performance Metrics Comparison
╔═══════════════════════════════════════════════════════════════╗
║ BEFORE OPTIMIZATION ║
╠═══════════════════════════════════════════════════════════════╣
║ Page Load Time: 15-30 seconds ║
║ Queries per Page: 2,847 (dashboard) ║
║ Memory Usage: 512MB+ per request ║
║ CPU Usage: 100% constant ║
║ Database Conn: Maxed out (100) ║
║ Monthly Hosting: Rp 2,500,000 (upgraded plan) ║
║ Client Satisfaction: Angry, threatening to leave ║
╚═══════════════════════════════════════════════════════════════╝
↓ REFACTORING ↓
╔═══════════════════════════════════════════════════════════════╗
║ AFTER OPTIMIZATION ║
╠═══════════════════════════════════════════════════════════════╣
║ Page Load Time: 0.5-1 second ║
║ Queries per Page: 8-12 (dashboard) ║
║ Memory Usage: 45MB per request ║
║ CPU Usage: 10-20% normal load ║
║ Database Conn: 5-10 concurrent ║
║ Monthly Hosting: Rp 350,000 (downgraded back) ║
║ Client Satisfaction: Happy, asking for new features ║
╚═══════════════════════════════════════════════════════════════╝
═══════════════════════════════════════════════════════════════
IMPROVEMENTS
═══════════════════════════════════════════════════════════════
Speed: 30x faster
Queries: 99.7% reduction
Memory: 91% reduction
Hosting Cost: 86% savings (Rp 2.15jt/bulan saved)
Client: Retained + new project incoming
═══════════════════════════════════════════════════════════════
Key Code Transformations
Transformation 1: Dashboard Controller
// ❌ BEFORE
public function index()
{
$products = Product::all();
foreach ($products as $product) {
$product->category->name;
$product->supplier->company;
$product->stocks->sum('qty');
}
return view('dashboard', compact('products'));
}
// ✅ AFTER
public function index()
{
$products = Product::query()
->with(['category:id,name', 'supplier:id,company'])
->withSum('stocks', 'qty')
->latest()
->paginate(25);
return view('dashboard', compact('products'));
}
Transformation 2: Statistics Service
// ❌ BEFORE
public function getStats()
{
return [
'total_products' => Product::all()->count(),
'total_orders' => Order::all()->count(),
'revenue' => Order::all()->sum('total'),
'low_stock' => Product::all()->filter(fn($p) => $p->stock < 10)->count(),
];
}
// ✅ AFTER
public function getStats()
{
return Cache::remember('dashboard_stats', 300, function () {
return DB::table('products')
->selectRaw("
(SELECT COUNT(*) FROM products) as total_products,
(SELECT COUNT(*) FROM orders) as total_orders,
(SELECT COALESCE(SUM(total), 0) FROM orders WHERE status = 'completed') as revenue,
(SELECT COUNT(*) FROM products WHERE stock < 10) as low_stock
")
->first();
});
}
// Bonus: Added 5-minute cache untuk reduce database load
Transformation 3: Export Function
// ❌ BEFORE
public function export()
{
$data = Transaction::all();
return Excel::download(new TransactionsExport($data), 'transactions.xlsx');
}
// ✅ AFTER
public function export()
{
return response()->streamDownload(function () {
$handle = fopen('php://output', 'w');
fputcsv($handle, ['ID', 'Date', 'Customer', 'Total', 'Status']);
Transaction::query()
->select(['id', 'created_at', 'customer_id', 'total', 'status'])
->with('customer:id,name')
->cursor()
->each(function ($trx) use ($handle) {
fputcsv($handle, [
$trx->id,
$trx->created_at->format('Y-m-d H:i'),
$trx->customer->name ?? 'N/A',
$trx->total,
$trx->status,
]);
});
fclose($handle);
}, 'transactions.csv');
}
Transformation 4: Newsletter Sender
// ❌ BEFORE
public function send()
{
$users = User::where('subscribed', true)->get();
foreach ($users as $user) {
Mail::to($user)->send(new Newsletter());
}
}
// ✅ AFTER
public function send()
{
User::where('subscribed', true)
->chunkById(100, function ($users) {
foreach ($users as $user) {
SendNewsletter::dispatch($user); // Queue instead of sync
}
});
return 'Newsletter queued for ' . User::where('subscribed', true)->count() . ' users';
}
// Bonus: Using queue jobs untuk async processing
Client Reaction
24 jam setelah deployment fix, saya dapat WhatsApp dari Raka:
"Kak, klien tadi telepon. Katanya websitenya sekarang cepet banget. Dia sampai nanya apa saya upgrade server? Saya bilang aja optimisasi di code. Dia happy banget, malah mau nambah fitur baru bulan depan."
Dan yang lebih penting:
"Tagihan hosting bulan ini turun lagi ke normal. Rp 350rb. Kemarin sempat Rp 2.5jt. Client juga udah ga ngomongin ganti rugi lagi."
What was saved:
- ~Rp 2 juta/bulan hosting cost
- Client relationship
- Raka's reputation
- Raka's mental health (no more 3am panic calls)
Bagian 9: Lessons Learned & Prevention
Dari entire experience ini, ada beberapa lessons yang saya harap kamu bisa ambil tanpa harus mengalami sendiri.
Lessons untuk Yang Vibe Coding
1. AI adalah Assistant, Bukan Replacement untuk Knowledge
AI bisa generate code yang syntactically correct dan functionally working. Tapi AI tidak optimize untuk:
- Production data volume yang sebenarnya
- Server constraints (memory, CPU)
- Concurrent users
- Long-term maintainability
Kamu harus punya knowledge untuk evaluate apakah code dari AI itu production-ready atau time bomb.
2. Local Testing dengan Data Kecil Tidak Cukup
// Local: 10 products, 3 users
// Result: "Works perfectly! Ship it!"
// Production: 50,000 products, 500 users
// Result: Server on fire 🔥
Selalu test dengan data volume yang realistic. Seed database dengan ribuan records sebelum claim "sudah tested".
3. "Works" Tidak Sama dengan "Optimal"
Code bisa:
- ✅ Works (menghasilkan output yang benar)
- ❌ Optimal (melakukannya dengan efisien)
Vibe coding cenderung produce code yang works tapi tidak optimal. Kamu perlu knowledge untuk bridging gap ini.
Prevention Checklist
Sebelum deploy ke production, pastikan kamu sudah check:
Query Performance:
□ Enable query log, check total queries per page load
□ Target: < 20 queries per page untuk typical pages
□ No N+1 patterns (check dengan Laravel Debugbar)
□ All relationships yang diakses sudah di-eager load
□ Pagination untuk semua list views
Memory Management:
□ Tidak ada Model::all() untuk tables yang bisa grow
□ Bulk operations menggunakan chunk() atau cursor()
□ Export functions menggunakan streaming
□ Memory usage < 128MB per request (test dengan memory_get_peak_usage())
Aggregates:
□ count() bukan all()->count()
□ sum('column') bukan all()->sum('column')
□ DB::table() untuk complex aggregates
□ withCount/withSum untuk per-record aggregates
Indexes:
□ Columns di WHERE clause ter-index
□ Foreign keys ter-index
□ Run EXPLAIN pada slow queries
□ Composite indexes untuk multi-column queries
General:
□ Test dengan production-like data volume
□ Load test sebelum launch (minimal basic)
□ Monitoring setup untuk alerts
□ Have rollback plan ready
Quick Optimization Checklist
Print ini dan tempel di monitor kamu:
╔═══════════════════════════════════════════════════════════════╗
║ LARAVEL PERFORMANCE CHECKLIST ║
╠═══════════════════════════════════════════════════════════════╣
║ ║
║ BEFORE EVERY PR/DEPLOYMENT: ║
║ ║
║ □ Query count reasonable? (check Debugbar) ║
║ □ No N+1? (strict mode enabled di development) ║
║ □ Large datasets use chunk/cursor/pagination? ║
║ □ Aggregates use database, not PHP? ║
║ □ Tested with realistic data volume? ║
║ □ Memory usage acceptable? ║
║ ║
║ RED FLAGS TO WATCH: ║
║ ║
║ ⚠️ Model::all() on any table that can grow ║
║ ⚠️ ->get() tanpa pagination di list views ║
║ ⚠️ Accessing relationship di loop tanpa eager loading ║
║ ⚠️ all()->count(), all()->sum(), all()->filter() ║
║ ⚠️ Processing di PHP yang bisa dilakukan di SQL ║
║ ║
╚═══════════════════════════════════════════════════════════════╝
"Setiap line code yang kamu deploy adalah tanggung jawab kamu — bukan AI yang generate. Invest waktu untuk paham fundamental. Shortcut tanpa understanding = technical debt yang akan ditagih dengan bunga. The interest rate is your 3am phone calls."
Bagian 10: Closing — Waktunya Invest di Fundamental
Recap: Yang Kita Pelajari
Dari case study ini, ini takeaways utama:
1. ELOQUENT VS DB::TABLE() VS RAW
└── Eloquent untuk features (relations, scopes, events)
└── DB::table untuk performance (aggregates, bulk ops)
└── Raw SQL untuk complex expressions
└── Default ke yang paling ringan yang meet requirements
2. N+1 PROBLEM
└── Killer #1 untuk Laravel performance
└── Selalu eager load dengan with()
└── Enable strict mode di development
└── Use withCount/withSum untuk aggregates
3. MEMORY MANAGEMENT
└── Jangan pernah all() untuk large tables
└── chunk() untuk processing + updates
└── cursor()/lazy() untuk read-only streaming
└── Paginate semua list views
4. LET DATABASE DO HEAVY LIFTING
└── count() bukan all()->count()
└── Aggregates di SQL, bukan PHP
└── Indexes untuk query performance
└── EXPLAIN untuk diagnosis
5. VIBE CODING DENGAN BIJAK
└── AI adalah tool, bukan pengganti knowledge
└── Review semua generated code
└── Test dengan realistic data volume
└── Pahami fundamental sebelum shortcut
The Real Cost of Skipping Fundamentals
Raka's situation:
- 2 minggu "saved" dengan vibe coding
- 3+ minggu lost untuk fixing production issues
- Almost lost a client
- Paid Rp 2+ juta extra untuk unnecessary hosting upgrade
- Stress dan anxiety yang tidak terukur
Kalau dari awal dia invest 1-2 minggu untuk paham Laravel fundamentals, semua itu bisa di-avoid.
The math is simple:
- 2 weeks learning properly upfront
- vs 3+ weeks of crisis management later
- Plus preserved client relationship
- Plus preserved mental health
- Plus saved money
Learning properly is always the better investment.
Rekomendasi Kelas di BuildWithAngga
Kalau kamu relate dengan cerita Raka dan mau strengthen fundamental Laravel kamu, ini resources yang saya rekomendasikan:
🎓 KELAS GRATIS — Start Here
| Kelas | Apa yang Dipelajari | Durasi |
|---|---|---|
| Laravel untuk Pemula | MVC, routing, Eloquent basics, migrations | ~8 jam |
| Database Design Fundamentals | Schema design, relationships, indexing | ~5 jam |
| Git untuk Developer | Version control, branching, collaboration | ~4 jam |
| Vibe Coding yang Proper | AI-assisted development dengan best practices | ~3 jam |
👉 **Akses gratis di: https://buildwithangga.com/courses**
💎 KELAS PREMIUM — Deep Dive
| Kelas | Apa yang Dipelajari | Kenapa Worth It |
|---|---|---|
| Laravel Mastery | Advanced Eloquent, optimization, testing, deployment | Deep understanding yang avoid production disasters |
| Laravel E-commerce | Real project dengan proper architecture dan scale considerations | Portfolio-ready project dengan best practices |
| Laravel API Development | RESTful APIs, authentication, rate limiting, performance | High-demand skill untuk freelance dan job market |
| Full-Stack Web Developer Path | Frontend + Backend + Deployment | Complete journey dari zero to production-ready |
👉 **Explore premium di: https://buildwithangga.com/premium**
Kenapa BuildWithAngga?
- ✅ Lifetime access — belajar sesuai pace kamu
- ✅ Project-based — langsung praktik, bukan cuma teori
- ✅ Mentor support — ada yang bantu kalau stuck
- ✅ Community — network dengan developer Indonesia lainnya
- ✅ Industry-relevant — materi updated sesuai kebutuhan market
Final Message
AI dan vibe coding adalah tools yang powerful. Saya sendiri pakai setiap hari. Productivity gain-nya real.
Tapi tools sehebat apapun butuh operator yang paham.
- Calculator hebat, tapi tidak berguna kalau tidak paham matematika
- Google Maps akurat, tapi tidak berguna kalau tidak bisa baca jalan
- AI code assistant powerful, tapi tidak berguna kalau tidak paham fundamentals
The sweet spot adalah: Fundamental knowledge + AI assistance = Maximum productivity dengan minimum risk.
Jangan jadi developer yang cuma bisa copy-paste.
Jadi developer yang paham:
- KENAPA code itu work
- KAPAN pakai approach tertentu
- BAGAIMANA optimize untuk production
Itu yang membedakan junior yang stuck dengan senior yang terus berkembang.
Itu yang membedakan freelancer yang underpaid dengan yang premium.
Itu yang membedakan developer yang panik jam 3 pagi dengan yang tidur nyenyak.
Start learning properly. Your future self — and your future clients — will thank you.
Artikel ini ditulis oleh Angga Risky Setiawan, AI Product Engineer & Founder BuildWithAngga. Berdasarkan pengalaman nyata review code student yang mengajarkan pentingnya fundamental di era AI-assisted development.
Punya pertanyaan atau mau share pengalaman serupa? Join komunitas BuildWithAngga dan connect dengan ribuan developer Indonesia lainnya.