Kesalahan Fatal Laravel Eloquent dari Vibe Coding yang Bikin Website Lemot dan Tagihan Hosting Membengkak

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:

SymptomYang TerjadiDampak ke Bisnis
Response time 15-30 detikSetiap halaman butuh waktu sangat lama untuk loadUser frustasi, banyak yang refresh atau tinggalkan
CPU usage 100% constantProcessor server kerja maksimal terus-menerusHosting throttle atau suspend account
RAM usage maksimalMemory server habis terpakaiOut of memory errors, crash
Database connections maxedSemua koneksi database terpakaiConnection timeout, queries gagal
Tagihan hosting naik 5xDari Rp 500rb ke Rp 2.5jt/bulanKlien 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:

  1. Apa yang salah — Findings dari investigation code Raka
  2. Kenapa bisa terjadi — Root cause dari vibe coding tanpa fundamental
  3. Technical deep-dive — Eloquent, Query Builder, dan kapan pakai yang mana
  4. The fixes — Step-by-step cara repair dengan before/after code
  5. 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:

  1. Transaction::all() load 30,000+ records langsung ke memory
  2. Setiap record butuh ~1-2KB memory → 30,000 × 2KB = 60MB minimum hanya untuk variable $transactions
  3. Belum termasuk N+1 untuk customer dan items
  4. Array $data duplicate data lagi → tambah 60MB+
  5. 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:

  1. Load 8,000 users sekaligus ke memory
  2. Memory tidak pernah di-release selama loop
  3. Setiap iterasi, memory usage naik terus
  4. Di tengah jalan: out of memory, email berhenti setengah jalan
  5. Tidak ada tracking mana yang sudah terkirim

The Verdict: Summary of Disasters

Setelah 2 jam investigation, ini summary yang saya compile:

LocationProblemQuery CountMemory Impact
DashboardN+1 pada products + relations~150,000/loadHigh
Statisticsall()->count() pattern4 heavy queriesVery High
ExportNo chunking, N+1~90,000/exportCritical (OOM)
SearchLIKE tanpa indexFull table scanMedium
NewsletterNo chunking8,000 queriesCritical (OOM)
Product ListNo paginationLoads allHigh

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 AIAI GenerateYang SeharusnyaKenapa AI Miss
"Get all products with category"Product::all() + foreach loopProduct::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 MailUser::chunk(100) + queueAI 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:

  1. Explicit dan verbose (lebih mudah dipahami)
  2. Uses high-level abstractions (Eloquent untuk semuanya)
  3. Process di application layer (PHP) bukan database layer
  4. 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(), atau lazy()
  • 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:

FeaturePerformance CostNotes
Model HydrationHighCreates full PHP object per row
Events/ObserversMediumFires on every create/update/delete
Attribute CastingLow-MediumTransforms on access
$with (default eager load)VariableAuto loads relations
$appendsMediumComputes accessors on toArray/toJson
TimestampsLowAuto-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 SizeOperationRecommendedCode Example
1-100 rowsDisplay dengan relationsModel::with()->get()Product::with('category')->get()
1-100 rowsSingle recordModel::find()Product::findOrFail($id)
1-100 rowsCounts per recordwithCount()Category::withCount('products')
100-10k rowsDisplay in paginated tableModel::paginate()Product::paginate(25)
100-10k rowsAggregate numbersDB::table()->selectRaw()See examples above
100-10k rowsBulk updateDB::table()->update()DB::table('x')->where()->update()
10k+ rowsProcess/transform eachchunk() atau lazy()Model::chunk(500, fn)
10k+ rowsRead-only iterationcursor()Model::cursor()
10k+ rowsBulk insertDB::table()->insert() batchesInsert in chunks of 500-1000
10k+ rowsExport to filecursor() + streamingStream response
Any sizeComplex SQL expressionsselectRaw() / 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:

  1. Execute 1 query untuk get main data (N records)
  2. 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:

  1. Execute query ke database
  2. Fetch SEMUA 100,000 rows
  3. Create 100,000 Eloquent model objects
  4. 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:

MethodCara KerjaMemory UsageBest For
chunk($size, $callback)Multiple queries, batch processingLow (batch size)Processing + updating records
chunkById($size, $callback)Multiple queries by IDLow (batch size)Safe updates during iteration
cursor()Single query, PHP generatorVery low (1 row)Read-only streaming
lazy($size)Single query, LazyCollectionVery 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():

Aspectchunk()cursor()
QueriesMultiple (LIMIT/OFFSET)Single query
Memory per iterationBatch size × model size1 × model size
Database connectionReleased between batchesHeld open during iteration
Best forProcessing + updatesRead-only operations
CaveatOffset issues on updateLong-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:

OperationBad PatternGood PatternImprovement
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

ComponentBeforeAfterImprovement
Dashboard queries150,001350,000x fewer
Statistics queries6 heavy1 light6x fewer, 100x faster
Export functionOut of memoryStreams smoothlyNo memory issues
Newsletter sendCrash at 5kHandles 50k+10x capacity
Page load time15-30 seconds0.5-1 second30x faster
Memory per request512MB+45MB91% reduction
CPU usage100% constant10-20% normal80% 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

KelasApa yang DipelajariDurasi
Laravel untuk PemulaMVC, routing, Eloquent basics, migrations~8 jam
Database Design FundamentalsSchema design, relationships, indexing~5 jam
Git untuk DeveloperVersion control, branching, collaboration~4 jam
Vibe Coding yang ProperAI-assisted development dengan best practices~3 jam

👉 **Akses gratis di: https://buildwithangga.com/courses**

💎 KELAS PREMIUM — Deep Dive

KelasApa yang DipelajariKenapa Worth It
Laravel MasteryAdvanced Eloquent, optimization, testing, deploymentDeep understanding yang avoid production disasters
Laravel E-commerceReal project dengan proper architecture dan scale considerationsPortfolio-ready project dengan best practices
Laravel API DevelopmentRESTful APIs, authentication, rate limiting, performanceHigh-demand skill untuk freelance dan job market
Full-Stack Web Developer PathFrontend + Backend + DeploymentComplete 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.