Pelajari cara membangun aplikasi e-commerce production-ready menggunakan Laravel 12, Filament 4, Midtrans, dan K6 dengan pendekatan vibe coding 80% prompt 20% review. Tutorial lengkap menggunakan Claude AI di Visual Studio Code untuk developer Indonesia yang ingin meningkatkan produktivitas tanpa mengorbankan kualitas code.
Bagian 1: Opening — Filosofi Vibe Coding 80/20
Saya pernah ketemu developer yang sudah 3 bulan ngerjain side project e-commerce. Tiga bulan. Untuk MVP sederhana.
Bukan karena dia tidak jago. Justru sebaliknya — dia terlalu perfeksionis. Setiap line code harus dia tulis sendiri. Setiap pattern harus dia riset dulu. Setiap edge case harus di-handle dari awal.
Hasilnya? Burnout. Project mangkrak. Ide bagus yang tidak pernah sampai ke user.
Di 2026 ini, saya mau share pendekatan berbeda yang sudah saya praktikkan untuk building products di BuildWithAngga. Pendekatan yang bikin saya bisa ship faster tanpa sacrifice quality.
Namanya: Vibe Coding dengan ratio 80/20.
Apa Itu Vibe Coding
Istilah "vibe coding" dipopulerkan oleh Andrej Karpathy — co-founder OpenAI, mantan Director of AI di Tesla — di awal 2025. Dia describe vibe coding sebagai cara ngoding di mana kita "fully give in to the vibes, embrace exponentials, and forget that the code even exists."
Collins Dictionary menjadikannya Word of the Year 2025. Dan statistik di 2026 menunjukkan fakta yang sudah tidak bisa diabaikan: 92% developer di US pakai AI coding tools setiap hari, dan 41% code yang ditulis secara global sekarang di-generate oleh AI.
Tapi here's the thing — vibe coding bukan tentang blindly copas output AI.
Yang membedakan developer yang sukses dengan vibe coding versus yang kena technical debt parah adalah framework 80/20.
Framework 80/20: Prompt vs Review
Ini konsep yang saya pakai sehari-hari:
┌─────────────────────────────────────────────────────────┐
│ 80% PROMPTING │
├─────────────────────────────────────────────────────────┤
│ │
│ • Describe apa yang kamu mau dalam natural language │
│ • Biarkan AI generate code │
│ • Iterate dengan follow-up prompts │
│ • Minta AI explain kalau ada yang tidak paham │
│ • Fokus di WHAT, bukan HOW │
│ │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ 20% REVIEW & EDIT │
├─────────────────────────────────────────────────────────┤
│ │
│ • Review code yang di-generate │
│ • Test functionality — does it actually work? │
│ • Edit manual untuk edge cases yang AI miss │
│ • Optimize performance jika perlu │
│ • UNDERSTAND dan OWN the code │
│ │
└─────────────────────────────────────────────────────────┘
80% waktu kamu dihabiskan untuk komunikasi dengan AI — menulis prompt yang jelas, iterate, dan refine. 20% sisanya untuk review, testing, dan manual adjustment.
Ini bukan berarti kamu jadi "malas" atau skip understanding. Justru sebaliknya — 20% review itu crucial. Di situlah expertise kamu sebagai developer berperan. AI bisa generate code, tapi AI tidak tahu context bisnis kamu, edge cases spesifik aplikasi kamu, atau performance requirements production kamu.
| Situasi | 80% Prompt | 20% Manual |
|---|---|---|
| Boilerplate code | ✅ AI generate | Review structure |
| CRUD operations | ✅ AI generate | Adjust validations |
| Database migrations | ✅ AI generate | Review indexes, relations |
| Business logic kompleks | AI generate draft | ✅ Heavy review & edit |
| Security-sensitive code | AI generate draft | ✅ Manual verify |
| Performance-critical | AI generate draft | ✅ Optimize manual |
Kenapa Claude AI
Ada banyak AI coding tools di luar sana — ChatGPT, Copilot, Gemini, dan lainnya. Kenapa saya prefer Claude untuk vibe coding?
Context window yang besar. Claude bisa handle context sampai 200K tokens. Artinya kamu bisa paste entire codebase dan dia masih bisa understand relationship antar file. Ini game-changer untuk project yang sudah berkembang.
Reasoning yang kuat. Claude tidak cuma autocomplete — dia bisa reason tentang architecture, explain kenapa suatu approach lebih baik, dan catch potential issues sebelum kamu implement.
Code quality. Dari pengalaman saya, code yang di-generate Claude cenderung lebih clean dan idiomatic dibanding alternatives. Dia follow best practices tanpa perlu di-prompt explicitly.
Honest tentang limitations. Kalau Claude tidak yakin atau tidak tahu, dia akan bilang. Ini penting — lebih baik AI yang jujur daripada yang confident tapi salah.
Yang Akan Kita Bangun
Di tutorial ini, kita akan build e-commerce sederhana tapi production-ready. Bukan toy project — tapi foundation yang bisa kamu kembangkan jadi bisnis nyata.
PROJECT: TOKO ONLINE
Tech Stack:
├── Laravel 12 (Backend)
├── Filament 4 (Admin Panel)
├── Blade + Tailwind (Frontend)
├── Midtrans (Payment Gateway)
└── K6 (Load Testing)
Features:
├── Admin Panel
│ ├── Dashboard dengan statistik
│ ├── CRUD Categories
│ ├── CRUD Products (dengan image upload)
│ └── Order management
│
├── Customer Frontend
│ ├── Homepage dengan featured products
│ ├── Product listing dengan filter
│ ├── Product detail
│ ├── Shopping cart
│ └── Checkout dengan Midtrans
│
└── Production Readiness
├── Load testing dengan K6
├── Performance optimization
└── Caching strategy
Kenapa stack ini?
Laravel 12 karena mature, well-documented, dan ecosystem payment gateway Indonesia sudah lengkap.
Filament 4 karena bikin admin panel jadi hitungan jam bukan minggu. Versi 4 bahkan 2.38x lebih cepat dari sebelumnya.
Midtrans karena ini payment gateway yang paling familiar untuk developer dan user Indonesia. Support lengkap: GoPay, OVO, DANA, Virtual Account semua bank, kartu kredit, bahkan Indomaret/Alfamart.
K6 karena — dan ini yang sering di-skip developer — kamu perlu tahu apakah aplikasi kamu bisa handle real traffic sebelum launch. K6 dari Grafana adalah tool load testing modern yang scriptable dengan JavaScript.
Mindset Shift
Sebelum kita mulai, saya mau kamu shift mindset dulu.
Traditional coding: "Saya harus tahu cara nulis setiap line code."
Vibe coding 80/20: "Saya harus tahu cara direct AI untuk generate code yang benar, dan verify hasilnya."
Ini bukan downgrade skill. Ini upgrade. Kamu jadi orchestrator, bukan typist. Kamu fokus di architecture, business logic, dan user experience — bukan fighting dengan syntax atau googling "how to create migration Laravel."
💡 TIPS SEBELUM MULAI:
Vibe coding bukan tentang kecepatan semata. Ini tentang:
1. Clarity — Semakin jelas prompt kamu, semakin bagus hasilnya
2. Iteration — Jarang sekali prompt pertama langsung perfect
3. Verification — SELALU test code yang di-generate
4. Understanding — Jangan ship code yang kamu tidak paham
Kalau kamu tidak bisa explain apa yang code itu lakukan,
kamu belum boleh commit.
Siap? Di bagian selanjutnya, kita akan setup Claude AI di VS Code supaya workflow vibe coding kamu seamless.
Bagian 2: Setup Claude AI di Visual Studio Code
Sebelum kita mulai coding, kita perlu setup environment dulu. Goal-nya adalah bikin workflow vibe coding se-seamless mungkin — kamu bisa prompt Claude langsung dari VS Code tanpa perlu switch ke browser.
Install Claude Extension
Buka VS Code, lalu pergi ke Extensions (Ctrl+Shift+X atau Cmd+Shift+X di Mac). Search "Claude" dan kamu akan menemukan beberapa opsi. Yang saya rekomendasikan:
Claude Dev — Extension official-ish yang paling populer untuk integrasi Claude di VS Code. Fiturnya lengkap: chat interface, code generation, dan bisa read file context dari project kamu.
Atau alternatifnya, kamu bisa pakai Continue — extension open-source yang support multiple AI providers termasuk Claude. Lebih flexible kalau kamu mau switch-switch antara Claude dan model lain.
Untuk tutorial ini, saya akan pakai pendekatan yang paling simple: Claude Dev extension.
LANGKAH INSTALL:
1. Buka VS Code
2. Ctrl+Shift+X (Extensions)
3. Search "Claude Dev"
4. Klik Install
5. Restart VS Code
Setup API Key
Setelah extension terinstall, kamu perlu API key dari Anthropic. Ini langkah-langkahnya:
- Pergi ke console.anthropic.com
- Sign up atau login
- Navigate ke API Keys
- Klik Create Key
- Copy key-nya (awalan
sk-ant-...)
Sekarang di VS Code:
- Buka Command Palette (Ctrl+Shift+P)
- Ketik "Claude: Set API Key"
- Paste API key kamu
- Enter
Untuk verifikasi, buka Command Palette lagi dan ketik "Claude: Open Chat". Kalau muncul chat interface tanpa error, setup berhasil.
⚠️ PENTING TENTANG API KEY:
- Jangan commit API key ke repository
- Simpan di environment variable atau VS Code settings
- API key Anthropic bersifat pay-per-use
- Monitor usage di console.anthropic.com
- Set spending limit untuk avoid surprise bills
Pilih Model yang Tepat
Claude punya beberapa model dengan trade-off berbeda:
| Model | Kelebihan | Cocok Untuk |
|---|---|---|
| Claude Sonnet 4 | Balance speed & quality | Daily coding, most tasks |
| Claude Opus 4 | Most capable, best reasoning | Complex architecture, debugging hard issues |
| Claude Haiku 4 | Fastest, cheapest | Quick questions, simple tasks |
Untuk vibe coding sehari-hari, Sonnet adalah sweet spot. Cukup capable untuk generate code kompleks, tapi tidak terlalu lambat atau mahal.
Kalau kamu stuck di masalah yang kompleks — misalnya debugging race condition atau designing system architecture — switch ke Opus untuk reasoning yang lebih dalam.
Anatomy of a Good Prompt
Ini bagian yang paling penting. Kualitas output Claude sangat tergantung pada kualitas prompt kamu. Garbage in, garbage out.
Struktur prompt yang efektif:
CONTEXT + TASK + CONSTRAINTS + FORMAT
1. CONTEXT: Kasih tau Claude tentang project kamu
2. TASK: Apa yang kamu mau dia lakukan
3. CONSTRAINTS: Batasan atau requirements spesifik
4. FORMAT: Bagaimana kamu mau output-nya
Contoh prompt BURUK:
Buatkan migration untuk products
Terlalu vague. Claude tidak tahu field apa saja yang kamu butuhkan, relasi ke tabel lain, atau conventions yang kamu pakai.
Contoh prompt BAGUS:
Saya sedang build e-commerce dengan Laravel 12.
Buatkan migration untuk tabel products dengan fields:
- name (string, required)
- slug (string, unique)
- description (text, nullable)
- price (integer, dalam rupiah tanpa desimal)
- stock (integer, default 0)
- category_id (foreign key ke categories)
- image (string, nullable)
- is_active (boolean, default true)
- timestamps
Tambahkan juga index untuk slug dan category_id karena
akan sering di-query.
Lihat perbedaannya? Prompt kedua memberikan context (Laravel 12, e-commerce), task yang jelas (migration), constraints (field types, defaults), dan implicit format (Laravel migration style).
Prompt Patterns yang Sering Saya Pakai
Setelah berbulan-bulan vibe coding, ada beberapa patterns yang consistently menghasilkan output bagus:
Pattern 1: "Saya sedang build X dengan Y"
Selalu mulai dengan context. Claude perlu tahu stack apa yang kamu pakai supaya dia generate code yang sesuai conventions.
Saya sedang build admin panel dengan Laravel 12 dan Filament 4.
[lanjutkan dengan task...]
Pattern 2: "Buatkan X dengan spesifikasi berikut"
Untuk tasks yang butuh detail, list spesifikasi dengan bullet points. Claude sangat bagus dalam following structured instructions.
Buatkan ProductResource untuk Filament 4 dengan spesifikasi:
- Form fields: [list fields]
- Table columns: [list columns]
- Filters: [list filters]
- Actions: [list actions]
Pattern 3: "Review code ini dan [improve/fix/explain]"
Gunakan Claude untuk review code yang sudah ada — baik code yang dia generate sebelumnya atau code existing.
Review migration ini dan tambahkan indexes yang diperlukan
untuk optimize query product listing dan filtering by category.
[paste code]
Pattern 4: "Jelaskan kenapa X dan bukan Y"
Kalau kamu tidak paham kenapa Claude memilih approach tertentu, tanya. Ini bagian penting dari "20% review" — kamu harus understand code yang kamu ship.
Di code sebelumnya, kamu pakai eager loading dengan `with()`.
Jelaskan kenapa ini lebih baik dibanding lazy loading untuk
use case product listing.
Keyboard Shortcuts untuk Produktivitas
Setup keyboard shortcuts supaya workflow makin smooth:
| Action | Suggested Shortcut |
|---|---|
| Open Claude Chat | Ctrl+Shift+C |
| Send Selection to Claude | Ctrl+Shift+S |
| Insert Claude Response | Ctrl+Shift+I |
| New Chat | Ctrl+Shift+N |
Untuk setup, buka File > Preferences > Keyboard Shortcuts dan search "Claude".
Test Setup: First Prompt
Mari test apakah setup sudah benar dengan prompt pertama kita:
Saya mau memulai project e-commerce dengan Laravel 12,
Filament 4 untuk admin panel, dan Midtrans untuk payment.
Berikan langkah-langkah setup project dari awal:
1. Install Laravel 12
2. Setup database
3. Install Filament 4
4. Konfigurasi dasar
Format sebagai step-by-step commands yang bisa langsung
saya jalankan di terminal.
Kalau Claude merespons dengan langkah-langkah yang jelas dan commands yang valid, kamu siap untuk mulai building.
💡 TIPS WORKFLOW:
1. Buka VS Code dengan 2 panel: Editor + Claude Chat
2. Selalu mulai session baru untuk task baru (avoid context pollution)
3. Kalau response terpotong, ketik "lanjutkan" atau "continue"
4. Save prompts yang bagus untuk reuse nanti
5. Jangan takut untuk iterate — prompt kedua/ketiga sering
lebih bagus dari pertama
Setup sudah selesai. Di bagian selanjutnya, kita akan mulai building — setup Laravel 12 dan Filament 4 dengan vibe coding approach.
Bagian 3: Setup Laravel 12 + Filament 4 dengan Prompt
Sekarang kita mulai building. Di bagian ini, saya akan tunjukkan real workflow vibe coding — dari prompt ke Claude sampai code yang running. Kamu akan lihat mana yang 80% dari AI, mana yang 20% manual review dan edit.
Prompt 1: Install Laravel 12
Buka Claude chat di VS Code, lalu ketik:
Saya mau memulai project e-commerce baru dengan Laravel 12.
Berikan saya:
1. Command untuk create project baru dengan nama "toko-online"
2. Konfigurasi .env untuk database MySQL
3. Command untuk jalankan migration default
Saya pakai MySQL dengan database name "toko_online_db".
Response Claude (80%):
# 1. Create project Laravel 12
composer create-project laravel/laravel toko-online "12.*"
cd toko-online
# 2. Edit file .env, sesuaikan bagian database:
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=toko_online_db
DB_USERNAME=root
DB_PASSWORD=
# 3. Buat database dulu di MySQL
mysql -u root -p -e "CREATE DATABASE toko_online_db"
# 4. Jalankan migration
php artisan migrate
Manual Review (20%):
Sebelum jalankan, saya review:
- ✅ Laravel version benar ("12.*")
- ✅ Database name konsisten
- ⚠️ Password MySQL — sesuaikan dengan setup lokal kamu
- ✅ Commands urutan sudah benar
Jalankan commands di terminal. Kalau tidak ada error, lanjut.
Prompt 2: Install Filament 4
Project Laravel 12 sudah ready. Sekarang saya mau install
Filament 4 untuk admin panel.
Berikan:
1. Command composer untuk install Filament 4
2. Command untuk setup panel admin
3. Command untuk buat user admin pertama
Saya mau panel admin bisa diakses di /admin
Response Claude (80%):
# 1. Install Filament 4
composer require filament/filament:"^4.0"
# 2. Install panel admin
php artisan filament:install --panels
# Saat ditanya panel ID, ketik: admin
# Saat ditanya path, ketik: admin
# 3. Buat user admin
php artisan make:filament-user
# Isi data:
# Name: Admin
# Email: [email protected]
# Password: password123
Manual Review (20%):
- ✅ Filament version 4.x
- ✅ Path /admin sesuai requirement
- ⚠️ Password user — di production pakai password yang kuat!
Jalankan dan test dengan buka http://localhost:8000/admin. Login dengan kredensial yang tadi dibuat.
Prompt 3: Konfigurasi Admin Panel
Setelah Filament terinstall, kita customize tampilan dan struktur navigation:
Saya sudah install Filament 4 di Laravel 12. Sekarang saya
mau konfigurasi AdminPanelProvider dengan:
1. Warna primary: blue (seperti default Tailwind blue-600)
2. Brand name: "Toko Online Admin"
3. Navigation groups:
- Master Data (untuk categories, products)
- Transaksi (untuk orders)
- Settings (untuk general settings nanti)
4. Favicon dari /favicon.ico
Buatkan konfigurasi lengkap untuk AdminPanelProvider.php
Response Claude (80%):
<?php
// app/Providers/Filament/AdminPanelProvider.php
namespace App\Providers\Filament;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Pages;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
use Filament\Widgets;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\AuthenticateSession;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;
class AdminPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->default()
->id('admin')
->path('admin')
->login()
->colors([
'primary' => Color::Blue,
])
->brandName('Toko Online Admin')
->favicon(asset('favicon.ico'))
->navigationGroups([
'Master Data',
'Transaksi',
'Settings',
])
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages')
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
->pages([
Pages\Dashboard::class,
])
->widgets([
Widgets\AccountWidget::class,
])
->middleware([
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
AuthenticateSession::class,
ShareErrorsFromSession::class,
VerifyCsrfToken::class,
SubstituteBindings::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
])
->authMiddleware([
Authenticate::class,
]);
}
}
Manual Review (20%):
- ✅ Colors menggunakan Filament 4 syntax (Color::Blue)
- ✅ Navigation groups sesuai requirement
- ⚠️ Tambahkan brandLogo jika punya logo — Claude tidak include karena saya tidak mention
Manual Edit:
Saya tambahkan satu line untuk dark mode support:
->colors([
'primary' => Color::Blue,
'danger' => Color::Rose,
'success' => Color::Emerald,
])
->darkMode(true) // Tambahan manual
Prompt 4: Setup Storage untuk Upload
Untuk produk nanti kita butuh upload gambar. Setup storage link:
Saya perlu setup Laravel storage untuk upload gambar produk.
Berikan:
1. Command untuk create storage link
2. Konfigurasi filesystem untuk public disk
3. Contoh cara akses URL gambar yang diupload
Response Claude (80%):
# Create symbolic link
php artisan storage:link
// config/filesystems.php sudah ada default config untuk public disk:
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => env('APP_URL').'/storage',
'visibility' => 'public',
],
// Cara akses URL gambar:
// Jika path disimpan sebagai "products/image.jpg"
$url = Storage::url('products/image.jpg');
// Result: http://localhost:8000/storage/products/image.jpg
// Atau pakai asset helper
$url = asset('storage/products/image.jpg');
Manual Review (20%):
- ✅ Storage link command benar
- ✅ Config sudah default di Laravel, tidak perlu edit
- ✅ URL access pattern benar
Verifikasi Setup
Sebelum lanjut ke database design, pastikan semua sudah working:
# Jalankan development server
php artisan serve
# Buka browser:
# - http://localhost:8000 → Laravel welcome page
# - http://localhost:8000/admin → Filament login
# - Login dengan [email protected] / password123
Kalau semua bisa diakses tanpa error, setup complete!
Struktur Project Sekarang
toko-online/
├── app/
│ ├── Filament/
│ │ ├── Pages/
│ │ ├── Resources/ ← Nanti kita isi
│ │ └── Widgets/ ← Nanti kita isi
│ ├── Models/
│ └── Providers/
│ └── Filament/
│ └── AdminPanelProvider.php ← Sudah dikonfigurasi
├── config/
├── database/
│ └── migrations/ ← Nanti kita tambah
├── public/
│ └── storage/ ← Symbolic link
├── resources/
├── routes/
└── storage/
└── app/
└── public/ ← Upload files disini
💡 RECAP VIBE CODING BAGIAN INI:
Prompts yang kita tulis: 4
Total code dari Claude: ~90 lines
Manual edits: ~5 lines (dark mode, password adjust)
Ratio actual: sekitar 85% prompt, 15% manual
Yang penting di-review:
- Versi package (Laravel 12, Filament 4)
- Credentials dan passwords
- Path dan URLs
- Konfigurasi environment-specific
Di bagian selanjutnya, kita akan design database — categories, products, orders — dengan prompt ke Claude dan review hasilnya untuk memastikan relasi dan indexes sudah optimal.
Bagian 4: Database Design dengan Claude
Database design adalah salah satu area di mana vibe coding sangat powerful — tapi juga butuh review yang teliti. Claude bisa generate migrations dengan cepat, tapi kamu harus pastikan relasi, indexes, dan data types sudah optimal untuk use case kamu.
Prompt 1: ERD Design
Sebelum langsung ke code, saya minta Claude untuk design ERD dulu:
Saya sedang build e-commerce sederhana dengan Laravel 12.
Design ERD untuk tabel-tabel berikut:
1. categories - kategori produk
2. products - produk yang dijual
3. orders - pesanan dari customer
4. order_items - detail item per order
Requirements:
- Satu product hanya bisa di satu category
- Satu order bisa punya banyak items
- Order perlu track status (pending, paid, shipped, completed, cancelled)
- Order perlu simpan info Midtrans (transaction_id, payment_type)
- Products punya stock tracking
Buatkan ERD dalam format ASCII art dengan penjelasan relasi.
Response Claude (80%):
┌──────────────────┐
│ CATEGORIES │
├──────────────────┤
│ id (PK) │
│ name │
│ slug │
│ created_at │
│ updated_at │
└────────┬─────────┘
│
│ 1
│
│ N
┌────────┴─────────┐
│ PRODUCTS │
├──────────────────┤
│ id (PK) │
│ category_id (FK) │───────────────┐
│ name │ │
│ slug │ │
│ description │ │
│ price │ │
│ stock │ │
│ image │ │
│ is_active │ │
│ created_at │ │
│ updated_at │ │
└──────────────────┘ │
│
┌──────────────────┐ │
│ ORDERS │ │
├──────────────────┤ │
│ id (PK) │ │
│ order_number │ │
│ customer_name │ │
│ customer_email │ │
│ customer_phone │ │
│ shipping_address │ │
│ total_amount │ │
│ status │ │
│ midtrans_id │ │
│ payment_type │ │
│ paid_at │ │
│ created_at │ │
│ updated_at │ │
└────────┬─────────┘ │
│ │
│ 1 │
│ │
│ N │
┌────────┴─────────┐ │
│ ORDER_ITEMS │ │
├──────────────────┤ │
│ id (PK) │ │
│ order_id (FK) │ │
│ product_id (FK) │───────────────┘
│ quantity │
│ price │ (snapshot harga saat beli)
│ created_at │
│ updated_at │
└──────────────────┘
RELASI:
• Category (1) ──── (N) Products
• Order (1) ──── (N) Order Items
• Product (1) ──── (N) Order Items
Manual Review (20%):
- ✅ Relasi one-to-many sudah benar
- ✅ Price di order_items sebagai snapshot — good practice
- ⚠️ Perlu tambah index untuk slug (sering di-query)
- ⚠️ Perlu tambah index untuk order_number (lookup)
- ⚠️ Perlu tambah index untuk status (filter)
Prompt 2: Migration Categories
Buatkan migration Laravel 12 untuk tabel categories berdasarkan
ERD tadi. Tambahkan index untuk slug karena akan sering di-query.
Response Claude (80%):
<?php
// database/migrations/xxxx_create_categories_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('categories');
}
};
Manual Review (20%):
- ✅ Slug sudah unique (otomatis ter-index)
- ✅ Simple dan clean
- Tidak perlu edit, langsung pakai
Prompt 3: Migration Products
Buatkan migration untuk tabel products dengan:
- Foreign key ke categories
- Index untuk slug dan category_id
- Price sebagai integer (rupiah tanpa desimal)
- Stock dengan default 0
- is_active dengan default true
Response Claude (80%):
<?php
// database/migrations/xxxx_create_products_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->foreignId('category_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->string('slug')->unique();
$table->text('description')->nullable();
$table->integer('price');
$table->integer('stock')->default(0);
$table->string('image')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->index('category_id');
});
}
public function down(): void
{
Schema::dropIfExists('products');
}
};
Manual Review (20%):
- ✅ Foreign key dengan cascade delete
- ✅ Indexes sudah ada
- ⚠️
foreignId()->constrained()sudah auto index, jadi$table->index('category_id')sebenarnya redundant
Manual Edit:
Hapus line redundant:
// Hapus baris ini karena foreignId sudah auto-index
// $table->index('category_id');
Prompt 4: Migration Orders
Buatkan migration untuk tabel orders dengan:
- order_number unique dan indexed
- Customer info (name, email, phone, address)
- total_amount sebagai integer
- status enum: pending, paid, processing, shipped, completed, cancelled
- Midtrans fields: midtrans_transaction_id, payment_type
- paid_at timestamp nullable
- Index untuk status karena sering di-filter
Response Claude (80%):
<?php
// database/migrations/xxxx_create_orders_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->string('order_number')->unique();
// Customer info
$table->string('customer_name');
$table->string('customer_email');
$table->string('customer_phone')->nullable();
$table->text('shipping_address');
// Order details
$table->integer('total_amount');
$table->enum('status', [
'pending',
'paid',
'processing',
'shipped',
'completed',
'cancelled'
])->default('pending');
// Midtrans
$table->string('midtrans_transaction_id')->nullable();
$table->string('payment_type')->nullable();
$table->timestamp('paid_at')->nullable();
$table->timestamps();
$table->index('status');
$table->index('customer_email');
});
}
public function down(): void
{
Schema::dropIfExists('orders');
}
};
Manual Review (20%):
- ✅ Enum status lengkap
- ✅ Midtrans fields ada
- ✅ Indexes untuk status dan email
- ✅ paid_at nullable — correct
Tidak perlu edit.
Prompt 5: Migration Order Items
Buatkan migration untuk order_items dengan:
- Foreign key ke orders (cascade delete)
- Foreign key ke products (restrict delete - jangan bisa hapus product yang sudah di-order)
- quantity integer
- price integer (snapshot harga saat pembelian)
Response Claude (80%):
<?php
// database/migrations/xxxx_create_order_items_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('order_items', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')->constrained()->cascadeOnDelete();
$table->foreignId('product_id')->constrained()->restrictOnDelete();
$table->integer('quantity');
$table->integer('price');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('order_items');
}
};
Manual Review (20%):
- ✅ Cascade delete untuk order — kalau order dihapus, items ikut
- ✅ Restrict delete untuk product — protect data integrity
- ✅ Price sebagai snapshot
Perfect, tidak perlu edit.
Prompt 6: Eloquent Models
Buatkan Eloquent models untuk semua tabel dengan:
- Proper relationships (belongsTo, hasMany)
- Fillable fields
- Casts untuk boolean dan datetime
- Accessor untuk formatted_price di Product dan Order
Satu model per code block supaya jelas.
Response Claude (80%):
Category Model:
<?php
// app/Models/Category.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Category extends Model
{
protected $fillable = [
'name',
'slug',
];
public function products(): HasMany
{
return $this->hasMany(Product::class);
}
}
Product Model:
<?php
// app/Models/Product.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Storage;
class Product extends Model
{
protected $fillable = [
'category_id',
'name',
'slug',
'description',
'price',
'stock',
'image',
'is_active',
];
protected function casts(): array
{
return [
'is_active' => 'boolean',
'price' => 'integer',
'stock' => 'integer',
];
}
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
public function orderItems(): HasMany
{
return $this->hasMany(OrderItem::class);
}
public function getFormattedPriceAttribute(): string
{
return 'Rp ' . number_format($this->price, 0, ',', '.');
}
public function getImageUrlAttribute(): ?string
{
return $this->image ? Storage::url($this->image) : null;
}
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeInStock($query)
{
return $query->where('stock', '>', 0);
}
}
Order Model:
<?php
// app/Models/Order.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
class Order extends Model
{
protected $fillable = [
'order_number',
'customer_name',
'customer_email',
'customer_phone',
'shipping_address',
'total_amount',
'status',
'midtrans_transaction_id',
'payment_type',
'paid_at',
];
protected function casts(): array
{
return [
'total_amount' => 'integer',
'paid_at' => 'datetime',
];
}
protected static function booted(): void
{
static::creating(function (Order $order) {
if (empty($order->order_number)) {
$order->order_number = 'ORD-' . strtoupper(Str::random(8));
}
});
}
public function items(): HasMany
{
return $this->hasMany(OrderItem::class);
}
public function getFormattedTotalAttribute(): string
{
return 'Rp ' . number_format($this->total_amount, 0, ',', '.');
}
public function getStatusLabelAttribute(): string
{
return match($this->status) {
'pending' => 'Menunggu Pembayaran',
'paid' => 'Sudah Dibayar',
'processing' => 'Diproses',
'shipped' => 'Dikirim',
'completed' => 'Selesai',
'cancelled' => 'Dibatalkan',
default => $this->status,
};
}
public function getStatusColorAttribute(): string
{
return match($this->status) {
'pending' => 'warning',
'paid' => 'info',
'processing' => 'primary',
'shipped' => 'primary',
'completed' => 'success',
'cancelled' => 'danger',
default => 'secondary',
};
}
}
OrderItem Model:
<?php
// app/Models/OrderItem.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class OrderItem extends Model
{
protected $fillable = [
'order_id',
'product_id',
'quantity',
'price',
];
protected function casts(): array
{
return [
'quantity' => 'integer',
'price' => 'integer',
];
}
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
public function getSubtotalAttribute(): int
{
return $this->price * $this->quantity;
}
public function getFormattedSubtotalAttribute(): string
{
return 'Rp ' . number_format($this->subtotal, 0, ',', '.');
}
}
Manual Review (20%):
- ✅ Relationships benar semua
- ✅ Accessors untuk formatting price
- ✅ Auto-generate order_number di boot
- ✅ Status label dan color untuk UI
- ✅ Scopes untuk active dan in_stock
Semua models sudah bagus, tidak perlu edit.
Jalankan Migrations
php artisan migrate
Pastikan tidak ada error. Kalau ada error foreign key, cek urutan migration files — categories harus sebelum products, orders sebelum order_items.
💡 RECAP DATABASE DESIGN:
Prompts: 6
Migrations generated: 4
Models generated: 4
Total lines dari Claude: ~200 lines
Manual edits: 1 (hapus redundant index)
Yang penting di-review untuk database:
- Foreign key relationships
- Cascade vs Restrict delete
- Index untuk kolom yang sering di-query/filter
- Data types (integer untuk harga rupiah)
- Nullable vs required fields
Di bagian selanjutnya, kita akan buat Filament Resources untuk CRUD — CategoryResource, ProductResource, dan OrderResource dengan dashboard widgets.
Bagian 5: Filament Resources — CRUD dengan Prompt
Sekarang kita masuk ke bagian yang paling satisfying — bikin admin panel lengkap dengan CRUD. Filament 4 bikin ini jadi sangat cepat, dan dengan vibe coding approach, kita bisa generate Resources dalam hitungan menit.
Prompt 1: CategoryResource
Buatkan Filament 4 Resource untuk Category dengan:
Form:
- name (text input, required)
- slug (text input, auto-generate dari name dengan Str::slug)
Table:
- name (searchable, sortable)
- products count (menampilkan jumlah produk di kategori)
- created_at (date format, sortable)
Navigation:
- Group: Master Data
- Icon: heroicon-o-tag
- Sort: 1
Include juga auto-generate slug saat name diketik.
Response Claude (80%):
<?php
// app/Filament/Resources/CategoryResource.php
namespace App\Filament\Resources;
use App\Filament\Resources\CategoryResource\Pages;
use App\Models\Category;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Forms\Set;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Support\Str;
class CategoryResource extends Resource
{
protected static ?string $model = Category::class;
protected static ?string $navigationIcon = 'heroicon-o-tag';
protected static ?string $navigationGroup = 'Master Data';
protected static ?int $navigationSort = 1;
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('name')
->required()
->maxLength(255)
->live(onBlur: true)
->afterStateUpdated(fn (Set $set, ?string $state) =>
$set('slug', Str::slug($state))
),
Forms\Components\TextInput::make('slug')
->required()
->maxLength(255)
->unique(ignoreRecord: true),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('products_count')
->counts('products')
->label('Products')
->sortable(),
Tables\Columns\TextColumn::make('created_at')
->dateTime('d M Y')
->sortable(),
])
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListCategories::route('/'),
'create' => Pages\CreateCategory::route('/create'),
'edit' => Pages\EditCategory::route('/{record}/edit'),
];
}
}
Manual Review (20%):
- ✅ Auto-generate slug dengan
live(onBlur: true)— nice touch - ✅ Unique validation untuk slug dengan
ignoreRecord: true - ✅ Products count dengan
counts('products') - ✅ Navigation group dan icon sesuai
Generate pages:
php artisan make:filament-resource Category --generate
# Atau kalau sudah buat manual, generate pages saja:
# php artisan make:filament-page ListCategories --resource=CategoryResource
Karena kita sudah punya code, tinggal buat file pages secara manual atau pakai artisan.
Prompt 2: ProductResource
Ini yang paling kompleks karena ada image upload dan rich editor:
Buatkan Filament 4 Resource untuk Product dengan:
Form (2 kolom):
Kolom 1:
- category_id (select, relationship, searchable, required)
- name (text input, required)
- slug (auto-generate dari name)
- description (rich editor / TipTap)
Kolom 2:
- price (text input, numeric, prefix "Rp", required)
- stock (numeric, default 0, min 0)
- image (file upload, image only, max 2MB, directory "products")
- is_active (toggle, default true)
Table:
- image (thumbnail, circular)
- name (searchable, sortable)
- category.name (badge, searchable)
- formatted_price (dari accessor)
- stock (badge: merah jika 0, kuning jika < 10, hijau jika >= 10)
- is_active (icon boolean)
Filters:
- category (select filter)
- is_active (ternary filter)
- stock status (low stock < 10)
Navigation:
- Group: Master Data
- Icon: heroicon-o-cube
- Sort: 2
Response Claude (80%):
<?php
// app/Filament/Resources/ProductResource.php
namespace App\Filament\Resources;
use App\Filament\Resources\ProductResource\Pages;
use App\Models\Product;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Forms\Set;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Support\Str;
class ProductResource extends Resource
{
protected static ?string $model = Product::class;
protected static ?string $navigationIcon = 'heroicon-o-cube';
protected static ?string $navigationGroup = 'Master Data';
protected static ?int $navigationSort = 2;
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\Grid::make(2)
->schema([
// Kolom 1
Forms\Components\Section::make('Informasi Produk')
->schema([
Forms\Components\Select::make('category_id')
->relationship('category', 'name')
->searchable()
->preload()
->required(),
Forms\Components\TextInput::make('name')
->required()
->maxLength(255)
->live(onBlur: true)
->afterStateUpdated(fn (Set $set, ?string $state) =>
$set('slug', Str::slug($state))
),
Forms\Components\TextInput::make('slug')
->required()
->maxLength(255)
->unique(ignoreRecord: true),
Forms\Components\RichEditor::make('description')
->columnSpanFull(),
]),
// Kolom 2
Forms\Components\Section::make('Harga & Stok')
->schema([
Forms\Components\TextInput::make('price')
->required()
->numeric()
->prefix('Rp')
->minValue(0),
Forms\Components\TextInput::make('stock')
->numeric()
->default(0)
->minValue(0),
Forms\Components\FileUpload::make('image')
->image()
->directory('products')
->maxSize(2048)
->imageResizeMode('cover')
->imageCropAspectRatio('1:1')
->imageResizeTargetWidth('500')
->imageResizeTargetHeight('500'),
Forms\Components\Toggle::make('is_active')
->label('Active')
->default(true),
]),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\ImageColumn::make('image')
->circular()
->defaultImageUrl(asset('images/placeholder.png')),
Tables\Columns\TextColumn::make('name')
->searchable()
->sortable()
->limit(30),
Tables\Columns\TextColumn::make('category.name')
->badge()
->color('primary')
->searchable(),
Tables\Columns\TextColumn::make('formatted_price')
->label('Price')
->sortable(query: fn ($query, $direction) =>
$query->orderBy('price', $direction)
),
Tables\Columns\TextColumn::make('stock')
->badge()
->color(fn (int $state): string => match(true) {
$state === 0 => 'danger',
$state < 10 => 'warning',
default => 'success',
}),
Tables\Columns\IconColumn::make('is_active')
->boolean()
->label('Active'),
])
->filters([
Tables\Filters\SelectFilter::make('category')
->relationship('category', 'name'),
Tables\Filters\TernaryFilter::make('is_active')
->label('Active Status'),
Tables\Filters\Filter::make('low_stock')
->label('Low Stock (< 10)')
->query(fn ($query) => $query->where('stock', '<', 10)),
])
->actions([
Tables\Actions\ViewAction::make(),
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListProducts::route('/'),
'create' => Pages\CreateProduct::route('/create'),
'view' => Pages\ViewProduct::route('/{record}'),
'edit' => Pages\EditProduct::route('/{record}/edit'),
];
}
}
Manual Review (20%):
- ✅ Grid 2 kolom dengan Sections
- ✅ Image upload dengan resize dan crop
- ✅ Stock badge dengan color logic
- ✅ Filters lengkap
- ⚠️ Placeholder image path — perlu buat atau ganti
Manual Edit:
Tambahkan navigation badge untuk low stock products:
public static function getNavigationBadge(): ?string
{
return static::getModel()::where('stock', '<', 10)->count() ?: null;
}
public static function getNavigationBadgeColor(): ?string
{
return 'warning';
}
Prompt 3: OrderResource
Order hanya view dan update status, tidak bisa create dari admin:
Buatkan Filament 4 Resource untuk Order dengan:
Table only (no create form):
- order_number (searchable, copyable)
- customer_name (searchable)
- customer_email
- formatted_total (dari accessor)
- status (badge dengan warna sesuai statusColorAttribute)
- payment_type (badge, nullable)
- created_at (datetime, sortable)
Actions:
- View detail
- Update status (dropdown action dengan pilihan status)
Filters:
- status (select)
- date range (created_at)
View page:
- Tampilkan semua info order
- Tampilkan list order items dengan nama produk, qty, price, subtotal
- Total di bawah
Navigation:
- Group: Transaksi
- Icon: heroicon-o-shopping-cart
- Sort: 1
- Badge: jumlah order pending
Response Claude (80%):
<?php
// app/Filament/Resources/OrderResource.php
namespace App\Filament\Resources;
use App\Filament\Resources\OrderResource\Pages;
use App\Models\Order;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Infolists;
use Filament\Infolists\Infolist;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class OrderResource extends Resource
{
protected static ?string $model = Order::class;
protected static ?string $navigationIcon = 'heroicon-o-shopping-cart';
protected static ?string $navigationGroup = 'Transaksi';
protected static ?int $navigationSort = 1;
public static function getNavigationBadge(): ?string
{
return static::getModel()::where('status', 'pending')->count() ?: null;
}
public static function getNavigationBadgeColor(): ?string
{
return 'warning';
}
public static function canCreate(): bool
{
return false;
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('order_number')
->searchable()
->copyable()
->copyMessage('Order number copied!')
->weight('bold'),
Tables\Columns\TextColumn::make('customer_name')
->searchable()
->limit(20),
Tables\Columns\TextColumn::make('customer_email')
->searchable()
->limit(25)
->toggleable(),
Tables\Columns\TextColumn::make('formatted_total')
->label('Total'),
Tables\Columns\TextColumn::make('status')
->badge()
->color(fn (Order $record): string => $record->status_color),
Tables\Columns\TextColumn::make('payment_type')
->badge()
->color('gray')
->placeholder('-'),
Tables\Columns\TextColumn::make('created_at')
->dateTime('d M Y H:i')
->sortable(),
])
->defaultSort('created_at', 'desc')
->filters([
Tables\Filters\SelectFilter::make('status')
->options([
'pending' => 'Pending',
'paid' => 'Paid',
'processing' => 'Processing',
'shipped' => 'Shipped',
'completed' => 'Completed',
'cancelled' => 'Cancelled',
]),
Tables\Filters\Filter::make('created_at')
->form([
Forms\Components\DatePicker::make('from'),
Forms\Components\DatePicker::make('until'),
])
->query(function ($query, array $data) {
return $query
->when($data['from'], fn ($q, $date) =>
$q->whereDate('created_at', '>=', $date))
->when($data['until'], fn ($q, $date) =>
$q->whereDate('created_at', '<=', $date));
}),
])
->actions([
Tables\Actions\ViewAction::make(),
Tables\Actions\Action::make('updateStatus')
->label('Update Status')
->icon('heroicon-o-arrow-path')
->form([
Forms\Components\Select::make('status')
->options([
'pending' => 'Pending',
'paid' => 'Paid',
'processing' => 'Processing',
'shipped' => 'Shipped',
'completed' => 'Completed',
'cancelled' => 'Cancelled',
])
->required(),
])
->action(function (Order $record, array $data) {
$record->update(['status' => $data['status']]);
}),
])
->bulkActions([
//
]);
}
public static function infolist(Infolist $infolist): Infolist
{
return $infolist
->schema([
Infolists\Components\Section::make('Order Information')
->schema([
Infolists\Components\TextEntry::make('order_number')
->weight('bold'),
Infolists\Components\TextEntry::make('status')
->badge()
->color(fn (Order $record): string => $record->status_color),
Infolists\Components\TextEntry::make('payment_type'),
Infolists\Components\TextEntry::make('midtrans_transaction_id')
->label('Transaction ID'),
Infolists\Components\TextEntry::make('created_at')
->dateTime(),
Infolists\Components\TextEntry::make('paid_at')
->dateTime()
->placeholder('Not paid yet'),
])
->columns(3),
Infolists\Components\Section::make('Customer Information')
->schema([
Infolists\Components\TextEntry::make('customer_name'),
Infolists\Components\TextEntry::make('customer_email'),
Infolists\Components\TextEntry::make('customer_phone'),
Infolists\Components\TextEntry::make('shipping_address')
->columnSpanFull(),
])
->columns(3),
Infolists\Components\Section::make('Order Items')
->schema([
Infolists\Components\RepeatableEntry::make('items')
->schema([
Infolists\Components\TextEntry::make('product.name')
->label('Product'),
Infolists\Components\TextEntry::make('quantity'),
Infolists\Components\TextEntry::make('price')
->money('IDR'),
Infolists\Components\TextEntry::make('formatted_subtotal')
->label('Subtotal'),
])
->columns(4),
]),
Infolists\Components\Section::make('Total')
->schema([
Infolists\Components\TextEntry::make('formatted_total')
->label('Total Amount')
->size('lg')
->weight('bold'),
]),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListOrders::route('/'),
'view' => Pages\ViewOrder::route('/{record}'),
];
}
}
Manual Review (20%):
- ✅
canCreate(): false— order tidak bisa dibuat dari admin - ✅ Infolist untuk view detail
- ✅ Update status action
- ✅ Navigation badge untuk pending orders
- ✅ Date range filter
Tidak perlu edit, sudah lengkap.
Prompt 4: Dashboard Widgets
Buatkan StatsOverviewWidget untuk dashboard Filament dengan 4 stats:
1. Total Products - jumlah semua produk aktif
2. Total Orders - jumlah order bulan ini
3. Revenue - total_amount dari order yang status paid/completed bulan ini
4. Pending Orders - jumlah order pending (dengan icon warning)
Format revenue sebagai Rupiah.
Response Claude (80%):
<?php
// app/Filament/Widgets/StatsOverview.php
namespace App\Filament\Widgets;
use App\Models\Order;
use App\Models\Product;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class StatsOverview extends BaseWidget
{
protected function getStats(): array
{
$currentMonth = now()->startOfMonth();
$totalProducts = Product::where('is_active', true)->count();
$totalOrders = Order::where('created_at', '>=', $currentMonth)->count();
$revenue = Order::where('created_at', '>=', $currentMonth)
->whereIn('status', ['paid', 'completed'])
->sum('total_amount');
$pendingOrders = Order::where('status', 'pending')->count();
return [
Stat::make('Total Products', $totalProducts)
->description('Active products')
->icon('heroicon-o-cube')
->color('primary'),
Stat::make('Orders This Month', $totalOrders)
->description('Total orders')
->icon('heroicon-o-shopping-cart')
->color('success'),
Stat::make('Revenue This Month', 'Rp ' . number_format($revenue, 0, ',', '.'))
->description('From paid orders')
->icon('heroicon-o-currency-dollar')
->color('success'),
Stat::make('Pending Orders', $pendingOrders)
->description('Awaiting payment')
->icon('heroicon-o-clock')
->color($pendingOrders > 0 ? 'warning' : 'success'),
];
}
}
Manual Review (20%):
- ✅ Query sudah benar
- ✅ Format Rupiah
- ✅ Conditional color untuk pending
- ✅ Icons sesuai context
Tidak perlu edit.
Generate Resource Pages
Setelah semua Resource code ready, generate pages-nya:
# Buat folder dan pages untuk setiap Resource
mkdir -p app/Filament/Resources/CategoryResource/Pages
mkdir -p app/Filament/Resources/ProductResource/Pages
mkdir -p app/Filament/Resources/OrderResource/Pages
Untuk pages, kamu bisa generate dengan artisan atau buat manual. Contoh ListCategories:
<?php
// app/Filament/Resources/CategoryResource/Pages/ListCategories.php
namespace App\Filament\Resources\CategoryResource\Pages;
use App\Filament\Resources\CategoryResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListCategories extends ListRecords
{
protected static string $resource = CategoryResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}
Pattern yang sama untuk Create, Edit, View pages di setiap Resource.
Test Admin Panel
php artisan serve
Buka http://localhost:8000/admin dan test:
- ✅ Dashboard dengan stats widgets
- ✅ Categories — create, edit, delete
- ✅ Products — create dengan image upload, edit, filter
- ✅ Orders — view list, view detail, update status
💡 RECAP FILAMENT RESOURCES:
Prompts: 4
Resources generated: 3 (Category, Product, Order)
Widgets generated: 1 (StatsOverview)
Total lines dari Claude: ~400 lines
Manual edits: ~10 lines (navigation badge untuk Product)
Tips Filament 4:
- Gunakan Infolist untuk view-only pages
- canCreate/canEdit/canDelete untuk control akses
- Navigation badge untuk highlight important counts
- Filter dengan form components untuk date range
Di bagian selanjutnya, kita akan buat frontend sederhana dengan Blade — homepage, product listing, dan cart.
Bagian 6: Frontend Blade Sederhana
Admin panel sudah ready. Sekarang kita buat frontend untuk customer — homepage, product listing, product detail, dan cart. Kita pakai Blade + Tailwind CSS untuk keep it simple.
Prompt 1: Layout Utama
Buatkan Blade layout untuk e-commerce dengan Tailwind CSS:
Layout (resources/views/layouts/app.blade.php):
- Navbar: logo text "Toko Online", menu (Home, Products, Cart dengan badge)
- Main content area dengan @yield('content')
- Footer: copyright 2026, simple links
Requirements:
- Mobile responsive dengan hamburger menu
- Cart badge menampilkan jumlah item dari session
- Pakai CDN Tailwind untuk simplicity
Response Claude (80%):
{{-- resources/views/layouts/app.blade.php --}}
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>@yield('title', 'Toko Online')</title>
<script src="https://cdn.tailwindcss.com"></script>
@stack('styles')
</head>
<body class="bg-gray-50 min-h-screen flex flex-col">
{{-- Navbar --}}
<nav class="bg-white shadow-sm sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
{{-- Logo --}}
<div class="flex items-center">
<a href="{{ route('home') }}" class="text-xl font-bold text-blue-600">
Toko Online
</a>
</div>
{{-- Desktop Menu --}}
<div class="hidden md:flex items-center space-x-8">
<a href="{{ route('home') }}"
class="text-gray-700 hover:text-blue-600 transition">
Home
</a>
<a href="{{ route('products.index') }}"
class="text-gray-700 hover:text-blue-600 transition">
Products
</a>
<a href="{{ route('cart.index') }}" class="relative">
<span class="text-gray-700 hover:text-blue-600 transition">Cart</span>
@if(session('cart') && count(session('cart')) > 0)
<span class="absolute -top-2 -right-4 bg-red-500 text-white text-xs rounded-full h-5 w-5 flex items-center justify-center">
{{ count(session('cart')) }}
</span>
@endif
</a>
</div>
{{-- Mobile Menu Button --}}
<div class="md:hidden flex items-center">
<button id="mobile-menu-btn" class="text-gray-700">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
</button>
</div>
</div>
{{-- Mobile Menu --}}
<div id="mobile-menu" class="hidden md:hidden pb-4">
<a href="{{ route('home') }}" class="block py-2 text-gray-700">Home</a>
<a href="{{ route('products.index') }}" class="block py-2 text-gray-700">Products</a>
<a href="{{ route('cart.index') }}" class="block py-2 text-gray-700">
Cart
@if(session('cart') && count(session('cart')) > 0)
<span class="ml-2 bg-red-500 text-white text-xs rounded-full px-2 py-1">
{{ count(session('cart')) }}
</span>
@endif
</a>
</div>
</div>
</nav>
{{-- Main Content --}}
<main class="flex-grow">
@yield('content')
</main>
{{-- Footer --}}
<footer class="bg-gray-800 text-white py-8 mt-auto">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center">
<p class="text-gray-400">© 2026 Toko Online. All rights reserved.</p>
</div>
</div>
</footer>
{{-- Mobile Menu Toggle Script --}}
<script>
document.getElementById('mobile-menu-btn').addEventListener('click', function() {
document.getElementById('mobile-menu').classList.toggle('hidden');
});
</script>
@stack('scripts')
</body>
</html>
Manual Review (20%):
- ✅ Responsive dengan mobile menu
- ✅ Cart badge dari session
- ✅ Sticky navbar
- ⚠️ Route names belum ada — perlu buat nanti
Prompt 2: Homepage
Buatkan homepage dengan:
- Hero section: heading, subheading, CTA button ke products
- Featured products section: 4 produk terbaru yang aktif
- Pakai component product card yang reusable
Homepage di resources/views/home.blade.php
Product card di resources/views/components/product-card.blade.php
Response Claude (80%):
{{-- resources/views/components/product-card.blade.php --}}
@props(['product'])
<div class="bg-white rounded-lg shadow-sm overflow-hidden hover:shadow-md transition group">
{{-- Image --}}
<div class="aspect-square overflow-hidden bg-gray-100">
@if($product->image)
<img src="{{ Storage::url($product->image) }}"
alt="{{ $product->name }}"
class="w-full h-full object-cover group-hover:scale-105 transition duration-300">
@else
<div class="w-full h-full flex items-center justify-center text-gray-400">
<svg class="w-16 h-16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
</div>
@endif
</div>
{{-- Content --}}
<div class="p-4">
<span class="text-xs text-blue-600 font-medium">{{ $product->category->name }}</span>
<h3 class="mt-1 font-semibold text-gray-800 truncate">{{ $product->name }}</h3>
<p class="mt-2 text-lg font-bold text-gray-900">{{ $product->formatted_price }}</p>
<div class="mt-4 flex gap-2">
<a href="{{ route('products.show', $product->slug) }}"
class="flex-1 text-center py-2 px-4 border border-blue-600 text-blue-600 rounded-lg hover:bg-blue-50 transition text-sm">
Detail
</a>
<form action="{{ route('cart.add', $product->id) }}" method="POST" class="flex-1">
@csrf
<button type="submit"
class="w-full py-2 px-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition text-sm"
@if($product->stock <= 0) disabled @endif>
@if($product->stock > 0)
+ Cart
@else
Habis
@endif
</button>
</form>
</div>
</div>
</div>
{{-- resources/views/home.blade.php --}}
@extends('layouts.app')
@section('title', 'Home - Toko Online')
@section('content')
{{-- Hero Section --}}
<section class="bg-gradient-to-r from-blue-600 to-blue-800 text-white py-20">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h1 class="text-4xl md:text-5xl font-bold mb-4">
Belanja Online Mudah & Cepat
</h1>
<p class="text-xl text-blue-100 mb-8 max-w-2xl mx-auto">
Temukan berbagai produk berkualitas dengan harga terbaik.
Pengiriman cepat ke seluruh Indonesia.
</p>
<a href="{{ route('products.index') }}"
class="inline-block bg-white text-blue-600 px-8 py-3 rounded-lg font-semibold hover:bg-gray-100 transition">
Lihat Produk
</a>
</div>
</section>
{{-- Featured Products --}}
<section class="py-16">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12">
<h2 class="text-3xl font-bold text-gray-800">Produk Terbaru</h2>
<p class="mt-2 text-gray-600">Koleksi terbaru yang bisa kamu pilih</p>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
@forelse($featuredProducts as $product)
<x-product-card :product="$product" />
@empty
<div class="col-span-full text-center py-12 text-gray-500">
Belum ada produk tersedia.
</div>
@endforelse
</div>
<div class="text-center mt-12">
<a href="{{ route('products.index') }}"
class="inline-block border-2 border-blue-600 text-blue-600 px-8 py-3 rounded-lg font-semibold hover:bg-blue-600 hover:text-white transition">
Lihat Semua Produk
</a>
</div>
</div>
</section>
@endsection
Manual Review (20%):
- ✅ Reusable product card component
- ✅ Stock check untuk disable button
- ✅ Hero dengan gradient
- ✅ Responsive grid
Prompt 3: Product Listing & Detail
Buatkan:
1. Product listing page dengan filter category (sidebar di desktop, dropdown di mobile)
2. Product detail page dengan gambar besar, info lengkap, dan add to cart
Files:
- resources/views/products/index.blade.php
- resources/views/products/show.blade.php
Response Claude (80%):
{{-- resources/views/products/index.blade.php --}}
@extends('layouts.app')
@section('title', 'Products - Toko Online')
@section('content')
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<h1 class="text-2xl font-bold text-gray-800 mb-8">Semua Produk</h1>
<div class="flex flex-col lg:flex-row gap-8">
{{-- Sidebar Filter (Desktop) --}}
<aside class="hidden lg:block w-64 flex-shrink-0">
<div class="bg-white rounded-lg shadow-sm p-6 sticky top-24">
<h3 class="font-semibold text-gray-800 mb-4">Kategori</h3>
<ul class="space-y-2">
<li>
<a href="{{ route('products.index') }}"
class="block py-2 px-3 rounded {{ !request('category') ? 'bg-blue-50 text-blue-600' : 'text-gray-600 hover:bg-gray-50' }}">
Semua
</a>
</li>
@foreach($categories as $category)
<li>
<a href="{{ route('products.index', ['category' => $category->slug]) }}"
class="block py-2 px-3 rounded {{ request('category') == $category->slug ? 'bg-blue-50 text-blue-600' : 'text-gray-600 hover:bg-gray-50' }}">
{{ $category->name }}
<span class="text-gray-400 text-sm">({{ $category->products_count }})</span>
</a>
</li>
@endforeach
</ul>
</div>
</aside>
{{-- Mobile Filter --}}
<div class="lg:hidden mb-4">
<select
class="w-full p-3 border rounded-lg">
<option value="{{ route('products.index') }}" {{ !request('category') ? 'selected' : '' }}>
Semua Kategori
</option>
@foreach($categories as $category)
<option value="{{ route('products.index', ['category' => $category->slug]) }}"
{{ request('category') == $category->slug ? 'selected' : '' }}>
{{ $category->name }} ({{ $category->products_count }})
</option>
@endforeach
</select>
</div>
{{-- Products Grid --}}
<div class="flex-grow">
@if($products->count() > 0)
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-6">
@foreach($products as $product)
<x-product-card :product="$product" />
@endforeach
</div>
<div class="mt-8">
{{ $products->withQueryString()->links() }}
</div>
@else
<div class="text-center py-16 text-gray-500">
<p>Tidak ada produk ditemukan.</p>
</div>
@endif
</div>
</div>
</div>
@endsection
{{-- resources/views/products/show.blade.php --}}
@extends('layouts.app')
@section('title', $product->name . ' - Toko Online')
@section('content')
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{{-- Breadcrumb --}}
<nav class="mb-8 text-sm">
<a href="{{ route('home') }}" class="text-gray-500 hover:text-blue-600">Home</a>
<span class="mx-2 text-gray-400">/</span>
<a href="{{ route('products.index') }}" class="text-gray-500 hover:text-blue-600">Products</a>
<span class="mx-2 text-gray-400">/</span>
<span class="text-gray-800">{{ $product->name }}</span>
</nav>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12">
{{-- Product Image --}}
<div class="aspect-square bg-gray-100 rounded-lg overflow-hidden">
@if($product->image)
<img src="{{ Storage::url($product->image) }}"
alt="{{ $product->name }}"
class="w-full h-full object-cover">
@else
<div class="w-full h-full flex items-center justify-center text-gray-400">
<svg class="w-32 h-32" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
</div>
@endif
</div>
{{-- Product Info --}}
<div>
<span class="inline-block bg-blue-100 text-blue-600 text-sm px-3 py-1 rounded-full mb-4">
{{ $product->category->name }}
</span>
<h1 class="text-3xl font-bold text-gray-800 mb-4">{{ $product->name }}</h1>
<p class="text-3xl font-bold text-blue-600 mb-6">{{ $product->formatted_price }}</p>
{{-- Stock Status --}}
<div class="mb-6">
@if($product->stock > 10)
<span class="text-green-600 font-medium">✓ Stok tersedia</span>
@elseif($product->stock > 0)
<span class="text-yellow-600 font-medium">⚠ Stok terbatas ({{ $product->stock }} tersisa)</span>
@else
<span class="text-red-600 font-medium">✗ Stok habis</span>
@endif
</div>
{{-- Add to Cart Form --}}
@if($product->stock > 0)
<form action="{{ route('cart.add', $product->id) }}" method="POST" class="mb-8">
@csrf
<div class="flex items-center gap-4 mb-4">
<label class="text-gray-700">Jumlah:</label>
<input type="number" name="quantity" value="1" min="1" max="{{ $product->stock }}"
class="w-24 px-4 py-2 border rounded-lg text-center">
</div>
<button type="submit"
class="w-full bg-blue-600 text-white py-3 px-6 rounded-lg font-semibold hover:bg-blue-700 transition">
Tambah ke Keranjang
</button>
</form>
@else
<button disabled class="w-full bg-gray-300 text-gray-500 py-3 px-6 rounded-lg font-semibold cursor-not-allowed mb-8">
Stok Habis
</button>
@endif
{{-- Description --}}
<div class="border-t pt-6">
<h3 class="font-semibold text-gray-800 mb-4">Deskripsi Produk</h3>
<div class="prose prose-sm text-gray-600">
{!! $product->description ?? '<p class="text-gray-400">Tidak ada deskripsi.</p>' !!}
</div>
</div>
</div>
</div>
</div>
@endsection
Manual Review (20%):
- ✅ Sidebar filter desktop + dropdown mobile
- ✅ Pagination dengan query string
- ✅ Stock status dengan visual feedback
- ✅ Quantity input dengan max limit
Prompt 4: Cart Page
Buatkan cart page dengan:
- List items: gambar, nama, harga, quantity (bisa diupdate), subtotal, tombol hapus
- Summary: total harga
- Tombol checkout (nanti akan ke Midtrans)
- Empty state jika cart kosong
File: resources/views/cart/index.blade.php
Response Claude (80%):
{{-- resources/views/cart/index.blade.php --}}
@extends('layouts.app')
@section('title', 'Keranjang - Toko Online')
@section('content')
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<h1 class="text-2xl font-bold text-gray-800 mb-8">Keranjang Belanja</h1>
@if(session('cart') && count(session('cart')) > 0)
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
{{-- Cart Items --}}
<div class="divide-y">
@php $total = 0; @endphp
@foreach(session('cart') as $id => $item)
@php $subtotal = $item['price'] * $item['quantity']; $total += $subtotal; @endphp
<div class="p-6 flex flex-col sm:flex-row gap-4">
{{-- Image --}}
<div class="w-24 h-24 bg-gray-100 rounded-lg overflow-hidden flex-shrink-0">
@if($item['image'])
<img src="{{ Storage::url($item['image']) }}"
alt="{{ $item['name'] }}"
class="w-full h-full object-cover">
@endif
</div>
{{-- Info --}}
<div class="flex-grow">
<h3 class="font-semibold text-gray-800">{{ $item['name'] }}</h3>
<p class="text-gray-600">Rp {{ number_format($item['price'], 0, ',', '.') }}</p>
</div>
{{-- Quantity --}}
<div class="flex items-center gap-2">
<form action="{{ route('cart.update', $id) }}" method="POST" class="flex items-center">
@csrf
@method('PATCH')
<input type="number" name="quantity" value="{{ $item['quantity'] }}" min="1"
class="w-20 px-3 py-2 border rounded text-center">
</form>
</div>
{{-- Subtotal --}}
<div class="text-right sm:w-32">
<p class="font-semibold text-gray-800">
Rp {{ number_format($subtotal, 0, ',', '.') }}
</p>
</div>
{{-- Remove --}}
<form action="{{ route('cart.remove', $id) }}" method="POST">
@csrf
@method('DELETE')
<button type="submit" class="text-red-500 hover:text-red-700">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</form>
</div>
@endforeach
</div>
{{-- Summary --}}
<div class="bg-gray-50 p-6">
<div class="flex justify-between items-center mb-6">
<span class="text-lg font-semibold text-gray-800">Total</span>
<span class="text-2xl font-bold text-blue-600">
Rp {{ number_format($total, 0, ',', '.') }}
</span>
</div>
<a href="{{ route('checkout.index') }}"
class="block w-full text-center bg-blue-600 text-white py-3 px-6 rounded-lg font-semibold hover:bg-blue-700 transition">
Lanjut ke Checkout
</a>
</div>
</div>
{{-- Clear Cart --}}
<div class="mt-4 text-center">
<form action="{{ route('cart.clear') }}" method="POST">
@csrf
@method('DELETE')
<button type="submit" class="text-gray-500 hover:text-red-500 text-sm">
Kosongkan Keranjang
</button>
</form>
</div>
@else
{{-- Empty State --}}
<div class="bg-white rounded-lg shadow-sm p-12 text-center">
<svg class="w-24 h-24 mx-auto text-gray-300 mb-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"/>
</svg>
<h2 class="text-xl font-semibold text-gray-800 mb-2">Keranjang Kosong</h2>
<p class="text-gray-500 mb-6">Belum ada produk di keranjang kamu.</p>
<a href="{{ route('products.index') }}"
class="inline-block bg-blue-600 text-white py-3 px-6 rounded-lg font-semibold hover:bg-blue-700 transition">
Mulai Belanja
</a>
</div>
@endif
</div>
@endsection
Manual Review (20%):
- ✅ Quantity update dengan onchange submit
- ✅ Remove per item
- ✅ Clear all cart
- ✅ Empty state
- ✅ Total calculation
Prompt 5: Controllers & Routes
Buatkan:
1. HomeController dengan index (4 featured products)
2. ProductController dengan index (filter category, paginate) dan show
3. CartController dengan index, add, update, remove, clear
4. Routes untuk semua di atas
Cart disimpan di session dengan struktur:
session('cart') = [
product_id => ['name', 'price', 'image', 'quantity']
]
Response Claude (80%):
<?php
// app/Http/Controllers/HomeController.php
namespace App\Http\Controllers;
use App\Models\Product;
class HomeController extends Controller
{
public function index()
{
$featuredProducts = Product::with('category')
->active()
->inStock()
->latest()
->take(4)
->get();
return view('home', compact('featuredProducts'));
}
}
<?php
// app/Http/Controllers/ProductController.php
namespace App\Http\Controllers;
use App\Models\Category;
use App\Models\Product;
use Illuminate\Http\Request;
class ProductController extends Controller
{
public function index(Request $request)
{
$categories = Category::withCount('products')->get();
$products = Product::with('category')
->active()
->when($request->category, function ($query, $category) {
$query->whereHas('category', fn($q) => $q->where('slug', $category));
})
->latest()
->paginate(12);
return view('products.index', compact('products', 'categories'));
}
public function show(string $slug)
{
$product = Product::with('category')
->where('slug', $slug)
->active()
->firstOrFail();
return view('products.show', compact('product'));
}
}
<?php
// app/Http/Controllers/CartController.php
namespace App\Http\Controllers;
use App\Models\Product;
use Illuminate\Http\Request;
class CartController extends Controller
{
public function index()
{
return view('cart.index');
}
public function add(Request $request, Product $product)
{
$request->validate([
'quantity' => 'integer|min:1|max:' . $product->stock,
]);
$cart = session()->get('cart', []);
$quantity = $request->input('quantity', 1);
if (isset($cart[$product->id])) {
$cart[$product->id]['quantity'] += $quantity;
} else {
$cart[$product->id] = [
'name' => $product->name,
'price' => $product->price,
'image' => $product->image,
'quantity' => $quantity,
];
}
session()->put('cart', $cart);
return redirect()->back()->with('success', 'Produk ditambahkan ke keranjang!');
}
public function update(Request $request, int $id)
{
$request->validate([
'quantity' => 'required|integer|min:1',
]);
$cart = session()->get('cart', []);
if (isset($cart[$id])) {
$cart[$id]['quantity'] = $request->quantity;
session()->put('cart', $cart);
}
return redirect()->back();
}
public function remove(int $id)
{
$cart = session()->get('cart', []);
if (isset($cart[$id])) {
unset($cart[$id]);
session()->put('cart', $cart);
}
return redirect()->back();
}
public function clear()
{
session()->forget('cart');
return redirect()->back();
}
}
<?php
// routes/web.php
use App\Http\Controllers\CartController;
use App\Http\Controllers\HomeController;
use App\Http\Controllers\ProductController;
use Illuminate\Support\Facades\Route;
Route::get('/', [HomeController::class, 'index'])->name('home');
Route::get('/products', [ProductController::class, 'index'])->name('products.index');
Route::get('/products/{slug}', [ProductController::class, 'show'])->name('products.show');
Route::get('/cart', [CartController::class, 'index'])->name('cart.index');
Route::post('/cart/add/{product}', [CartController::class, 'add'])->name('cart.add');
Route::patch('/cart/update/{id}', [CartController::class, 'update'])->name('cart.update');
Route::delete('/cart/remove/{id}', [CartController::class, 'remove'])->name('cart.remove');
Route::delete('/cart/clear', [CartController::class, 'clear'])->name('cart.clear');
// Placeholder untuk checkout (akan dibuat di bagian Midtrans)
Route::get('/checkout', function () {
return 'Checkout page - akan dibuat di bagian Midtrans';
})->name('checkout.index');
Manual Review (20%):
- ✅ Scopes active() dan inStock() dari model
- ✅ Eager loading category
- ✅ Validation quantity dengan max stock
- ✅ Session cart structure
💡 RECAP FRONTEND BLADE:
Prompts: 5
Views created: 6 (layout, home, index, show, cart, component)
Controllers created: 3
Routes defined: 9
Total lines dari Claude: ~450 lines
Manual edits: 0 (semua langsung working)
Frontend ini simple tapi functional. Untuk production:
- Tambahkan flash messages untuk feedback
- Tambahkan loading states
- Implement proper error pages
- Optimize images dengan lazy loading
Di bagian selanjutnya, kita akan integrate Midtrans untuk payment — dari setup config sampai webhook handler.
Bagian 7: Midtrans Payment Integration
Ini bagian yang paling exciting — integrasi payment gateway. Midtrans adalah pilihan populer di Indonesia karena support lengkap: GoPay, OVO, DANA, ShopeePay, Virtual Account semua bank, kartu kredit, bahkan bayar di Indomaret/Alfamart.
Kita akan pakai Midtrans Snap — popup payment yang handle semua payment methods dalam satu interface.
Setup Midtrans Account
Sebelum coding, kamu perlu:
- Daftar di dashboard.midtrans.com
- Buat merchant account (sandbox untuk testing)
- Ambil credentials di Settings > Access Keys:
- Server Key (untuk backend)
- Client Key (untuk frontend)
- Merchant ID
⚠️ PENTING:
- Pakai SANDBOX credentials untuk development
- JANGAN commit credentials ke repository
- Server Key harus RAHASIA — hanya di backend
- Client Key boleh exposed di frontend
Prompt 1: Environment & Config
Setup konfigurasi Midtrans di Laravel:
1. Environment variables di .env
2. Config file di config/midtrans.php
3. Pastikan bisa switch antara sandbox dan production
Credentials yang dibutuhkan:
- Server Key
- Client Key
- Merchant ID
- Environment (sandbox/production)
Response Claude (80%):
# .env
MIDTRANS_MERCHANT_ID=your-merchant-id
MIDTRANS_CLIENT_KEY=your-client-key
MIDTRANS_SERVER_KEY=your-server-key
MIDTRANS_IS_PRODUCTION=false
<?php
// config/midtrans.php
return [
'merchant_id' => env('MIDTRANS_MERCHANT_ID'),
'client_key' => env('MIDTRANS_CLIENT_KEY'),
'server_key' => env('MIDTRANS_SERVER_KEY'),
'is_production' => env('MIDTRANS_IS_PRODUCTION', false),
'snap_url' => env('MIDTRANS_IS_PRODUCTION', false)
? 'https://app.midtrans.com/snap/snap.js'
: 'https://app.sandbox.midtrans.com/snap/snap.js',
'api_url' => env('MIDTRANS_IS_PRODUCTION', false)
? 'https://api.midtrans.com'
: 'https://api.sandbox.midtrans.com',
];
Manual Review (20%):
- ✅ Sandbox/production URL handling
- ✅ Credentials dari env
- Tidak perlu edit
Prompt 2: MidtransService Class
Buatkan MidtransService class dengan methods:
1. createSnapToken(Order $order): string
- Generate snap token untuk payment
- Include transaction details, item details, customer details
- Set expiry 24 jam
- Enable semua payment methods
2. verifySignature(array $notification): bool
- Verify webhook notification dari Midtrans
- Pakai SHA512 signature
3. getTransactionStatus(string $orderId): array
- Get status transaksi dari Midtrans API
Pakai Guzzle HTTP client untuk API calls.
Response Claude (80%):
<?php
// app/Services/MidtransService.php
namespace App\Services;
use App\Models\Order;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class MidtransService
{
protected string $serverKey;
protected string $clientKey;
protected string $apiUrl;
protected bool $isProduction;
public function __construct()
{
$this->serverKey = config('midtrans.server_key');
$this->clientKey = config('midtrans.client_key');
$this->apiUrl = config('midtrans.api_url');
$this->isProduction = config('midtrans.is_production');
}
public function createSnapToken(Order $order): string
{
$order->load('items.product');
$itemDetails = $order->items->map(function ($item) {
return [
'id' => (string) $item->product_id,
'price' => $item->price,
'quantity' => $item->quantity,
'name' => substr($item->product->name, 0, 50),
];
})->toArray();
$params = [
'transaction_details' => [
'order_id' => $order->order_number,
'gross_amount' => $order->total_amount,
],
'item_details' => $itemDetails,
'customer_details' => [
'first_name' => $order->customer_name,
'email' => $order->customer_email,
'phone' => $order->customer_phone ?? '',
'billing_address' => [
'address' => $order->shipping_address,
],
'shipping_address' => [
'address' => $order->shipping_address,
],
],
'expiry' => [
'unit' => 'hours',
'duration' => 24,
],
'callbacks' => [
'finish' => route('checkout.finish'),
],
];
$response = Http::withBasicAuth($this->serverKey, '')
->post($this->apiUrl . '/snap/v1/transactions', $params);
if ($response->failed()) {
Log::error('Midtrans Snap Token Error', [
'order' => $order->order_number,
'response' => $response->json(),
]);
throw new \Exception('Failed to create payment token');
}
return $response->json('token');
}
public function verifySignature(array $notification): bool
{
$orderId = $notification['order_id'] ?? '';
$statusCode = $notification['status_code'] ?? '';
$grossAmount = $notification['gross_amount'] ?? '';
$signatureKey = $notification['signature_key'] ?? '';
$expectedSignature = hash('sha512',
$orderId . $statusCode . $grossAmount . $this->serverKey
);
return $signatureKey === $expectedSignature;
}
public function getTransactionStatus(string $orderId): array
{
$response = Http::withBasicAuth($this->serverKey, '')
->get($this->apiUrl . "/v2/{$orderId}/status");
return $response->json();
}
public function getClientKey(): string
{
return $this->clientKey;
}
public function getSnapUrl(): string
{
return config('midtrans.snap_url');
}
}
Manual Review (20%):
- ✅ HTTP client dengan Basic Auth
- ✅ Signature verification SHA512
- ✅ Item name truncated ke 50 chars (Midtrans limit)
- ✅ Expiry 24 jam
- ⚠️ Error handling bisa ditambah untuk production
Manual Edit:
Tambahkan method untuk handle notification status:
public function handleNotificationStatus(string $transactionStatus, string $fraudStatus = null): string
{
if ($transactionStatus == 'capture') {
return $fraudStatus == 'accept' ? 'paid' : 'pending';
} elseif ($transactionStatus == 'settlement') {
return 'paid';
} elseif (in_array($transactionStatus, ['cancel', 'deny', 'expire'])) {
return 'cancelled';
} elseif ($transactionStatus == 'pending') {
return 'pending';
}
return 'pending';
}
Prompt 3: Checkout Controller
Buatkan CheckoutController dengan flow:
1. index() - tampilkan form checkout (nama, email, phone, alamat)
2. process() -
- Validasi cart tidak kosong
- Validasi form input
- Create order dengan status pending
- Create order items dari cart session
- Kurangi stock produk
- Generate Midtrans snap token
- Clear cart
- Return view dengan snap token
3. finish() - handle redirect dari Midtrans setelah payment
Include validation dan error handling yang proper.
Response Claude (80%):
<?php
// app/Http/Controllers/CheckoutController.php
namespace App\Http\Controllers;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Product;
use App\Services\MidtransService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class CheckoutController extends Controller
{
public function __construct(
protected MidtransService $midtrans
) {}
public function index()
{
$cart = session('cart', []);
if (empty($cart)) {
return redirect()->route('cart.index')
->with('error', 'Keranjang kosong!');
}
$total = collect($cart)->sum(fn($item) => $item['price'] * $item['quantity']);
return view('checkout.index', [
'cart' => $cart,
'total' => $total,
]);
}
public function process(Request $request)
{
$cart = session('cart', []);
if (empty($cart)) {
return redirect()->route('cart.index')
->with('error', 'Keranjang kosong!');
}
$validated = $request->validate([
'customer_name' => 'required|string|max:255',
'customer_email' => 'required|email|max:255',
'customer_phone' => 'nullable|string|max:20',
'shipping_address' => 'required|string|max:500',
]);
try {
$order = DB::transaction(function () use ($cart, $validated) {
$total = collect($cart)->sum(fn($item) => $item['price'] * $item['quantity']);
// Create order
$order = Order::create([
'customer_name' => $validated['customer_name'],
'customer_email' => $validated['customer_email'],
'customer_phone' => $validated['customer_phone'],
'shipping_address' => $validated['shipping_address'],
'total_amount' => $total,
'status' => 'pending',
]);
// Create order items & reduce stock
foreach ($cart as $productId => $item) {
OrderItem::create([
'order_id' => $order->id,
'product_id' => $productId,
'quantity' => $item['quantity'],
'price' => $item['price'],
]);
// Reduce stock
Product::where('id', $productId)
->decrement('stock', $item['quantity']);
}
return $order;
});
// Generate Midtrans token
$snapToken = $this->midtrans->createSnapToken($order);
// Clear cart after successful order creation
session()->forget('cart');
return view('checkout.payment', [
'order' => $order,
'snapToken' => $snapToken,
'clientKey' => $this->midtrans->getClientKey(),
'snapUrl' => $this->midtrans->getSnapUrl(),
]);
} catch (\Exception $e) {
Log::error('Checkout Error', [
'message' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
return redirect()->back()
->with('error', 'Terjadi kesalahan. Silakan coba lagi.')
->withInput();
}
}
public function finish(Request $request)
{
$orderId = $request->query('order_id');
if (!$orderId) {
return redirect()->route('home');
}
$order = Order::where('order_number', $orderId)->first();
if (!$order) {
return redirect()->route('home')
->with('error', 'Order tidak ditemukan.');
}
return view('checkout.finish', compact('order'));
}
}
Manual Review (20%):
- ✅ DB transaction untuk data integrity
- ✅ Stock decrement atomic
- ✅ Cart cleared setelah order created
- ✅ Error handling dengan logging
- ⚠️ Perlu handle race condition untuk stock (di production pakai pessimistic locking)
Prompt 4: Checkout Views
Buatkan views untuk checkout:
1. checkout/index.blade.php - form input customer info
2. checkout/payment.blade.php - tampilkan Midtrans Snap popup
3. checkout/finish.blade.php - thank you page dengan order summary
Untuk payment page, integrate dengan Midtrans Snap.js
Response Claude (80%):
{{-- resources/views/checkout/index.blade.php --}}
@extends('layouts.app')
@section('title', 'Checkout - Toko Online')
@section('content')
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<h1 class="text-2xl font-bold text-gray-800 mb-8">Checkout</h1>
@if(session('error'))
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-6">
{{ session('error') }}
</div>
@endif
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
{{-- Form --}}
<div class="bg-white rounded-lg shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-6">Informasi Pengiriman</h2>
<form action="{{ route('checkout.process') }}" method="POST">
@csrf
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Nama Lengkap *
</label>
<input type="text" name="customer_name" value="{{ old('customer_name') }}"
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('customer_name') border-red-500 @enderror"
required>
@error('customer_name')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Email *
</label>
<input type="email" name="customer_email" value="{{ old('customer_email') }}"
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('customer_email') border-red-500 @enderror"
required>
@error('customer_email')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
No. Telepon
</label>
<input type="tel" name="customer_phone" value="{{ old('customer_phone') }}"
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Alamat Pengiriman *
</label>
<textarea name="shipping_address" rows="3"
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('shipping_address') border-red-500 @enderror"
required>{{ old('shipping_address') }}</textarea>
@error('shipping_address')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
</div>
<button type="submit"
class="w-full mt-6 bg-blue-600 text-white py-3 px-6 rounded-lg font-semibold hover:bg-blue-700 transition">
Lanjut ke Pembayaran
</button>
</form>
</div>
{{-- Order Summary --}}
<div class="bg-white rounded-lg shadow-sm p-6 h-fit">
<h2 class="text-lg font-semibold text-gray-800 mb-6">Ringkasan Pesanan</h2>
<div class="space-y-4">
@foreach($cart as $id => $item)
<div class="flex justify-between text-sm">
<span class="text-gray-600">
{{ $item['name'] }} × {{ $item['quantity'] }}
</span>
<span class="text-gray-800">
Rp {{ number_format($item['price'] * $item['quantity'], 0, ',', '.') }}
</span>
</div>
@endforeach
</div>
<div class="border-t mt-4 pt-4">
<div class="flex justify-between text-lg font-semibold">
<span>Total</span>
<span class="text-blue-600">Rp {{ number_format($total, 0, ',', '.') }}</span>
</div>
</div>
</div>
</div>
</div>
@endsection
{{-- resources/views/checkout/payment.blade.php --}}
@extends('layouts.app')
@section('title', 'Pembayaran - Toko Online')
@section('content')
<div class="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="bg-white rounded-lg shadow-sm p-8 text-center">
<h1 class="text-2xl font-bold text-gray-800 mb-4">Pembayaran</h1>
<p class="text-gray-600 mb-2">Order: <strong>{{ $order->order_number }}</strong></p>
<p class="text-2xl font-bold text-blue-600 mb-8">{{ $order->formatted_total }}</p>
<button id="pay-button"
class="bg-blue-600 text-white py-3 px-8 rounded-lg font-semibold hover:bg-blue-700 transition">
Bayar Sekarang
</button>
<p class="mt-6 text-sm text-gray-500">
Klik tombol di atas untuk memilih metode pembayaran
</p>
</div>
</div>
@endsection
@push('scripts')
<script src="{{ $snapUrl }}" data-client-key="{{ $clientKey }}"></script>
<script>
document.getElementById('pay-button').addEventListener('click', function() {
snap.pay('{{ $snapToken }}', {
onSuccess: function(result) {
window.location.href = '{{ route('checkout.finish') }}?order_id={{ $order->order_number }}';
},
onPending: function(result) {
window.location.href = '{{ route('checkout.finish') }}?order_id={{ $order->order_number }}';
},
onError: function(result) {
alert('Pembayaran gagal. Silakan coba lagi.');
console.log(result);
},
onClose: function() {
alert('Anda belum menyelesaikan pembayaran.');
}
});
});
</script>
@endpush
{{-- resources/views/checkout/finish.blade.php --}}
@extends('layouts.app')
@section('title', 'Terima Kasih - Toko Online')
@section('content')
<div class="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
<div class="bg-white rounded-lg shadow-sm p-8 text-center">
@if($order->status === 'paid')
<div class="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-6">
<svg class="w-10 h-10 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</div>
<h1 class="text-2xl font-bold text-gray-800 mb-2">Pembayaran Berhasil!</h1>
<p class="text-gray-600 mb-6">Terima kasih atas pesanan Anda.</p>
@else
<div class="w-20 h-20 bg-yellow-100 rounded-full flex items-center justify-center mx-auto mb-6">
<svg class="w-10 h-10 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<h1 class="text-2xl font-bold text-gray-800 mb-2">Menunggu Pembayaran</h1>
<p class="text-gray-600 mb-6">Silakan selesaikan pembayaran Anda.</p>
@endif
<div class="bg-gray-50 rounded-lg p-6 text-left mb-6">
<h3 class="font-semibold text-gray-800 mb-4">Detail Pesanan</h3>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-600">Nomor Order</span>
<span class="font-medium">{{ $order->order_number }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Status</span>
<span class="font-medium capitalize">{{ $order->status_label }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Total</span>
<span class="font-medium">{{ $order->formatted_total }}</span>
</div>
</div>
</div>
<p class="text-sm text-gray-500 mb-6">
Konfirmasi pesanan telah dikirim ke <strong>{{ $order->customer_email }}</strong>
</p>
<a href="{{ route('home') }}"
class="inline-block bg-blue-600 text-white py-3 px-8 rounded-lg font-semibold hover:bg-blue-700 transition">
Kembali ke Home
</a>
</div>
</div>
@endsection
Manual Review (20%):
- ✅ Snap.js integration
- ✅ Callback handlers (success, pending, error, close)
- ✅ Conditional display based on order status
- ✅ Order summary display
Prompt 5: Webhook Handler
Buatkan MidtransWebhookController untuk handle notification dari Midtrans:
1. Verify signature
2. Get order by order_number
3. Update order status berdasarkan transaction_status:
- capture (fraud_status=accept) / settlement → paid
- pending → tetap pending
- cancel / deny / expire → cancelled
4. Set paid_at timestamp jika status paid
5. Simpan midtrans_transaction_id dan payment_type
6. Return 200 OK
Webhook harus bisa diakses tanpa CSRF token.
Response Claude (80%):
<?php
// app/Http/Controllers/MidtransWebhookController.php
namespace App\Http\Controllers;
use App\Models\Order;
use App\Services\MidtransService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class MidtransWebhookController extends Controller
{
public function __construct(
protected MidtransService $midtrans
) {}
public function handle(Request $request)
{
$notification = $request->all();
Log::info('Midtrans Webhook Received', $notification);
// Verify signature
if (!$this->midtrans->verifySignature($notification)) {
Log::warning('Midtrans Webhook: Invalid signature', $notification);
return response()->json(['message' => 'Invalid signature'], 403);
}
$orderId = $notification['order_id'] ?? null;
$transactionStatus = $notification['transaction_status'] ?? null;
$fraudStatus = $notification['fraud_status'] ?? null;
$transactionId = $notification['transaction_id'] ?? null;
$paymentType = $notification['payment_type'] ?? null;
// Find order
$order = Order::where('order_number', $orderId)->first();
if (!$order) {
Log::warning('Midtrans Webhook: Order not found', ['order_id' => $orderId]);
return response()->json(['message' => 'Order not found'], 404);
}
// Determine new status
$newStatus = $this->midtrans->handleNotificationStatus($transactionStatus, $fraudStatus);
// Update order
$updateData = [
'status' => $newStatus,
'midtrans_transaction_id' => $transactionId,
'payment_type' => $paymentType,
];
if ($newStatus === 'paid' && !$order->paid_at) {
$updateData['paid_at'] = now();
}
// Restore stock if cancelled
if ($newStatus === 'cancelled' && $order->status !== 'cancelled') {
foreach ($order->items as $item) {
$item->product->increment('stock', $item->quantity);
}
}
$order->update($updateData);
Log::info('Midtrans Webhook: Order updated', [
'order_id' => $orderId,
'old_status' => $order->getOriginal('status'),
'new_status' => $newStatus,
]);
return response()->json(['message' => 'OK']);
}
}
Manual Review (20%):
- ✅ Signature verification
- ✅ Stock restoration untuk cancelled orders
- ✅ Logging untuk debugging
- ✅ Idempotent (check paid_at sebelum set)
Update Routes
// routes/web.php - tambahkan routes checkout
use App\Http\Controllers\CheckoutController;
Route::get('/checkout', [CheckoutController::class, 'index'])->name('checkout.index');
Route::post('/checkout/process', [CheckoutController::class, 'process'])->name('checkout.process');
Route::get('/checkout/finish', [CheckoutController::class, 'finish'])->name('checkout.finish');
// routes/api.php - webhook harus di api routes (no CSRF)
use App\Http\Controllers\MidtransWebhookController;
Route::post('/midtrans/webhook', [MidtransWebhookController::class, 'handle'])
->name('midtrans.webhook');
Register Service Provider
// bootstrap/providers.php atau config/app.php
// Tambahkan service binding di AppServiceProvider
// app/Providers/AppServiceProvider.php
public function register(): void
{
$this->app->singleton(\App\Services\MidtransService::class);
}
Testing di Sandbox
Untuk test payment di sandbox:
| Payment Method | Test Credentials |
|---|---|
| Credit Card | 4811 1111 1111 1114, CVV: 123, Exp: future date |
| BCA VA | Approve di Midtrans simulator |
| GoPay | Scan QR di simulator |
| OVO | Use number 08123456789 |
Akses Midtrans Simulator di: simulator.sandbox.midtrans.com
💡 RECAP MIDTRANS INTEGRATION:
Prompts: 5
Files created:
- config/midtrans.php
- MidtransService.php
- CheckoutController.php
- MidtransWebhookController.php
- 3 Blade views
Total lines dari Claude: ~500 lines
Manual edits: ~15 lines (handleNotificationStatus method)
Security checklist:
✓ Server key hanya di backend
✓ Signature verification untuk webhook
✓ CSRF disabled untuk webhook endpoint
✓ Stock restoration untuk cancelled orders
✓ Logging untuk audit trail
Di bagian selanjutnya, kita akan setup K6 untuk load testing — pastikan aplikasi kamu bisa handle traffic sebelum go production.
Bagian 8: K6 Load Testing — Setup & Basic Tests
Ini bagian yang sering di-skip developer: load testing. Banyak yang baru sadar aplikasinya tidak kuat handle traffic setelah launch dan dapat ribuan visitors. Don't be that developer.
K6 adalah tool load testing modern dari Grafana Labs. Scriptable dengan JavaScript, output yang readable, dan gratis untuk local testing.
Kenapa K6
Sebelum K6, load testing tools seperti JMeter atau Gatling
punya learning curve yang curam dan setup yang ribet.
K6 mengubah itu:
- Script pakai JavaScript (familiar)
- CLI-based (no GUI needed)
- Output langsung readable di terminal
- Bisa integrate dengan Grafana untuk visualization
- Open source dan gratis
Install K6
MacOS:
brew install k6
Windows:
choco install k6
# atau download dari https://dl.k6.io/msi/k6-latest-amd64.msi
Linux (Debian/Ubuntu):
sudo gpg -k
sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list
sudo apt-get update
sudo apt-get install k6
Docker:
docker run --rm -i grafana/k6 run - <script.js
Verify instalasi:
k6 version
# k6 v0.49.0 atau lebih baru
Prompt 1: Basic Homepage Test
Buatkan K6 script untuk load test homepage e-commerce dengan:
- 50 virtual users (VUs)
- Duration 1 menit
- Check response status 200
- Check response time < 500ms
- Output metrics: response time avg, p95, p99, error rate
Target URL: http://localhost:8000
Response Claude (80%):
// tests/k6/homepage.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';
// Custom metrics
const errorRate = new Rate('errors');
const responseTime = new Trend('response_time');
export const options = {
vus: 50,
duration: '1m',
thresholds: {
http_req_duration: ['p(95)<500'], // 95% requests harus < 500ms
errors: ['rate<0.1'], // Error rate harus < 10%
},
};
const BASE_URL = __ENV.BASE_URL || 'http://localhost:8000';
export default function () {
const response = http.get(`${BASE_URL}/`);
// Track custom metrics
responseTime.add(response.timings.duration);
errorRate.add(response.status !== 200);
// Assertions
check(response, {
'status is 200': (r) => r.status === 200,
'response time < 500ms': (r) => r.timings.duration < 500,
'body contains Toko Online': (r) => r.body.includes('Toko Online'),
});
// Think time - simulate real user behavior
sleep(1);
}
export function handleSummary(data) {
return {
'stdout': textSummary(data, { indent: ' ', enableColors: true }),
};
}
function textSummary(data, options) {
const metrics = data.metrics;
return `
================================================================================
K6 LOAD TEST SUMMARY
================================================================================
Duration: ${data.state.testRunDurationMs / 1000}s
Virtual Users: ${options.vus || 50}
HTTP Requests:
Total: ${metrics.http_reqs?.values?.count || 0}
Rate: ${(metrics.http_reqs?.values?.rate || 0).toFixed(2)}/s
Response Times:
Average: ${(metrics.http_req_duration?.values?.avg || 0).toFixed(2)}ms
Min: ${(metrics.http_req_duration?.values?.min || 0).toFixed(2)}ms
Max: ${(metrics.http_req_duration?.values?.max || 0).toFixed(2)}ms
P(90): ${(metrics.http_req_duration?.values['p(90)'] || 0).toFixed(2)}ms
P(95): ${(metrics.http_req_duration?.values['p(95)'] || 0).toFixed(2)}ms
P(99): ${(metrics.http_req_duration?.values['p(99)'] || 0).toFixed(2)}ms
Checks:
Passed: ${metrics.checks?.values?.passes || 0}
Failed: ${metrics.checks?.values?.fails || 0}
Errors: ${((metrics.errors?.values?.rate || 0) * 100).toFixed(2)}%
================================================================================
`;
}
Manual Review (20%):
- ✅ Custom metrics untuk tracking
- ✅ Thresholds untuk pass/fail criteria
- ✅ Think time untuk realistic simulation
- ✅ Environment variable untuk BASE_URL
Prompt 2: Product Listing Test
Buatkan K6 script untuk test product listing dengan:
- Test endpoint /products
- Test dengan filter category (ambil dari response pertama)
- Ramp up: 0 → 100 VUs dalam 30 detik
- Sustain: 100 VUs selama 2 menit
- Ramp down: 100 → 0 VUs dalam 30 detik
- Check pagination works
Response Claude (80%):
// tests/k6/products.js
import http from 'k6/http';
import { check, sleep, group } from 'k6';
import { Rate } from 'k6/metrics';
const errorRate = new Rate('errors');
export const options = {
stages: [
{ duration: '30s', target: 100 }, // Ramp up
{ duration: '2m', target: 100 }, // Sustain
{ duration: '30s', target: 0 }, // Ramp down
],
thresholds: {
http_req_duration: ['p(95)<1000'],
errors: ['rate<0.05'],
},
};
const BASE_URL = __ENV.BASE_URL || 'http://localhost:8000';
export default function () {
group('Product Listing', function () {
// Test basic product listing
const listResponse = http.get(`${BASE_URL}/products`);
check(listResponse, {
'products page status 200': (r) => r.status === 200,
'products page has content': (r) => r.body.includes('Semua Produk'),
});
errorRate.add(listResponse.status !== 200);
sleep(1);
});
group('Product Listing with Filter', function () {
// Test with category filter
const categories = ['electronics', 'fashion', 'food'];
const randomCategory = categories[Math.floor(Math.random() * categories.length)];
const filterResponse = http.get(`${BASE_URL}/products?category=${randomCategory}`);
check(filterResponse, {
'filtered products status 200': (r) => r.status === 200,
});
errorRate.add(filterResponse.status !== 200);
sleep(1);
});
group('Pagination', function () {
// Test pagination
const page2Response = http.get(`${BASE_URL}/products?page=2`);
check(page2Response, {
'pagination status 200': (r) => r.status === 200,
});
errorRate.add(page2Response.status !== 200);
sleep(1);
});
}
Manual Review (20%):
- ✅ Stages untuk ramp up/down pattern
- ✅ Groups untuk organize metrics
- ✅ Random category selection
- ⚠️ Hardcoded categories — di production ambil dari API dulu
Prompt 3: User Journey Test (Full Checkout Simulation)
Buatkan K6 script untuk simulate full user journey:
1. Visit homepage
2. Browse products
3. View product detail
4. Add to cart
5. View cart
6. Go to checkout (sampai form, tidak sampai payment)
Pakai realistic think time antar steps.
Extract data dari responses untuk next requests.
30 VUs selama 3 menit.
Response Claude (80%):
// tests/k6/user-journey.js
import http from 'k6/http';
import { check, sleep, group, fail } from 'k6';
import { Rate, Counter } from 'k6/metrics';
import { parseHTML } from 'k6/html';
const errorRate = new Rate('errors');
const successfulJourneys = new Counter('successful_journeys');
const failedJourneys = new Counter('failed_journeys');
export const options = {
vus: 30,
duration: '3m',
thresholds: {
http_req_duration: ['p(95)<2000'],
errors: ['rate<0.1'],
successful_journeys: ['count>50'],
},
};
const BASE_URL = __ENV.BASE_URL || 'http://localhost:8000';
export default function () {
let productSlug = null;
let productId = null;
let csrfToken = null;
// Step 1: Visit Homepage
group('1. Homepage', function () {
const response = http.get(`${BASE_URL}/`);
const success = check(response, {
'homepage status 200': (r) => r.status === 200,
});
if (!success) {
errorRate.add(1);
failedJourneys.add(1);
return;
}
// Extract CSRF token for later use
const doc = parseHTML(response.body);
csrfToken = doc.find('meta[name="csrf-token"]').attr('content');
sleep(randomThinkTime(2, 5));
});
// Step 2: Browse Products
group('2. Browse Products', function () {
const response = http.get(`${BASE_URL}/products`);
const success = check(response, {
'products page status 200': (r) => r.status === 200,
});
if (!success) {
errorRate.add(1);
failedJourneys.add(1);
return;
}
// Try to extract a product link
const matches = response.body.match(/href="\/products\/([^"]+)"/);
if (matches && matches[1]) {
productSlug = matches[1];
}
sleep(randomThinkTime(3, 8));
});
// Step 3: View Product Detail
group('3. Product Detail', function () {
if (!productSlug) {
productSlug = 'sample-product'; // Fallback
}
const response = http.get(`${BASE_URL}/products/${productSlug}`);
const success = check(response, {
'product detail status 200': (r) => r.status === 200,
});
if (!success) {
errorRate.add(1);
failedJourneys.add(1);
return;
}
// Extract product ID from add to cart form
const idMatch = response.body.match(/cart\/add\/(\d+)/);
if (idMatch && idMatch[1]) {
productId = idMatch[1];
}
// Extract fresh CSRF token
const doc = parseHTML(response.body);
const newToken = doc.find('input[name="_token"]').attr('value');
if (newToken) csrfToken = newToken;
sleep(randomThinkTime(5, 15)); // User reads product details
});
// Step 4: Add to Cart
group('4. Add to Cart', function () {
if (!productId) {
productId = '1'; // Fallback
}
const payload = {
_token: csrfToken,
quantity: '1',
};
const response = http.post(
`${BASE_URL}/cart/add/${productId}`,
payload,
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
redirects: 5,
}
);
check(response, {
'add to cart successful': (r) => r.status === 200 || r.status === 302,
});
sleep(randomThinkTime(1, 3));
});
// Step 5: View Cart
group('5. View Cart', function () {
const response = http.get(`${BASE_URL}/cart`);
const success = check(response, {
'cart page status 200': (r) => r.status === 200,
'cart has items': (r) => r.body.includes('Keranjang') || r.body.includes('cart'),
});
if (!success) {
errorRate.add(1);
}
// Extract CSRF for checkout
const doc = parseHTML(response.body);
const newToken = doc.find('input[name="_token"]').attr('value');
if (newToken) csrfToken = newToken;
sleep(randomThinkTime(2, 5));
});
// Step 6: Go to Checkout
group('6. Checkout Page', function () {
const response = http.get(`${BASE_URL}/checkout`);
const success = check(response, {
'checkout page status 200': (r) => r.status === 200,
'checkout form present': (r) => r.body.includes('customer_name') || r.body.includes('Checkout'),
});
if (success) {
successfulJourneys.add(1);
} else {
errorRate.add(1);
failedJourneys.add(1);
}
sleep(randomThinkTime(3, 10)); // User fills form (but doesn't submit)
});
}
// Helper: Random think time between min and max seconds
function randomThinkTime(min, max) {
return Math.random() * (max - min) + min;
}
Manual Review (20%):
- ✅ Realistic user journey dengan sequential steps
- ✅ Data extraction antar steps (product slug, CSRF token)
- ✅ Random think time untuk realistic simulation
- ✅ Fallback values untuk robustness
- ✅ Journey success/failure tracking
Prompt 4: API Endpoint Test
Buatkan K6 script untuk test API performance:
- GET /products (JSON response check)
- GET /products/{slug}
- Concurrent load: 200 VUs
- Duration: 1 minute
- Focus on response time dan throughput
Response Claude (80%):
// tests/k6/api-load.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend, Counter } from 'k6/metrics';
const errorRate = new Rate('errors');
const requestDuration = new Trend('request_duration');
const requestCount = new Counter('requests');
export const options = {
vus: 200,
duration: '1m',
thresholds: {
http_req_duration: ['p(95)<1500', 'p(99)<3000'],
errors: ['rate<0.05'],
http_reqs: ['rate>100'], // At least 100 requests/second
},
};
const BASE_URL = __ENV.BASE_URL || 'http://localhost:8000';
// Pre-defined product slugs for testing
const PRODUCT_SLUGS = [
'product-1',
'product-2',
'product-3',
];
export default function () {
// Randomly choose between endpoints
const scenario = Math.random();
if (scenario < 0.6) {
// 60% - Product listing
testProductListing();
} else {
// 40% - Product detail
testProductDetail();
}
}
function testProductListing() {
const response = http.get(`${BASE_URL}/products`, {
headers: {
'Accept': 'text/html',
},
});
requestCount.add(1);
requestDuration.add(response.timings.duration);
errorRate.add(response.status !== 200);
check(response, {
'listing: status 200': (r) => r.status === 200,
'listing: response < 1s': (r) => r.timings.duration < 1000,
'listing: has products': (r) => r.body.includes('product') || r.body.includes('Product'),
});
sleep(0.5);
}
function testProductDetail() {
const slug = PRODUCT_SLUGS[Math.floor(Math.random() * PRODUCT_SLUGS.length)];
const response = http.get(`${BASE_URL}/products/${slug}`, {
headers: {
'Accept': 'text/html',
},
});
requestCount.add(1);
requestDuration.add(response.timings.duration);
// 404 is acceptable for non-existent products in this test
errorRate.add(response.status !== 200 && response.status !== 404);
check(response, {
'detail: status 200 or 404': (r) => r.status === 200 || r.status === 404,
'detail: response < 1s': (r) => r.timings.duration < 1000,
});
sleep(0.5);
}
Manual Review (20%):
- ✅ High concurrency (200 VUs)
- ✅ Mixed endpoint testing
- ✅ Throughput threshold
- ⚠️ Product slugs hardcoded — sesuaikan dengan data seeder kamu
Menjalankan K6 Tests
# Pastikan Laravel running di terminal lain
php artisan serve
# Run basic homepage test
k6 run tests/k6/homepage.js
# Run dengan custom BASE_URL (misal staging)
k6 run -e BASE_URL=https://staging.example.com tests/k6/homepage.js
# Run dengan output ke file
k6 run --out json=results.json tests/k6/homepage.js
# Run semua tests
k6 run tests/k6/homepage.js
k6 run tests/k6/products.js
k6 run tests/k6/user-journey.js
k6 run tests/k6/api-load.js
Membaca Hasil K6
Contoh output K6:
/\ |‾‾| /‾‾/ /‾‾/
/\ / \ | |/ / / /
/ \/ \ | ( / ‾‾\
/ \ | |\ \ | (‾) |
/ __________ \ |__| \__\ \_____/ .io
execution: local
script: tests/k6/homepage.js
output: -
scenarios: (100.00%) 1 scenario, 50 max VUs, 1m30s max duration
* default: 50 looping VUs for 1m0s
running (1m00.0s), 00/50 VUs, 2847 complete iterations
default ✓ [======================================] 50 VUs 1m0s
✓ status is 200
✓ response time < 500ms
✓ body contains Toko Online
checks.........................: 100.00% ✓ 8541 ✗ 0
data_received..................: 45 MB 752 kB/s
data_sent......................: 285 kB 4.7 kB/s
http_req_duration..............: avg=89.23ms min=12.45ms max=892.34ms p(90)=156.78ms p(95)=234.56ms
http_reqs......................: 2847 47.45/s
iteration_duration.............: avg=1.09s min=1.01s max=1.89s p(90)=1.16s p(95)=1.24s
iterations.....................: 2847 47.45/s
vus............................: 50 min=50 max=50
vus_max........................: 50 min=50 max=50
Metrics Yang Perlu Diperhatikan
| Metric | Deskripsi | Target (E-commerce) |
|---|---|---|
http_req_duration avg | Rata-rata response time | < 200ms |
http_req_duration p(95) | 95% requests di bawah nilai ini | < 500ms |
http_req_duration p(99) | 99% requests di bawah nilai ini | < 1000ms |
http_reqs rate | Requests per second (throughput) | > 100 rps |
checks | Pass rate untuk assertions | > 99% |
errors | Error rate | < 1% |
Interpretasi Hasil
Hasil BAGUS:
http_req_duration: avg=89ms p(95)=234ms
http_reqs: 47.45/s
errors: 0%
Hasil PERLU OPTIMASI:
http_req_duration: avg=850ms p(95)=2340ms ← Terlalu lambat
http_reqs: 12.5/s ← Throughput rendah
errors: 5.2% ← Error tinggi
Hasil KRITIS:
http_req_duration: avg=3500ms p(95)=8000ms ← Sangat lambat
http_reqs: 3.2/s ← Throughput sangat rendah
errors: 25% ← Banyak error
💡 RECAP K6 LOAD TESTING:
Prompts: 4
Test scripts created: 4
- homepage.js (basic load test)
- products.js (ramp up/down pattern)
- user-journey.js (full user simulation)
- api-load.js (high concurrency API test)
Key concepts covered:
- Virtual Users (VUs) dan stages
- Thresholds untuk pass/fail
- Groups untuk organize metrics
- Think time untuk realistic simulation
- Data extraction antar requests
Next: Kalau hasil K6 menunjukkan masalah,
kita perlu optimize aplikasi.
Di bagian selanjutnya, kita akan analyze hasil K6 dan optimize aplikasi berdasarkan bottleneck yang ditemukan — eager loading, caching, dan database indexing.
Bagian 9: Optimasi Berdasarkan Hasil K6
Setelah run K6 tests, kamu mungkin menemukan beberapa bottleneck. Di bagian ini, kita akan bahas optimasi yang paling umum dibutuhkan untuk Laravel e-commerce: eager loading, caching, dan database indexing.
Simulasi Hasil K6 yang Perlu Optimasi
Misalnya hasil K6 menunjukkan:
Endpoint: /products
─────────────────────────────────────
http_req_duration:
avg=856ms ← Terlalu lambat (target <200ms)
p(95)=1823ms ← Sangat lambat (target <500ms)
http_reqs: 18.5/s ← Throughput rendah (target >100/s)
Checks:
✓ status 200: 100%
✗ response < 500ms: 23.4% ← Banyak yang lambat
Ini menunjukkan endpoint /products punya masalah performance. Mari kita diagnosa dan fix.
Prompt 1: Diagnosa N+1 Query Problem
Saya punya ProductController yang lambat. Ini code-nya:
public function index(Request $request)
{
$products = Product::active()
->when($request->category, function ($query, $category) {
$query->whereHas('category', fn($q) => $q->where('slug', $category));
})
->latest()
->paginate(12);
return view('products.index', compact('products'));
}
Dan di Blade saya akses $product->category->name.
Analisis kemungkinan masalah performance dan berikan solusi.
Response Claude (80%):
Masalah utama: N+1 Query Problem
Ketika kamu paginate 12 products dan akses $product->category->name di Blade, Laravel akan execute:
- 1 query untuk get products
- 12 queries untuk get category masing-masing product
Total: 13 queries untuk 1 page. Kalau pagination 100 products, jadi 101 queries!
Solusi: Eager Loading
<?php
// app/Http/Controllers/ProductController.php
public function index(Request $request)
{
$products = Product::with('category') // Eager load category
->active()
->when($request->category, function ($query, $category) {
$query->whereHas('category', fn($q) => $q->where('slug', $category));
})
->latest()
->paginate(12);
return view('products.index', compact('products'));
}
Dengan with('category'), Laravel execute hanya 2 queries:
- 1 query untuk get products
- 1 query untuk get all related categories
Manual Review (20%):
- ✅ Eager loading dengan
with() - Tambahan: gunakan
select()untuk ambil kolom yang dibutuhkan saja
Manual Edit — Optimize lebih lanjut:
public function index(Request $request)
{
$products = Product::query()
->select(['id', 'category_id', 'name', 'slug', 'price', 'stock', 'image', 'is_active'])
->with(['category:id,name,slug']) // Select specific columns
->active()
->when($request->category, function ($query, $category) {
$query->whereHas('category', fn($q) => $q->where('slug', $category));
})
->latest()
->paginate(12);
return view('products.index', compact('products'));
}
Prompt 2: Implement Caching
Implement caching untuk product listing dengan requirements:
- Cache selama 10 menit
- Cache key berbeda untuk setiap combination filter (category, page)
- Invalidate cache saat ada product create/update/delete
- Pakai Redis sebagai cache driver
Buatkan juga cache invalidation di ProductObserver.
Response Claude (80%):
Controller dengan caching:
<?php
// app/Http/Controllers/ProductController.php
namespace App\Http\Controllers;
use App\Models\Category;
use App\Models\Product;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class ProductController extends Controller
{
public function index(Request $request)
{
$categories = Cache::remember('categories:with_count', 600, function () {
return Category::withCount('products')->get();
});
$cacheKey = $this->buildCacheKey($request);
$products = Cache::remember($cacheKey, 600, function () use ($request) {
return Product::query()
->select(['id', 'category_id', 'name', 'slug', 'price', 'stock', 'image', 'is_active'])
->with(['category:id,name,slug'])
->active()
->when($request->category, function ($query, $category) {
$query->whereHas('category', fn($q) => $q->where('slug', $category));
})
->latest()
->paginate(12);
});
return view('products.index', compact('products', 'categories'));
}
public function show(string $slug)
{
$product = Cache::remember("product:{$slug}", 600, function () use ($slug) {
return Product::with('category')
->where('slug', $slug)
->active()
->firstOrFail();
});
return view('products.show', compact('product'));
}
protected function buildCacheKey(Request $request): string
{
$parts = ['products'];
if ($request->category) {
$parts[] = "cat:{$request->category}";
}
$parts[] = "page:{$request->get('page', 1)}";
return implode(':', $parts);
}
}
Product Observer untuk cache invalidation:
<?php
// app/Observers/ProductObserver.php
namespace App\Observers;
use App\Models\Product;
use Illuminate\Support\Facades\Cache;
class ProductObserver
{
public function created(Product $product): void
{
$this->clearProductCache($product);
}
public function updated(Product $product): void
{
$this->clearProductCache($product);
// Clear specific product cache
Cache::forget("product:{$product->slug}");
// If slug changed, clear old slug cache too
if ($product->isDirty('slug')) {
Cache::forget("product:{$product->getOriginal('slug')}");
}
}
public function deleted(Product $product): void
{
$this->clearProductCache($product);
Cache::forget("product:{$product->slug}");
}
protected function clearProductCache(Product $product): void
{
// Clear all product listing caches
// Using Redis SCAN would be better for production
Cache::flush(); // Simple approach - clears all cache
// Or more targeted approach with tags (if using Redis):
// Cache::tags(['products'])->flush();
// Also clear category count cache
Cache::forget('categories:with_count');
}
}
Register observer:
<?php
// app/Providers/AppServiceProvider.php
use App\Models\Product;
use App\Observers\ProductObserver;
public function boot(): void
{
Product::observe(ProductObserver::class);
}
Manual Review (20%):
- ✅ Cache dengan unique key per filter combination
- ✅ Observer untuk invalidation
- ⚠️
Cache::flush()terlalu agresif untuk production
Manual Edit — Better cache invalidation dengan tags:
// Gunakan cache tags (requires Redis/Memcached)
// Di Controller:
$products = Cache::tags(['products', 'listing'])
->remember($cacheKey, 600, function () use ($request) {
// ... query
});
// Di Observer:
protected function clearProductCache(Product $product): void
{
Cache::tags(['products'])->flush();
Cache::forget('categories:with_count');
}
Prompt 3: Redis Configuration
Setup Redis sebagai cache driver di Laravel 12:
1. Install predis
2. Konfigurasi .env
3. Konfigurasi config/database.php untuk Redis
4. Test connection
Response Claude (80%):
# Install Redis PHP client
composer require predis/predis
# .env
CACHE_DRIVER=redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
REDIS_DB=0
REDIS_CACHE_DB=1
<?php
// config/database.php - Redis section sudah ada default, pastikan:
'redis' => [
'client' => env('REDIS_CLIENT', 'predis'),
'default' => [
'host' => env('REDIS_HOST', '127.0.0.1'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
],
'cache' => [
'host' => env('REDIS_HOST', '127.0.0.1'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
],
],
# Test connection
php artisan tinker
>>> use Illuminate\Support\Facades\Redis;
>>> Redis::ping();
=> "PONG"
>>> use Illuminate\Support\Facades\Cache;
>>> Cache::put('test', 'hello', 60);
>>> Cache::get('test');
=> "hello"
Manual Review (20%):
- ✅ Predis client
- ✅ Separate database untuk cache
- ✅ Test commands
Prompt 4: Database Index Optimization
Analisis query yang sering dijalankan di e-commerce ini dan
rekomendasikan indexes yang perlu ditambahkan:
1. Product listing dengan filter category dan order by created_at
2. Product detail by slug
3. Order listing dengan filter status dan date range
4. Dashboard stats: count products, sum orders by status
Buatkan migration untuk menambahkan indexes.
Response Claude (80%):
<?php
// database/migrations/xxxx_add_performance_indexes.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// Products table indexes
Schema::table('products', function (Blueprint $table) {
// Composite index untuk listing dengan filter dan sort
$table->index(['is_active', 'created_at'], 'products_active_created_index');
// Composite index untuk filter by category + active + sort
$table->index(['category_id', 'is_active', 'created_at'], 'products_category_active_created_index');
// Index untuk stock queries (low stock alerts)
$table->index(['is_active', 'stock'], 'products_active_stock_index');
});
// Orders table indexes
Schema::table('orders', function (Blueprint $table) {
// Composite index untuk listing dengan filter status + date
$table->index(['status', 'created_at'], 'orders_status_created_index');
// Index untuk customer lookup
$table->index('customer_email', 'orders_customer_email_index');
// Index untuk dashboard stats
$table->index(['status', 'total_amount'], 'orders_status_amount_index');
});
// Order items - untuk join queries
Schema::table('order_items', function (Blueprint $table) {
// Composite index untuk order detail queries
$table->index(['order_id', 'product_id'], 'order_items_order_product_index');
});
}
public function down(): void
{
Schema::table('products', function (Blueprint $table) {
$table->dropIndex('products_active_created_index');
$table->dropIndex('products_category_active_created_index');
$table->dropIndex('products_active_stock_index');
});
Schema::table('orders', function (Blueprint $table) {
$table->dropIndex('orders_status_created_index');
$table->dropIndex('orders_customer_email_index');
$table->dropIndex('orders_status_amount_index');
});
Schema::table('order_items', function (Blueprint $table) {
$table->dropIndex('order_items_order_product_index');
});
}
};
Manual Review (20%):
- ✅ Composite indexes untuk query patterns
- ✅ Index naming convention
- ⚠️ Jangan over-index — setiap index menambah overhead saat INSERT/UPDATE
Jalankan migration:
php artisan migrate
Prompt 5: Query Optimization di Filament Dashboard
Optimize StatsOverview widget yang melakukan 4 queries terpisah.
Combine jadi lebih efficient dan tambahkan caching.
Response Claude (80%):
<?php
// app/Filament/Widgets/StatsOverview.php
namespace App\Filament\Widgets;
use App\Models\Order;
use App\Models\Product;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class StatsOverview extends BaseWidget
{
protected static ?int $sort = 1;
// Refresh setiap 30 detik
protected static ?string $pollingInterval = '30s';
protected function getStats(): array
{
$stats = Cache::remember('dashboard:stats', 60, function () {
return $this->calculateStats();
});
return [
Stat::make('Total Products', $stats['total_products'])
->description('Active products')
->icon('heroicon-o-cube')
->color('primary'),
Stat::make('Orders This Month', $stats['orders_this_month'])
->description('Total orders')
->icon('heroicon-o-shopping-cart')
->color('success'),
Stat::make('Revenue This Month', 'Rp ' . number_format($stats['revenue'], 0, ',', '.'))
->description('From paid orders')
->icon('heroicon-o-currency-dollar')
->color('success'),
Stat::make('Pending Orders', $stats['pending_orders'])
->description('Awaiting payment')
->icon('heroicon-o-clock')
->color($stats['pending_orders'] > 0 ? 'warning' : 'success'),
];
}
protected function calculateStats(): array
{
$currentMonth = now()->startOfMonth();
// Single query untuk semua order stats
$orderStats = Order::query()
->selectRaw("
COUNT(CASE WHEN created_at >= ? THEN 1 END) as orders_this_month,
SUM(CASE WHEN status IN ('paid', 'completed') AND created_at >= ? THEN total_amount ELSE 0 END) as revenue,
COUNT(CASE WHEN status = 'pending' THEN 1 END) as pending_orders
", [$currentMonth, $currentMonth])
->first();
// Single query untuk products
$totalProducts = Product::where('is_active', true)->count();
return [
'total_products' => $totalProducts,
'orders_this_month' => $orderStats->orders_this_month ?? 0,
'revenue' => $orderStats->revenue ?? 0,
'pending_orders' => $orderStats->pending_orders ?? 0,
];
}
}
Manual Review (20%):
- ✅ Single query dengan conditional aggregation
- ✅ 60 second cache
- ✅ Polling untuk auto-refresh
Re-run K6 Test
Setelah semua optimasi, run K6 lagi:
k6 run tests/k6/products.js
Expected improvement:
BEFORE OPTIMIZATION:
────────────────────
http_req_duration: avg=856ms p(95)=1823ms
http_reqs: 18.5/s
checks: ✗ response < 500ms: 23.4%
AFTER OPTIMIZATION:
───────────────────
http_req_duration: avg=45ms p(95)=123ms ← 19x faster!
http_reqs: 156.3/s ← 8.4x throughput!
checks: ✓ response < 500ms: 99.8%
Before vs After Comparison
| Metric | Before | After | Improvement |
|---|---|---|---|
| Avg Response Time | 856ms | 45ms | 19x faster |
| P(95) Response Time | 1823ms | 123ms | 15x faster |
| Throughput | 18.5 rps | 156.3 rps | 8.4x higher |
| Pass Rate (<500ms) | 23.4% | 99.8% | 4.3x better |
Optimization Checklist
✅ OPTIMIZATIONS APPLIED:
Database:
□ Eager loading untuk relationships
□ Select specific columns only
□ Composite indexes untuk common queries
□ Avoid N+1 query problem
Caching:
□ Redis sebagai cache driver
□ Cache product listings (10 min TTL)
□ Cache individual products
□ Cache dashboard stats
□ Proper cache invalidation via Observer
Query:
□ Conditional aggregation untuk stats
□ Single query instead of multiple
Application:
□ Pagination untuk large datasets
□ Lazy loading images di frontend
💡 RECAP OPTIMIZATION:
Prompts: 5
Optimizations implemented:
- Eager loading
- Redis caching dengan invalidation
- Database composite indexes
- Query optimization
Key lessons:
1. SELALU profile sebelum optimize
2. N+1 adalah masalah paling umum
3. Caching adalah quick win terbesar
4. Index harus match dengan query pattern
5. Re-test setelah setiap optimasi
Di bagian terakhir, kita akan wrap up tutorial ini dan saya akan share rekomendasi kelas di BuildWithAngga untuk continue learning journey kamu.
Bagian 10: Penutup & Rekomendasi BuildWithAngga
Selamat! Kamu sudah sampai di akhir tutorial ini.
Mari kita recap perjalanan yang sudah kita lewati bersama — dari konsep vibe coding sampai production-ready e-commerce dengan load testing.
Apa yang Sudah Kita Bangun
PROJECT: TOKO ONLINE E-COMMERCE
══════════════════════════════════════════════════════════
Tech Stack:
├── Laravel 12 ─────────── Backend framework
├── Filament 4 ─────────── Admin panel
├── Blade + Tailwind ───── Frontend
├── Midtrans ───────────── Payment gateway
├── Redis ──────────────── Caching
└── K6 ─────────────────── Load testing
Features Implemented:
├── Admin Panel
│ ├── Dashboard dengan real-time stats
│ ├── Category management (CRUD)
│ ├── Product management (image upload, filters)
│ └── Order management (view, update status)
│
├── Customer Frontend
│ ├── Homepage dengan featured products
│ ├── Product listing dengan category filter
│ ├── Product detail page
│ ├── Shopping cart (session-based)
│ └── Checkout flow
│
├── Payment Integration
│ ├── Midtrans Snap integration
│ ├── Multiple payment methods
│ ├── Webhook handler
│ └── Stock management
│
└── Production Readiness
├── K6 load test scripts
├── Performance optimization
├── Redis caching
└── Database indexing
Statistik Vibe Coding
Sepanjang tutorial ini, inilah breakdown bagaimana kita menerapkan framework 80/20:
| Kategori | Dari Claude (80%) | Manual Edit (20%) |
|---|---|---|
| Migrations | 4 files, ~80 lines | Index adjustments |
| Models | 4 files, ~150 lines | Additional scopes |
| Filament Resources | 3 files, ~400 lines | Navigation badges |
| Controllers | 5 files, ~300 lines | Error handling tweaks |
| Blade Views | 8 files, ~600 lines | Styling refinements |
| Services | 1 file, ~100 lines | Status handler method |
| K6 Scripts | 4 files, ~350 lines | URL adjustments |
| Total | ~1,980 lines | ~50 lines edited |
Actual ratio: 97.5% dari prompt, 2.5% manual edit
Bahkan lebih baik dari target 80/20! Ini karena prompt yang detail dan Claude yang capable dalam following instructions.
Lessons Learned
1. Prompt Quality = Output Quality
Semakin detail dan terstruktur prompt kamu, semakin bagus hasilnya. Pattern yang consistently works:
CONTEXT: Saya sedang build [project] dengan [tech stack].
TASK: Buatkan [specific thing] dengan [requirements].
CONSTRAINTS: [Limitations, preferences, conventions].
FORMAT: [How you want the output].
2. Review Tetap Critical
Meskipun Claude generate 97.5% code, 2.5% review dan edit itu tetap crucial:
- Security-sensitive code (webhook signature verification)
- Business logic edge cases (stock restoration)
- Performance implications (index placement)
- Environment-specific configs (URLs, credentials)
3. Iterate, Don't Expect Perfection
Jarang prompt pertama langsung perfect. Pattern yang efektif:
- Prompt → Review → Follow-up prompt untuk improve
- "Tambahkan X ke code sebelumnya"
- "Optimize bagian Y"
- "Jelaskan kenapa kamu pilih approach ini"
4. Understand Before You Ship
Rule of thumb: Kalau kamu tidak bisa explain apa yang code itu lakukan, jangan commit.
Vibe coding bukan tentang blindly copy-paste. Kamu harus paham:
- Apa yang code lakukan
- Kenapa ditulis dengan cara itu
- Apa potential issues-nya
- Bagaimana cara debug kalau error
5. Test, Test, Test
K6 load testing bukan opsional untuk production app. Better to find problems in testing than in production dengan real users.
What's Next: Fitur yang Bisa Ditambahkan
Project ini adalah foundation. Kamu bisa extend dengan:
FITUR LANJUTAN:
Authentication:
├── Customer registration & login
├── Order history per customer
├── Wishlist functionality
└── Address book management
Product:
├── Product variants (size, color)
├── Product reviews & ratings
├── Related products
└── Product search dengan Meilisearch/Typesense
Order:
├── Order tracking
├── Invoice PDF generation
├── Email notifications
└── Shipping integration (RajaOngkir)
Admin:
├── Sales reports & analytics
├── Export to Excel
├── Role-based permissions
└── Activity logging
Marketing:
├── Discount codes & vouchers
├── Flash sale dengan countdown
├── Abandoned cart recovery
└── Email marketing integration
Rekomendasi Belajar di BuildWithAngga
Untuk continue learning journey kamu, berikut rekomendasi kelas yang relevan:
Kelas Gratis — Fondasi yang Kuat
| Kelas | Yang Dipelajari | Durasi |
|---|---|---|
| Laravel Fundamental | MVC, Eloquent, Blade, Auth dasar | 4 jam |
| PHP untuk Pemula | Syntax, OOP, functions, arrays | 3 jam |
| JavaScript Fundamental | ES6+, DOM manipulation, async | 4 jam |
| Tailwind CSS | Utility-first, responsive, components | 3 jam |
| SQL for Beginners | Query, JOIN, subquery, indexing | 3 jam |
| Git untuk Pemula | Version control, branching, collaboration | 2 jam |
Semua kelas gratis ini bisa kamu akses langsung di BuildWithAngga tanpa bayar apapun.
Kelas Premium — Level Up Skills
| Kelas | Cocok Untuk | Highlight |
|---|---|---|
| Full-Stack Laravel E-Commerce | Bikin toko online production-ready | Payment, shipping, admin panel lengkap |
| Laravel Filament Mastery | Master admin panel development | Advanced resources, widgets, plugins |
| Laravel API Development | Build backend untuk mobile apps | REST API, authentication, documentation |
| Full-Stack Laravel + Vue.js | Modern SPA development | Inertia.js, real-time features |
| DevOps untuk Developer | Deploy & manage production servers | Docker, CI/CD, monitoring, security |
| SaaS dengan Laravel | Build subscription-based products | Multi-tenancy, billing, teams |
Kenapa Belajar di BuildWithAngga
┌─────────────────────────────────────────────────────────┐
│ BENEFIT KELAS PREMIUM BUILDWITHANGGA │
├─────────────────────────────────────────────────────────┤
│ │
│ 📦 PROJECT PORTFOLIO-READY │
│ Source code lengkap dari nol sampai deploy. │
│ Bisa langsung dipakai untuk klien atau dijual. │
│ Real-world project, bukan tutorial toy. │
│ │
│ ♾️ AKSES SEUMUR HIDUP │
│ Beli sekali, akses forever. │
│ Update materi gratis selamanya. │
│ Bonus resources dan assets. │
│ │
│ 👨🏫 KONSULTASI MENTOR │
│ Tanya langsung via forum dan grup. │
│ Code review dari praktisi berpengalaman. │
│ Career guidance dan tips industri. │
│ │
│ 📜 SERTIFIKAT RESMI │
│ Bukti kompetensi yang diakui. │
│ LinkedIn-ready untuk profile. │
│ Nilai plus untuk CV dan interview. │
│ │
│ 👥 KOMUNITAS 900.000+ STUDENTS │
│ Networking dengan sesama developer. │
│ Info lowongan kerja dan freelance. │
│ Sharing pengalaman dan tips real. │
│ │
│ 🚀 ALWAYS UP-TO-DATE │
│ Materi diupdate mengikuti teknologi terbaru. │
│ Laravel 12, Filament 4, dan tools modern. │
│ Tidak ketinggalan jaman. │
│ │
└─────────────────────────────────────────────────────────┘
Final Thoughts
Vibe coding dengan framework 80/20 bukan tentang jadi malas atau skip understanding. Ini tentang leverage AI sebagai multiplier untuk productivity — kamu fokus di high-level thinking (architecture, business logic, user experience) sementara AI handle repetitive coding tasks.
Stack yang kita pakai di tutorial ini — Laravel 12, Filament 4, Midtrans, K6 — bukan toy project. Ini production-ready foundation yang bisa kamu kembangkan jadi bisnis nyata atau pakai untuk client projects.
Yang membedakan developer biasa dengan developer yang sukses di era AI bukan siapa yang paling jago nulis code manual. Tapi siapa yang paling efektif directing AI untuk generate code yang benar, dan paling teliti reviewing hasilnya.
REMEMBER:
80% Prompting — Communicate clearly with AI
20% Review — Verify, test, and own the code
The 20% is where YOUR expertise matters.
Terima kasih sudah mengikuti tutorial ini sampai akhir. Kalau ada pertanyaan atau feedback, feel free untuk reach out.
Keep building. Keep learning. Keep shipping.
Angga Risky Setiawan Founder, BuildWithAngga AI Product Engineer
👉 buildwithangga.com