Di artikel ini kita akan bangun toko produk digital — jual templates, source code, e-books — lengkap dengan payment gateway Xendit yang support QRIS, virtual account, e-wallet, sampai PayLater. Stack-nya Laravel 12 + Inertia.js 2.0 + Vue 3. Dan kita bangunnya dengan pendekatan vibe coding.
Vibe Coding Itu Apa, Sebenarnya?
Term ini dipopulerkan oleh Andrej Karpathy pada Februari 2025 — ya, orang yang sama yang pernah jadi Director of AI di Tesla dan co-founder OpenAI. Collins Dictionary bahkan memilihnya sebagai salah satu Word of the Year 2025.
Definisinya sederhana: kamu describe apa yang kamu mau, AI generate kodenya, kamu review dan iterate sampai hasilnya sesuai.
Tapi ada miskonsepsi besar yang perlu kita luruskan dari awal.
Vibe coding bukan berarti kamu copas kode dari AI tanpa ngerti isinya. Simon Willison — developer senior yang juga tokoh penting di komunitas open source — punya framing yang saya suka banget: "If you've reviewed the code, tested it, and understood it all, that's just using an LLM as a typing assistant."
Nah, justru itu yang kita mau. AI sebagai typing assistant yang sangat cepat. Bukan pengganti nalar kamu sebagai developer.
Bedanya dengan ngoding biasa?
| Traditional Coding | Vibe Coding | |
|---|---|---|
| Mulai dari | Sintaks dan struktur kode | Deskripsi problem dalam bahasa manusia |
| Flow kerja | Tulis → debug → tulis lagi | Describe → generate → review → iterate |
| Stuck di | Lupa syntax, googling API docs | Prompt yang kurang spesifik |
| Output | Kode yang kamu tulis sendiri | Kode yang kamu pahami dan validasi |
| Kecepatan | Bergantung typing speed & memory | Jauh lebih cepat untuk boilerplate |
Yang paling penting dari tabel di atas: kolom "Stuck di". Skill utama yang harus kamu kuasai dalam vibe coding bukan lagi hafalan sintaks — tapi kemampuan menulis prompt yang tepat dan mengevaluasi hasilnya.
Tools yang Kita Pakai
Untuk project ini, saya akan pakai Claude (via Cursor) sebagai main AI assistant. Tapi konsep promptingnya sama saja kalau kamu pakai ChatGPT, GitHub Copilot, atau Gemini.
Yang perlu kamu siapkan:
✅ Cursor / VS Code dengan Copilot
✅ Claude atau ChatGPT (akun biasa sudah cukup)
✅ PHP 8.2+ dan Composer
✅ Node.js 20+
✅ Akun Xendit (gratis, daftar di dashboard.xendit.co)
Project Yang Kita Bangun
Sampai akhir artikel ini, kamu akan punya aplikasi toko produk digital dengan fitur:
- Landing page dengan product showcase
- Cart dan checkout flow
- Payment Xendit — QRIS, virtual account, e-wallet, kartu kredit
- Instant download setelah payment dikonfirmasi
- Admin dashboard untuk manage produk dan lihat orders
Stack-nya: Laravel 12 + Inertia.js 2.0 + Vue 3.
Kenapa stack ini? Laravel 12 yang rilis Februari 2025 sudah mature dan punya ekosistem yang sangat bagus untuk aplikasi e-commerce. Inertia 2.0 memberi kita SPA experience tanpa harus bikin API terpisah — perfect untuk project yang mau launch cepat. Vue 3 dengan Composition API-nya bekerja sangat smooth bareng Inertia. Dan Xendit adalah pilihan paling pragmatis untuk payment gateway Indonesia — coverage-nya paling lengkap dan dokumentasinya cukup developer-friendly.
Cara Baca Artikel Ini
Setiap bagian punya pola yang sama:
- Saya tulis prompt yang saya kasih ke AI — lengkap, persis seperti aslinya
- Saya jelaskan cara review hasilnya — apa yang perlu kamu cek, apa red flag-nya
- Code final yang siap dipakai — sudah saya validasi dan adjust kalau perlu
Tujuannya supaya kamu tidak cuma dapat kodenya, tapi juga belajar cara berpikir saat vibe coding. Karena skill itu yang akan berguna jauh setelah project ini selesai.
Di bagian selanjutnya, kita mulai dari awal: install Laravel 12, setup Inertia + Vue, dan desain database untuk toko digital kita. Saya akan tunjukkan prompt persis yang saya pakai dan bagaimana cara evaluate hasilnya sebelum kita lanjut ke langkah berikutnya.
Bagian 2: Setup Project dan Database Design
Kita mulai dari nol. Install Laravel 12, pasang Inertia + Vue, lalu desain database untuk toko digital kita. Semua lewat vibe coding — tulis prompt, review hasilnya, pakai.
Install Laravel 12 + Inertia + Vue
Untuk bagian install awal ini kita tidak perlu prompt ke AI — cukup jalankan perintah berikut secara berurutan:
composer create-project laravel/laravel digital-store "12.*"
cd digital-store
# Install Inertia server-side
composer require inertiajs/inertia-laravel
php artisan inertia:middleware
# Install Vue adapter dan Vite plugin
npm install vue@3 @inertiajs/vue3
npm install -D @vitejs/plugin-vue
Setelah install selesai, kita perlu konfigurasi tiga file: vite.config.js, app.blade.php, dan resources/js/app.js. Di sinilah kita mulai pakai AI.
Prompt yang saya pakai:
Saya baru install Laravel 12 dengan Inertia.js dan Vue 3.
Tolong buatkan konfigurasi untuk tiga file berikut:
1. vite.config.js — setup Vue plugin dan alias @
2. resources/views/app.blade.php — root template dengan @inertia directive
3. resources/js/app.js — setup createInertiaApp dengan Vue 3,
resolve component dari Pages folder, dan progress bar
Tidak perlu install apapun, hanya isi ketiga file tersebut.
Cara review hasilnya:
Sebelum langsung copy-paste, cek tiga hal ini di kode yang di-generate AI:
- Di
vite.config.js: pastikan adalaravel()plugin danvue()plugin keduanya terdaftar. Kalau salah satu hilang, Vite tidak akan bisa compile Vue components. - Di
app.blade.php: pastikan ada@inertiaHeaddi dalam<head>dan@inertiadi dalam<body>. Kalau@inertiaHeadtidak ada, meta tags dari tiap page tidak akan ter-render. - Di
app.js: pastikanresolvemengarah ke folderPagesyang benar. AI kadang nulispages(lowercase) yang bisa menyebabkan error di Linux server karena case-sensitive.
Hasil generate (sudah divalidasi):
// vite.config.js
import { defineConfig } from 'vite'
import laravel from 'laravel-vite-plugin'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [
laravel({
input: ['resources/js/app.js'],
refresh: true,
}),
vue(),
],
resolve: {
alias: {
'@': '/resources/js',
},
},
})
<!-- resources/views/app.blade.php -->
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@inertiaHead
@vite(['resources/js/app.js'])
</head>
<body class="antialiased">
@inertia
</body>
</html>
// resources/js/app.js
import { createApp, h } from 'vue'
import { createInertiaApp } from '@inertiajs/vue3'
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'
createInertiaApp({
// Title tampil di browser tab
title: (title) => `${title} - Digital Store`,
resolve: (name) =>
resolvePageComponent(
`./Pages/${name}.vue`,
import.meta.glob('./Pages/**/*.vue')
),
setup({ el, App, props, plugin }) {
createApp({ render: () => h(App, props) })
.use(plugin)
.mount(el)
},
// Progress bar saat navigasi antar halaman
progress: {
color: '#6366f1',
},
})
Setup sudah selesai. Sekarang kita desain database-nya.
Database Design
Ini bagian yang paling worth it untuk minta bantuan AI — desain skema database melibatkan banyak pertimbangan relasi dan edge case yang mudah kelewat kalau dikerjakan sendiri terburu-buru.
Prompt yang saya pakai:
Saya sedang bangun toko produk digital dengan Laravel 12.
Produk yang dijual berupa file digital (templates, source code, e-books).
Tolong buatkan skema database yang terdiri dari:
- products: menyimpan info produk termasuk path file aslinya (bukan public)
- orders: menyimpan data pembelian, terhubung ke Xendit Invoice API
- order_items: detail produk dalam satu order
- downloads: tracking riwayat download per user per produk
Kebutuhan bisnis:
- Satu order bisa berisi banyak produk
- Setelah bayar, user bisa download produk berkali-kali tapi ada batasnya
- File produk disimpan di luar folder public (secure)
- Order punya status: pending, paid, expired, failed
Buatkan dalam bentuk Laravel migration files, lengkap dengan
foreign keys, indexes, dan komentar untuk kolom yang perlu penjelasan.
Gunakan UUIDv7 untuk primary key orders dan downloads.
Cara review hasilnya:
AI cukup andal untuk bikin migration, tapi ada beberapa yang perlu kamu cek manual:
- Index: pastikan kolom yang sering di-query punya index. Minimal
xendit_invoice_iddi tabel orders, dan(user_id, product_id)di tabel downloads. - UUIDv7: Laravel 12 sudah support
$table->uuid()dengan UUIDv7 sebagai default. Kalau AI generate$table->id()biasa untuk tabel yang diminta pakai UUID, itu perlu diganti. - File path: pastikan kolom
file_pathdi products tidak ada di tabel yang bisa diakses publik tanpa validasi. Ini bukan soal migration tapi reminder untuk controller nanti. - Soft deletes: untuk tabel products, pertimbangkan tambahkan
$table->softDeletes()— AI kadang tidak include ini padahal berguna untuk admin yang mau "hapus" produk tanpa benar-benar hapus data ordernya.
Hasil generate (sudah divalidasi):
// database/migrations/xxxx_create_products_table.php
public function up(): void
{
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->text('description')->nullable();
$table->longText('content')->nullable(); // rich text deskripsi panjang
$table->unsignedBigInteger('price'); // dalam Rupiah, hindari float untuk uang
$table->string('file_path'); // path di storage/app/private, bukan public
$table->string('thumbnail')->nullable();
$table->json('preview_images')->nullable(); // array path screenshot
$table->string('category')->nullable();
$table->boolean('is_published')->default(false);
$table->unsignedInteger('download_limit')->default(5); // max download per pembelian
$table->softDeletes();
$table->timestamps();
});
}
// database/migrations/xxxx_create_orders_table.php
public function up(): void
{
Schema::create('orders', function (Blueprint $table) {
$table->uuid('id')->primary(); // UUIDv7, time-ordered
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('order_number')->unique(); // format: ORD-20250301-XXXXXX
$table->unsignedBigInteger('total');
$table->enum('status', ['pending', 'paid', 'expired', 'failed'])->default('pending');
$table->string('xendit_invoice_id')->nullable()->index();
$table->string('xendit_invoice_url')->nullable();
$table->string('payment_method')->nullable(); // VA, EWALLET, QRIS, dll
$table->string('payment_channel')->nullable(); // BCA, OVO, DANA, dll
$table->timestamp('paid_at')->nullable();
$table->timestamps();
});
}
// database/migrations/xxxx_create_order_items_table.php
public function up(): void
{
Schema::create('order_items', function (Blueprint $table) {
$table->id();
$table->foreignUuid('order_id')->constrained()->cascadeOnDelete();
$table->foreignId('product_id')->constrained()->restrictOnDelete();
$table->unsignedBigInteger('price'); // snapshot harga saat beli, bukan ambil dari products
$table->timestamps();
$table->unique(['order_id', 'product_id']); // satu produk tidak boleh dobel dalam satu order
});
}
// database/migrations/xxxx_create_downloads_table.php
public function up(): void
{
Schema::create('downloads', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
$table->foreignUuid('order_id')->constrained()->cascadeOnDelete();
$table->unsignedInteger('download_count')->default(0);
$table->timestamp('last_downloaded_at')->nullable();
$table->timestamps();
$table->index(['user_id', 'product_id']); // query paling sering: "user ini bisa download produk ini?"
});
}
Satu hal yang saya tambahkan manual dan tidak ada di hasil generate pertama: kolom price di order_items sengaja di-snapshot dari harga saat pembelian. Jadi kalau harga produk berubah di kemudian hari, riwayat order lama tetap akurat.
Eloquent Models
Setelah migration, kita butuh models dengan relationships yang benar. Prompt lanjutannya:
Prompt yang saya pakai:
Berdasarkan migration yang sudah kita buat (products, orders, order_items, downloads),
buatkan Eloquent Models untuk Laravel 12 dengan:
- Semua relationships yang relevan (hasMany, belongsTo, belongsToMany)
- Casts yang tepat (price sebagai integer, status sebagai enum, uuid handling)
- Scope untuk query yang sering dipakai: published(), paid(), featured()
- Accessor: formatted_price (tampil sebagai "Rp 150.000"), is_paid (boolean)
- Order model pakai HasUuids trait
Tulis semua dalam satu response, per file.
Hasil generate (sudah divalidasi):
// app/Models/Product.php
class Product extends Model
{
use SoftDeletes;
protected $fillable = [
'name', 'slug', 'description', 'content',
'price', 'file_path', 'thumbnail',
'preview_images', 'category',
'is_published', 'download_limit',
];
protected $casts = [
'price' => 'integer',
'preview_images' => 'array',
'is_published' => 'boolean',
'download_limit' => 'integer',
];
// Relationships
public function orderItems(): HasMany
{
return $this->hasMany(OrderItem::class);
}
public function downloads(): HasMany
{
return $this->hasMany(Download::class);
}
// Scopes
public function scopePublished(Builder $query): void
{
$query->where('is_published', true);
}
public function scopeFeatured(Builder $query): void
{
$query->published()->latest()->limit(8);
}
// Accessors
public function getFormattedPriceAttribute(): string
{
return 'Rp ' . number_format($this->price, 0, ',', '.');
}
}
// app/Models/Order.php
class Order extends Model
{
use HasUuids; // Laravel 12 default ke UUIDv7
protected $fillable = [
'user_id', 'order_number', 'total', 'status',
'xendit_invoice_id', 'xendit_invoice_url',
'payment_method', 'payment_channel', 'paid_at',
];
protected $casts = [
'total' => 'integer',
'paid_at' => 'datetime',
];
// Relationships
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function items(): HasMany
{
return $this->hasMany(OrderItem::class);
}
public function products(): BelongsToMany
{
return $this->belongsToMany(Product::class, 'order_items')
->withPivot('price');
}
// Scopes
public function scopePaid(Builder $query): void
{
$query->where('status', 'paid');
}
// Accessors
public function getIsPaidAttribute(): bool
{
return $this->status === 'paid';
}
public function getFormattedTotalAttribute(): string
{
return 'Rp ' . number_format($this->total, 0, ',', '.');
}
}
// app/Models/Download.php
class Download extends Model
{
use HasUuids;
protected $fillable = [
'user_id', 'product_id', 'order_id',
'download_count', 'last_downloaded_at',
];
protected $casts = [
'last_downloaded_at' => 'datetime',
'download_count' => 'integer',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
// Cek apakah user masih bisa download
public function canDownload(): bool
{
return $this->download_count < $this->product->download_limit;
}
}
Jalankan migration:
php artisan migrate
Database kita sudah siap. Di bagian selanjutnya kita masuk ke bagian yang paling sering bikin developer Indonesia takut duluan: integrasi Xendit. Kita akan buat service class untuk create invoice, handle webhook pembayaran, dan generate secure download links — semuanya lewat vibe coding dengan prompt yang terstruktur.
Bagian 3: Xendit Integration — Invoice, Webhook, dan Secure Download
Ini bagian yang paling sering bikin developer stuck. Bukan karena Xendit-nya susah — dokumentasinya sebenarnya cukup bagus — tapi karena ada banyak moving parts: create invoice, terima webhook, verifikasi signature, generate download link. Kalau dikerjakan tanpa struktur, gampang berantakan.
Dengan vibe coding, kita pecah jadi beberapa prompt kecil yang masing-masing fokus.
Setup Xendit SDK
Install dulu:
composer require xendit/xendit-php
Lalu tambahkan ke .env:
XENDIT_SECRET_KEY=xnd_development_xxxxxxxxxxxx
XENDIT_WEBHOOK_TOKEN=your_webhook_verification_token
Dan register di config/services.php:
'xendit' => [
'secret_key' => env('XENDIT_SECRET_KEY'),
'webhook_token' => env('XENDIT_WEBHOOK_TOKEN'),
],
Webhook token bisa kamu set sendiri — string random yang sama antara di .env kamu dan di Xendit Dashboard. Nanti kita pakai untuk verifikasi bahwa request webhook benar-benar dari Xendit.
XenditService — Create Invoice
Prompt yang saya pakai:
Saya menggunakan Xendit PHP SDK v7 (xendit/xendit-php).
Buatkan Laravel Service class bernama XenditService di app/Services/XenditService.php.
Class ini punya satu method: createInvoice(Order $order): array
Kebutuhan invoice:
- external_id pakai order->order_number
- amount dari order->total
- currency IDR
- customer data dari order->user (name dan email)
- invoice berlaku 24 jam
- success_redirect_url ke route('orders.success', $order)
- failure_redirect_url ke route('orders.failed', $order)
Gunakan Xendit\\Configuration::setXenditKey() untuk auth.
Gunakan Xendit\\Invoice\\InvoiceApi dan CreateInvoiceRequest.
Inject config dari config('services.xendit.secret_key').
Tambahkan proper exception handling.
Cara review hasilnya:
- Pastikan
Configuration::setXenditKey()dipanggil di constructor, bukan di method. Kalau di method, setiap call akan re-init yang tidak efisien. - Cek return value —
createInvoice()dari SDK v7 return array, bukan object. AI kadang generate code yang akses hasilnya pakai>invoice_url(object notation) padahal harusnya['invoice_url']. - Pastikan ada try-catch. Tanpa ini, kalau Xendit API down, aplikasi kamu akan throw unhandled exception ke user.
Hasil generate (sudah divalidasi):
<?php
// app/Services/XenditService.php
namespace App\\Services;
use App\\Models\\Order;
use Xendit\\Configuration;
use Xendit\\Invoice\\InvoiceApi;
use Xendit\\Invoice\\CreateInvoiceRequest;
use Exception;
class XenditService
{
private InvoiceApi $invoiceApi;
public function __construct()
{
Configuration::setXenditKey(config('services.xendit.secret_key'));
$this->invoiceApi = new InvoiceApi();
}
public function createInvoice(Order $order): array
{
try {
$request = new CreateInvoiceRequest([
'external_id' => $order->order_number,
'amount' => $order->total,
'description' => "Pembayaran Order #{$order->order_number}",
'currency' => 'IDR',
'invoice_duration' => 86400, // 24 jam dalam detik
'customer' => [
'given_names' => $order->user->name,
'email' => $order->user->email,
],
'success_redirect_url' => route('orders.success', $order),
'failure_redirect_url' => route('orders.failed', $order),
]);
return (array) $this->invoiceApi->createInvoice($request);
} catch (Exception $e) {
// Log error tapi jangan expose detail ke caller
\\Log::error('Xendit createInvoice failed', [
'order_number' => $order->order_number,
'error' => $e->getMessage(),
]);
throw new Exception('Gagal membuat invoice pembayaran. Silakan coba lagi.');
}
}
}
CheckoutController
Prompt yang saya pakai:
Buatkan CheckoutController untuk Laravel 12 dengan satu method: process().
Flow yang harus terjadi:
1. Ambil cart dari CartService (inject via method)
2. Validasi cart tidak kosong
3. Buka DB transaction
4. Buat Order dengan order_number format "ORD-YYYYMMDD-XXXXXX" (random 6 char uppercase)
5. Buat OrderItems dari tiap item di cart
6. Panggil XenditService->createInvoice($order)
7. Simpan xendit_invoice_id dan xendit_invoice_url ke order
8. Clear cart
9. Redirect user ke xendit_invoice_url menggunakan Inertia::location()
10. Kalau ada error di manapun, rollback transaction dan redirect back dengan pesan error
User harus sudah login untuk checkout.
Inject XenditService dan CartService via method parameter.
Cara review hasilnya:
- Pastikan
DB::transaction()membungkus semua operasi database DAN panggilan ke Xendit. Kalau Xendit gagal setelah order tersimpan, kita tidak mau ada order yatim yang tidak punya invoice. - Cek
Inertia::location()— ini yang dipakai untuk redirect ke URL eksternal (Xendit payment page) dari Inertia. Kalau AI generatereturn redirect()biasa, itu akan conflict dengan Inertia dan menyebabkan halaman tidak redirect dengan benar. - Pastikan
order_numberbenar-benar unique. AI kadang generate tanpa unique check, padahal collision meski kecil bisa menyebabkan error constraint di database.
Hasil generate (sudah divalidasi):
<?php
// app/Http/Controllers/CheckoutController.php
namespace App\\Http\\Controllers;
use App\\Models\\Order;
use App\\Services\\CartService;
use App\\Services\\XenditService;
use Illuminate\\Http\\Request;
use Illuminate\\Support\\Facades\\DB;
use Illuminate\\Support\\Str;
use Inertia\\Inertia;
use Exception;
class CheckoutController extends Controller
{
public function process(Request $request, CartService $cart, XenditService $xendit)
{
$items = $cart->get();
if (empty($items)) {
return back()->with('error', 'Cart kamu kosong.');
}
try {
DB::transaction(function () use ($items, $cart, $xendit, $request, &$invoiceUrl) {
// Buat order dengan order_number yang unique
do {
$orderNumber = 'ORD-' . now()->format('Ymd') . '-' . strtoupper(Str::random(6));
} while (Order::where('order_number', $orderNumber)->exists());
$order = Order::create([
'user_id' => auth()->id(),
'order_number' => $orderNumber,
'total' => $cart->total(),
'status' => 'pending',
]);
// Simpan setiap item — price di-snapshot dari cart
foreach ($items as $item) {
$order->items()->create([
'product_id' => $item['product_id'],
'price' => $item['price'],
]);
}
// Buat invoice di Xendit
$invoice = $xendit->createInvoice($order);
$order->update([
'xendit_invoice_id' => $invoice['id'],
'xendit_invoice_url' => $invoice['invoice_url'],
]);
$cart->clear();
$invoiceUrl = $invoice['invoice_url'];
});
// Redirect ke halaman pembayaran Xendit
// Inertia::location() untuk redirect ke URL eksternal
return Inertia::location($invoiceUrl);
} catch (Exception $e) {
return back()->with('error', $e->getMessage());
}
}
}
Webhook Handler
Webhook adalah bagian paling krusial — ini yang mengubah status order dari pending ke paid dan memberikan akses download ke user. Kalau webhook tidak jalan, user sudah bayar tapi tidak dapat produknya.
Prompt yang saya pakai:
Buatkan XenditWebhookController untuk Laravel 12.
Method handle() menerima POST request dari Xendit dengan payload invoice.
Yang harus dilakukan:
1. Verifikasi x-callback-token header dengan config('services.xendit.webhook_token')
2. Kalau token tidak cocok, return 401
3. Cari Order berdasarkan xendit_invoice_id (dari $request->id)
4. Kalau status di payload adalah "PAID":
a. Update order: status=paid, paid_at=now(), payment_method, payment_channel
b. Untuk tiap item di order, buat record di tabel downloads
c. Dispatch job SendOrderConfirmationEmail
5. Return JSON {"status": "ok"} dengan HTTP 200
Webhook harus idempotent — kalau Xendit kirim duplikat, tidak boleh create downloads dobel.
Exclude route ini dari CSRF middleware.
Cara review hasilnya:
- Idempotency adalah yang paling sering kelewat dari hasil generate. Xendit bisa kirim webhook yang sama lebih dari sekali (retry mechanism). Pastikan ada pengecekan sebelum create download record — pakai
firstOrCreate, bukancreate. - Pastikan route webhook ada di
$exceptdiVerifyCsrfTokenmiddleware, atau kamu pakaiwithoutMiddleware([VerifyCsrfToken::class])di route definition. - Jangan lupa cek bahwa
orderditemukan sebelum diproses — null check penting di sini.
Hasil generate (sudah divalidasi):
<?php
// app/Http/Controllers/XenditWebhookController.php
namespace App\\Http\\Controllers;
use App\\Jobs\\SendOrderConfirmationEmail;
use App\\Models\\Download;
use App\\Models\\Order;
use Illuminate\\Http\\Request;
class XenditWebhookController extends Controller
{
public function handle(Request $request)
{
// Verifikasi bahwa request ini benar-benar dari Xendit
$token = $request->header('x-callback-token');
if ($token !== config('services.xendit.webhook_token')) {
return response()->json(['error' => 'Unauthorized'], 401);
}
$order = Order::with('items.product')
->where('xendit_invoice_id', $request->id)
->first();
// Order tidak ditemukan — mungkin dari sistem lain, abaikan saja
if (!$order) {
return response()->json(['status' => 'ok']);
}
if ($request->status === 'PAID' && $order->status !== 'paid') {
// Update status order
$order->update([
'status' => 'paid',
'paid_at' => now(),
'payment_method' => $request->payment_method,
'payment_channel' => $request->payment_channel,
]);
// Buat download access untuk setiap produk — firstOrCreate untuk idempotency
foreach ($order->items as $item) {
Download::firstOrCreate(
[
'user_id' => $order->user_id,
'product_id' => $item->product_id,
'order_id' => $order->id,
],
[
'download_count' => 0,
'last_downloaded_at' => null,
]
);
}
// Kirim email konfirmasi via queue agar webhook tidak timeout
SendOrderConfirmationEmail::dispatch($order);
}
return response()->json(['status' => 'ok']);
}
}
Untuk route-nya, exclude dari CSRF:
// routes/web.php
use App\\Http\\Controllers\\XenditWebhookController;
// Webhook harus exclude CSRF — Xendit tidak kirim CSRF token
Route::post('/webhook/xendit', [XenditWebhookController::class, 'handle'])
->withoutMiddleware([\\App\\Http\\Middleware\\VerifyCsrfToken::class])
->name('webhook.xendit');
Secure Download Controller
Setelah order paid, user perlu bisa download file-nya. Tapi kita tidak mau file bisa diakses langsung via URL — harus lewat controller yang verifikasi akses.
Prompt yang saya pakai:
Buatkan DownloadController untuk Laravel 12.
Method download($productId) melakukan:
1. Cari record Download milik user yang sedang login untuk product tersebut
2. Kalau tidak ada, return 403
3. Cek apakah download_count masih di bawah product->download_limit
4. Kalau sudah habis, redirect back dengan pesan error
5. Increment download_count dan update last_downloaded_at
6. Return file download dari storage path yang ada di product->file_path
Gunakan Storage::download() bukan Storage::url() — file di luar public
User harus login. Tambahkan rate limiting 10 request per menit per user.
Cara review hasilnya:
- Pastikan
Storage::download()— bukanStorage::url().url()generate public URL yang bisa diakses siapapun.download()stream file melalui response, tetap private. - Cek bahwa increment dan file serving ada dalam satu operasi yang atomic, atau minimal increment dulu sebelum serve file — bukan setelah. Kalau server crash saat serving, lebih baik count sudah naik daripada tidak tercatat sama sekali.
Hasil generate (sudah divalidasi):
<?php
// app/Http/Controllers/DownloadController.php
namespace App\\Http\\Controllers;
use App\\Models\\Download;
use App\\Models\\Product;
use Illuminate\\Http\\Request;
use Illuminate\\Support\\Facades\\Storage;
class DownloadController extends Controller
{
public function __construct()
{
$this->middleware('auth');
$this->middleware('throttle:10,1'); // max 10 download per menit per user
}
public function download(Product $product)
{
// Cek apakah user punya akses download untuk produk ini
$downloadRecord = Download::where('user_id', auth()->id())
->where('product_id', $product->id)
->first();
if (!$downloadRecord) {
abort(403, 'Kamu belum membeli produk ini.');
}
// Cek apakah masih dalam batas download
if (!$downloadRecord->canDownload()) {
return back()->with('error', "Batas download untuk produk ini sudah habis ({$product->download_limit}x).");
}
// Catat download sebelum serve file
$downloadRecord->increment('download_count');
$downloadRecord->update(['last_downloaded_at' => now()]);
// Stream file dari private storage — tidak lewat public URL
return Storage::download(
$product->file_path,
basename($product->file_path) // nama file yang akan muncul di browser
);
}
}
Tambahkan route-nya:
Route::get('/download/{product}', [DownloadController::class, 'download'])
->middleware('auth')
->name('products.download');
Test di Xendit Sandbox
Sebelum go live, test semua payment methods di Xendit sandbox dulu. Beberapa tips:
| Payment Method | Test Card/Number | Expected Result |
|---|---|---|
| Virtual Account BCA | Apapun | Simulasi via Xendit Dashboard |
| QRIS | Scan QR code | Gunakan Xendit test mode simulator |
| E-Wallet OVO | +6281234567890 | Auto-approve di test mode |
| Kartu Kredit | 4000000000000002 | Approved |
| Kartu Kredit | 4000000000000069 | Declined |
Untuk test webhook di local, pakai ngrok:
ngrok http 8000
# Copy URL ngrok, paste ke Xendit Dashboard > Settings > Webhooks
# Contoh: <https://abc123.ngrok.io/webhook/xendit>
Integrasi Xendit selesai. Di bagian selanjutnya kita bangun tampilan front-end-nya — landing page, product listing, dan detail produk — menggunakan Inertia 2.0 dan Vue 3, lengkap dengan fitur deferred props dan prefetching yang jadi andalan Inertia versi terbaru.
Bagian 4: Frontend dengan Inertia 2.0 + Vue 3
Sekarang kita bangun tampilannya. Landing page, product listing, dan product detail — semuanya pakai Vue 3 dengan Inertia 2.0. Di bagian ini kita akan manfaatkan dua fitur baru Inertia 2.0 yang sangat berguna: Deferred Props untuk lazy load data berat, dan Prefetching untuk navigasi yang terasa instan.
Setup Layout dan Shared Data
Sebelum bikin halaman, kita perlu layout utama dan shared data yang tersedia di semua halaman.
Prompt yang saya pakai:
Saya pakai Laravel 12 + Inertia 2.0 + Vue 3 untuk toko produk digital.
Buatkan dua hal:
1. HandleInertiaRequests middleware (app/Http/Middleware/HandleInertiaRequests.php)
Share data berikut ke semua halaman:
- auth.user: user yang sedang login (null kalau guest)
- cart.count: jumlah item di cart (dari session)
- flash: success dan error dari session
2. Layouts/AppLayout.vue
Layout utama dengan:
- Navbar: logo "Digital Store", navigation links (Home, Products),
cart icon dengan badge count, login/register atau user dropdown
- Main content area dengan <slot />
- Footer sederhana
- Pakai Tailwind CSS
- Pakai <Link> dari @inertiajs/vue3 untuk navigasi (bukan <a>)
Cara review hasilnya:
- Di middleware, pastikan
cart.countpakai closurefn () =>bukan langsung dipanggil. Kalau tanpa closure, session dibaca di setiap request meskipun halaman tersebut tidak butuh data cart. - Di
AppLayout.vue, pastikan importLinkdari@inertiajs/vue3, bukanvue-router. Ini kesalahan paling umum — AI sering bingung antara dua library ini. - Cek bahwa flash message ditampilkan di layout, bukan di tiap halaman — lebih DRY.
Hasil generate (sudah divalidasi):
<?php
// app/Http/Middleware/HandleInertiaRequests.php
namespace App\\Http\\Middleware;
use Illuminate\\Http\\Request;
use Inertia\\Middleware;
class HandleInertiaRequests extends Middleware
{
protected $rootView = 'app';
public function share(Request $request): array
{
return array_merge(parent::share($request), [
'auth' => [
'user' => $request->user(),
],
// Closure — hanya dieksekusi kalau benar-benar dipakai
'cart' => [
'count' => fn () => count(session('cart', [])),
],
'flash' => [
'success' => fn () => $request->session()->get('success'),
'error' => fn () => $request->session()->get('error'),
],
]);
}
}
<!-- resources/js/Layouts/AppLayout.vue -->
<script setup>
import { Link, usePage } from '@inertiajs/vue3'
import { computed } from 'vue'
const page = usePage()
const auth = computed(() => page.props.auth)
const cartCount = computed(() => page.props.cart.count)
const flash = computed(() => page.props.flash)
</script>
<template>
<div class="min-h-screen bg-gray-50">
<!-- Navbar -->
<nav class="bg-white border-b border-gray-200">
<div class="max-w-6xl mx-auto px-4 py-4 flex items-center justify-between">
<Link :href="route('home')" class="text-xl font-bold text-indigo-600">
Digital Store
</Link>
<div class="flex items-center gap-6">
<Link :href="route('products.index')"
class="text-gray-600 hover:text-indigo-600 transition-colors">
Products
</Link>
<!-- Cart icon dengan badge -->
<Link :href="route('cart.index')" class="relative">
<svg class="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 3h2l.4 2M7 13h10l4-4H5.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>
<span v-if="cartCount > 0"
class="absolute -top-2 -right-2 bg-indigo-600 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
{{ cartCount }}
</span>
</Link>
<!-- Auth links -->
<template v-if="auth.user">
<Link :href="route('logout')" method="post" as="button"
class="text-gray-600 hover:text-red-500 transition-colors text-sm">
Logout
</Link>
</template>
<template v-else>
<Link :href="route('login')"
class="text-gray-600 hover:text-indigo-600 transition-colors text-sm">
Login
</Link>
<Link :href="route('register')"
class="bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm hover:bg-indigo-700 transition-colors">
Daftar
</Link>
</template>
</div>
</div>
</nav>
<!-- Flash messages -->
<div v-if="flash.success || flash.error" class="max-w-6xl mx-auto px-4 mt-4">
<div v-if="flash.success"
class="bg-green-50 border border-green-200 text-green-800 px-4 py-3 rounded-lg">
{{ flash.success }}
</div>
<div v-if="flash.error"
class="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded-lg">
{{ flash.error }}
</div>
</div>
<!-- Main content -->
<main class="max-w-6xl mx-auto px-4 py-8">
<slot />
</main>
<!-- Footer -->
<footer class="border-t border-gray-200 mt-16 py-8 text-center text-gray-500 text-sm">
© {{ new Date().getFullYear() }} Digital Store. Built with Laravel + Inertia + Vue.
</footer>
</div>
</template>
Product Listing dengan Deferred Props
Deferred Props adalah fitur Inertia 2.0 yang memungkinkan data berat di-load setelah halaman pertama kali render. Hasilnya: halaman terasa lebih cepat karena user langsung lihat layout, sementara daftar produk menyusul.
Prompt yang saya pakai:
Buatkan ProductController untuk Laravel 12 + Inertia 2.0 dengan dua method:
1. index() — halaman daftar produk
- featuredProducts: 4 produk published terbaru, langsung dikirim (bukan deferred)
- allProducts: semua produk published dengan pagination 12/halaman,
ini dijadikan Inertia::defer() karena datanya banyak
- categories: list semua category untuk filter, langsung dikirim
- Filter by category: terima query param ?category=xxx
2. show($slug) — halaman detail produk
- Tampilkan satu produk berdasarkan slug
- relatedProducts: 4 produk dari category yang sama (exclude produk ini)
- Cek apakah user sudah pernah beli produk ini (hasPurchased)
- Return 404 kalau produk tidak published
Sertakan juga Pages/Products/Index.vue yang:
- Pakai <Deferred> component untuk allProducts
- Tampilkan loading skeleton saat data belum siap
- Pakai <Link prefetch="hover"> untuk tiap product card
- Pakai AppLayout
Cara review hasilnya:
- Pastikan
Inertia::defer()membungkus closure, bukan langsung nilai.Inertia::defer(fn () => ...)bukanInertia::defer(...). - Di Vue,
<Deferred>component diimport dari@inertiajs/vue3. AI kadang tidak include import ini. - Pastikan
prefetch="hover"ada di<Link>— ini yang bikin navigasi terasa instan karena data di-fetch saat user hover, bukan saat klik. - Cek bahwa skeleton loading jumlah kolomnya konsisten dengan grid produk aslinya.
Hasil generate (sudah divalidasi):
<?php
// app/Http/Controllers/ProductController.php
namespace App\\Http\\Controllers;
use App\\Models\\Product;
use Inertia\\Inertia;
class ProductController extends Controller
{
public function index()
{
$category = request('category');
return Inertia::render('Products/Index', [
// Langsung dikirim — dipakai untuk hero section
'featuredProducts' => Product::published()
->latest()
->take(4)
->get(),
'categories' => \\App\\Models\\Product::published()
->distinct()
->pluck('category')
->filter()
->values(),
'activeCategory' => $category,
// Deferred — load setelah halaman render
'allProducts' => Inertia::defer(fn () =>
Product::published()
->when($category, fn ($q) => $q->where('category', $category))
->latest()
->paginate(12)
),
]);
}
public function show(string $slug)
{
$product = Product::published()
->where('slug', $slug)
->firstOrFail();
return Inertia::render('Products/Show', [
'product' => $product,
'relatedProducts' => Product::published()
->where('category', $product->category)
->where('id', '!=', $product->id)
->take(4)
->get(),
// Cek apakah user sudah punya akses download
'hasPurchased' => auth()->check()
? \\App\\Models\\Download::where('user_id', auth()->id())
->where('product_id', $product->id)
->exists()
: false,
]);
}
}
<!-- resources/js/Pages/Products/Index.vue -->
<script setup>
import { Deferred, Link } from '@inertiajs/vue3'
import AppLayout from '@/Layouts/AppLayout.vue'
defineProps({
featuredProducts: Array,
categories: Array,
activeCategory: String,
// allProducts tidak perlu didefinisikan di sini —
// Deferred component yang handle passing-nya
})
</script>
<template>
<AppLayout>
<head title="Products" />
<!-- Featured Products — langsung muncul -->
<section v-if="featuredProducts.length" class="mb-12">
<h2 class="text-2xl font-bold text-gray-900 mb-6">Produk Terbaru</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<Link
v-for="product in featuredProducts"
:key="product.id"
:href="route('products.show', product.slug)"
prefetch="hover"
>
<ProductCard :product="product" />
</Link>
</div>
</section>
<!-- Category filter -->
<div class="flex gap-2 mb-6 flex-wrap">
<Link :href="route('products.index')"
class="px-4 py-2 rounded-full text-sm transition-colors"
:class="!activeCategory ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'">
Semua
</Link>
<Link
v-for="cat in categories"
:key="cat"
:href="route('products.index', { category: cat })"
class="px-4 py-2 rounded-full text-sm transition-colors"
:class="activeCategory === cat ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
>
{{ cat }}
</Link>
</div>
<!-- All Products — load setelah render -->
<Deferred data="allProducts">
<!-- Skeleton loading -->
<template #fallback>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<div v-for="i in 12" :key="i"
class="bg-gray-200 rounded-xl animate-pulse aspect-square" />
</div>
</template>
<!-- Data sudah siap -->
<template #default="{ allProducts }">
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<Link
v-for="product in allProducts.data"
:key="product.id"
:href="route('products.show', product.slug)"
prefetch="hover"
>
<ProductCard :product="product" />
</Link>
</div>
<!-- Pagination -->
<div class="flex justify-center gap-2 mt-8">
<Link
v-for="link in allProducts.links"
:key="link.label"
:href="link.url ?? '#'"
v-html="link.label"
class="px-3 py-2 rounded text-sm"
:class="link.active
? 'bg-indigo-600 text-white'
: 'bg-white text-gray-600 hover:bg-gray-50 border border-gray-200'"
/>
</div>
</template>
</Deferred>
</AppLayout>
</template>
ProductCard Component
Prompt yang saya pakai:
Buatkan ProductCard.vue untuk toko produk digital.
Tampilkan: thumbnail, nama produk, category badge, dan formatted_price.
Pakai Tailwind CSS. Ada hover effect yang subtle.
Kalau tidak ada thumbnail, tampilkan placeholder dengan icon.
Props: product (object dengan name, thumbnail, category, formatted_price).
Hasil generate (sudah divalidasi):
<!-- resources/js/Components/ProductCard.vue -->
<script setup>
defineProps({
product: {
type: Object,
required: true,
},
})
</script>
<template>
<div class="bg-white rounded-xl overflow-hidden border border-gray-100
hover:border-indigo-200 hover:shadow-md transition-all duration-200 group">
<!-- Thumbnail -->
<div class="aspect-video bg-gray-100 overflow-hidden">
<img
v-if="product.thumbnail"
:src="product.thumbnail"
:alt="product.name"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
<div v-else class="w-full h-full flex items-center justify-center">
<svg class="w-12 h-12 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
</svg>
</div>
</div>
<!-- Info -->
<div class="p-4">
<span v-if="product.category"
class="text-xs text-indigo-600 font-medium bg-indigo-50 px-2 py-0.5 rounded-full">
{{ product.category }}
</span>
<h3 class="font-semibold text-gray-900 mt-2 text-sm leading-snug line-clamp-2">
{{ product.name }}
</h3>
<p class="text-indigo-600 font-bold mt-2">{{ product.formatted_price }}</p>
</div>
</div>
</template>
Dengan setup ini, halaman produk sudah punya pengalaman yang cukup smooth: layout dan featured products muncul langsung, daftar lengkap menyusul dengan skeleton animation, dan navigasi ke detail produk terasa instan karena prefetch saat hover.
Di bagian selanjutnya kita bangun cart dan checkout flow — dari tambah produk ke cart, sampai user di-redirect ke halaman pembayaran Xendit.
Bagian 5: Cart & Checkout Flow
Cart kita buat session-based — tidak perlu tabel database tambahan, cukup simpan di session Laravel. Ini pilihan yang tepat untuk MVP: simple, cepat, dan tidak ada kompleksitas sinkronisasi antar device. Kalau nanti butuh persistent cart, tinggal ganti implementasi CartService-nya saja tanpa ubah controller.
CartService
Prompt yang saya pakai:
Buatkan CartService untuk Laravel 12 di app/Services/CartService.php.
Cart disimpan di Laravel session dengan key "cart".
Method yang dibutuhkan:
- add(Product $product): void
Tambah produk ke cart. Kalau sudah ada, tidak dobel (digital product, qty selalu 1).
Simpan: product_id, name, price, thumbnail, slug.
- remove(int $productId): void
Hapus produk dari cart berdasarkan product_id.
- get(): array
Return semua item di cart.
- total(): int
Return total harga semua item (dalam Rupiah, integer).
- count(): int
Return jumlah item di cart.
- clear(): void
Kosongkan cart.
- has(int $productId): bool
Cek apakah produk sudah ada di cart.
Cara review hasilnya:
- Pastikan
add()cek duplikat denganisset($cart[$product->id])sebelum tambah — bukan cek array_search atau loop manual yang lebih lambat. - Kolom
priceyang disimpan ke session harus dari$product->price(integer), bukanformatted_price(string). Kalau yang disimpan string, kalkulasitotal()akan error. total()harus returnint, bukanfloat. Harga rupiah tidak pakai desimal.
Hasil generate (sudah divalidasi):
<?php
// app/Services/CartService.php
namespace App\\Services;
use App\\Models\\Product;
class CartService
{
private string $sessionKey = 'cart';
public function add(Product $product): void
{
$cart = session($this->sessionKey, []);
// Produk digital — tidak perlu qty lebih dari 1
if (!isset($cart[$product->id])) {
$cart[$product->id] = [
'product_id' => $product->id,
'name' => $product->name,
'price' => $product->price, // integer, bukan formatted
'thumbnail' => $product->thumbnail,
'slug' => $product->slug,
];
}
session([$this->sessionKey => $cart]);
}
public function remove(int $productId): void
{
$cart = session($this->sessionKey, []);
unset($cart[$productId]);
session([$this->sessionKey => $cart]);
}
public function get(): array
{
return session($this->sessionKey, []);
}
public function total(): int
{
return (int) array_sum(array_column($this->get(), 'price'));
}
public function count(): int
{
return count($this->get());
}
public function clear(): void
{
session()->forget($this->sessionKey);
}
public function has(int $productId): bool
{
return isset(session($this->sessionKey, [])[$productId]);
}
}
Daftarkan sebagai singleton di AppServiceProvider supaya instance-nya konsisten dalam satu request:
// app/Providers/AppServiceProvider.php
public function register(): void
{
$this->app->singleton(CartService::class);
}
CartController
Prompt yang saya pakai:
Buatkan CartController untuk Laravel 12 + Inertia dengan tiga method:
1. index() — tampilkan halaman cart
Return Inertia render dengan items dari CartService dan total.
Untuk setiap item, tambahkan formatted_price (Rp xxx.xxx).
2. add(Product $product) — POST, tambah produk ke cart
Gunakan CartService->add().
Kalau user sudah pernah beli produk ini (ada di tabel downloads), jangan tambah ke cart,
redirect back dengan error "Kamu sudah memiliki produk ini."
Kalau sudah ada di cart, redirect back dengan info "Produk sudah di cart."
Kalau berhasil, redirect back dengan success message.
preserveScroll: true via Inertia.
3. remove(Product $product) — DELETE, hapus dari cart
Gunakan CartService->remove().
Redirect back dengan preserveScroll.
Inject CartService via constructor.
Cara review hasilnya:
- Pengecekan "sudah pernah beli" harus ada di
add(), bukan hanya di checkout. Kalau tidak, user yang sudah punya produk bisa tetap masukkan ke cart dan bayar lagi. preserveScrolltidak bisa di-set dari controller — ini adalah opsi di sisi Vue/Inertia router. Dari controller kita cukupreturn back()ataureturn redirect()biasa. Kalau AI generatereturn Inertia::back(preserveScroll: true), itu salah syntax.- Pastikan route
cart.addpakaiPOSTdancart.removepakaiDELETE.
Hasil generate (sudah divalidasi):
<?php
// app/Http/Controllers/CartController.php
namespace App\\Http\\Controllers;
use App\\Models\\Download;
use App\\Models\\Product;
use App\\Services\\CartService;
use Inertia\\Inertia;
class CartController extends Controller
{
public function __construct(private CartService $cart) {}
public function index()
{
$items = $this->cart->get();
// Tambahkan formatted_price untuk tiap item di cart
$formattedItems = array_map(function ($item) {
$item['formatted_price'] = 'Rp ' . number_format($item['price'], 0, ',', '.');
return $item;
}, $items);
return Inertia::render('Cart/Index', [
'items' => array_values($formattedItems),
'total' => $this->cart->total(),
'formattedTotal' => 'Rp ' . number_format($this->cart->total(), 0, ',', '.'),
]);
}
public function add(Product $product)
{
// Cek apakah user sudah pernah beli
if (auth()->check()) {
$alreadyOwned = Download::where('user_id', auth()->id())
->where('product_id', $product->id)
->exists();
if ($alreadyOwned) {
return back()->with('error', 'Kamu sudah memiliki produk ini.');
}
}
// Cek apakah sudah ada di cart
if ($this->cart->has($product->id)) {
return back()->with('error', 'Produk sudah ada di cart.');
}
$this->cart->add($product);
return back()->with('success', "{$product->name} ditambahkan ke cart.");
}
public function remove(Product $product)
{
$this->cart->remove($product->id);
return back();
}
}
Routes untuk cart:
// routes/web.php
Route::get('/cart', [CartController::class, 'index'])->name('cart.index');
Route::post('/cart/{product}', [CartController::class, 'add'])->name('cart.add');
Route::delete('/cart/{product}', [CartController::class, 'remove'])->name('cart.remove');
Route::post('/checkout', [CheckoutController::class, 'process'])
->middleware('auth')
->name('checkout.process');
Route::get('/orders/{order}/success', [OrderController::class, 'success'])
->middleware('auth')
->name('orders.success');
Route::get('/orders/{order}/failed', [OrderController::class, 'failed'])
->middleware('auth')
->name('orders.failed');
Cart Page
Prompt yang saya pakai:
Buatkan Pages/Cart/Index.vue untuk Laravel + Inertia + Vue 3.
Tampilkan:
- List item di cart: thumbnail, nama, harga, tombol hapus
- Summary di kanan: total harga, tombol "Lanjut Bayar"
- Kalau cart kosong: ilustrasi empty state dengan link ke halaman products
- Tombol hapus pakai router.delete() dengan preserveScroll: true
- Tombol "Lanjut Bayar" pakai router.post() ke route checkout.process
- Tombol bayar harus disabled dan tampilkan loading state saat sedang proses
- Kalau user belum login, tombol bayar redirect ke halaman login dulu
- Pakai AppLayout dan Tailwind CSS
Cara review hasilnya:
router.delete()danrouter.post()harus importrouterdari@inertiajs/vue3, bukan dari package lain.- Loading state perlu
ref(false)yang di-settruesaat tombol diklik dan kembalifalsekalau ada error. AI kadang generate tanpa reset state saat error, sehingga tombol tetap disabled selamanya kalau checkout gagal. - Cek bahwa tombol "Lanjut Bayar" tidak bisa diklik kalau cart kosong — tambahkan
:disabled="items.length === 0 || isLoading".
Hasil generate (sudah divalidasi):
<!-- resources/js/Pages/Cart/Index.vue -->
<script setup>
import { ref } from 'vue'
import { router, Link } from '@inertiajs/vue3'
import AppLayout from '@/Layouts/AppLayout.vue'
const props = defineProps({
items: Array,
total: Number,
formattedTotal: String,
})
const isLoading = ref(false)
const removeItem = (productId) => {
router.delete(route('cart.remove', productId), {
preserveScroll: true,
})
}
const checkout = () => {
isLoading.value = true
router.post(route('checkout.process'), {}, {
onError: () => {
// Reset loading state kalau ada error
isLoading.value = false
},
})
}
</script>
<template>
<AppLayout>
<head title="Cart" />
<h1 class="text-2xl font-bold text-gray-900 mb-8">Cart Kamu</h1>
<!-- Empty state -->
<div v-if="items.length === 0" class="text-center py-20">
<svg class="w-16 h-16 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M3 3h2l.4 2M7 13h10l4-4H5.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>
<p class="text-gray-500 mb-4">Cart kamu masih kosong.</p>
<Link :href="route('products.index')"
class="text-indigo-600 hover:underline font-medium">
Lihat produk →
</Link>
</div>
<!-- Cart dengan items -->
<div v-else class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- List items -->
<div class="lg:col-span-2 space-y-4">
<div v-for="item in items" :key="item.product_id"
class="bg-white rounded-xl border border-gray-100 p-4 flex gap-4 items-center">
<!-- Thumbnail -->
<div class="w-16 h-16 rounded-lg bg-gray-100 overflow-hidden flex-shrink-0">
<img v-if="item.thumbnail" :src="item.thumbnail" :alt="item.name"
class="w-full h-full object-cover" />
<div v-else class="w-full h-full flex items-center justify-center">
<svg class="w-6 h-6 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
</svg>
</div>
</div>
<!-- Info -->
<div class="flex-1 min-w-0">
<p class="font-medium text-gray-900 truncate">{{ item.name }}</p>
<p class="text-indigo-600 font-bold mt-1">{{ item.formatted_price }}</p>
</div>
<!-- Hapus -->
<button @click="removeItem(item.product_id)"
class="text-gray-400 hover:text-red-500 transition-colors p-1">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
<!-- Summary -->
<div class="lg:col-span-1">
<div class="bg-white rounded-xl border border-gray-100 p-6 sticky top-6">
<h2 class="font-bold text-gray-900 mb-4">Ringkasan</h2>
<div class="flex justify-between text-sm text-gray-600 mb-2">
<span>{{ items.length }} produk</span>
<span>{{ formattedTotal }}</span>
</div>
<div class="border-t border-gray-100 my-4" />
<div class="flex justify-between font-bold text-gray-900 mb-6">
<span>Total</span>
<span class="text-indigo-600">{{ formattedTotal }}</span>
</div>
<button
@click="checkout"
:disabled="isLoading"
class="w-full bg-indigo-600 text-white py-3 rounded-xl font-semibold
hover:bg-indigo-700 transition-colors disabled:opacity-50
disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
<svg v-if="isLoading" class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/>
</svg>
{{ isLoading ? 'Memproses...' : 'Lanjut Bayar' }}
</button>
<p class="text-xs text-gray-400 text-center mt-3">
Kamu akan diarahkan ke halaman pembayaran Xendit
</p>
</div>
</div>
</div>
</AppLayout>
</template>
Order Success Page
Setelah bayar, Xendit redirect user ke orders.success. Di sini kita tampilkan konfirmasi dan tombol download.
Prompt yang saya pakai:
Buatkan OrderController dengan dua method:
1. success(Order $order)
- Pastikan order milik auth user, kalau bukan abort 403
- Pastikan status order adalah "paid", kalau belum tampilkan halaman waiting
- Return Inertia render dengan order beserta items dan products-nya
- Sertakan download_url untuk tiap produk: route('products.download', product)
2. failed(Order $order)
- Pastikan order milik auth user
- Return Inertia render dengan order
Buatkan juga Pages/Orders/Success.vue:
- Tampilkan pesan sukses dengan checkmark animation (CSS only)
- List produk yang dibeli dengan tombol download masing-masing
- Tombol download pakai <a :href="download_url"> dengan target="_blank"
- Info: "Link download berlaku untuk X kali unduhan"
- Pakai AppLayout
Cara review hasilnya:
- Order bisa saja belum
paidsaat user di-redirect dari Xendit — ada jeda antara redirect dan webhook masuk. Buatkan halaman "waiting" dengan auto-refresh menggunakan Inertia polling, bukan langsung error. - Pastikan
download_urldi-generate di controller, bukan di Vue. URL yang generate signed URL atau butuh auth logic harus dari server side.
Hasil generate (sudah divalidasi):
<?php
// app/Http/Controllers/OrderController.php
namespace App\\Http\\Controllers;
use App\\Models\\Order;
use Inertia\\Inertia;
class OrderController extends Controller
{
public function success(Order $order)
{
// Pastikan order milik user ini
abort_if($order->user_id !== auth()->id(), 403);
// Order belum paid — mungkin webhook belum masuk
if (!$order->is_paid) {
return Inertia::render('Orders/Waiting', [
'order' => $order->only('id', 'order_number', 'status'),
]);
}
$order->load('items.product');
// Tambahkan download_url untuk tiap produk
$items = $order->items->map(function ($item) {
return [
'product_name' => $item->product->name,
'product_thumb' => $item->product->thumbnail,
'formatted_price'=> $item->product->formatted_price,
'download_url' => route('products.download', $item->product),
'download_limit' => $item->product->download_limit,
];
});
return Inertia::render('Orders/Success', [
'order' => $order->only('order_number', 'formatted_total', 'paid_at'),
'items' => $items,
]);
}
public function failed(Order $order)
{
abort_if($order->user_id !== auth()->id(), 403);
return Inertia::render('Orders/Failed', [
'order' => $order->only('order_number'),
]);
}
}
<!-- resources/js/Pages/Orders/Waiting.vue -->
<!-- Ditampilkan kalau webhook belum masuk saat user di-redirect -->
<script setup>
import { router } from '@inertiajs/vue3'
import AppLayout from '@/Layouts/AppLayout.vue'
defineProps({ order: Object })
// Polling setiap 3 detik — cek apakah order sudah paid
// Inertia 2.0 punya built-in polling
router.poll(3000)
</script>
<template>
<AppLayout>
<div class="text-center py-20">
<div class="animate-spin w-12 h-12 border-4 border-indigo-600 border-t-transparent
rounded-full mx-auto mb-6" />
<h1 class="text-xl font-bold text-gray-900 mb-2">Memverifikasi Pembayaran...</h1>
<p class="text-gray-500">Halaman ini akan otomatis update setelah pembayaran dikonfirmasi.</p>
</div>
</AppLayout>
</template>
<!-- resources/js/Pages/Orders/Success.vue -->
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue'
defineProps({
order: Object,
items: Array,
})
</script>
<template>
<AppLayout>
<head title="Pembayaran Berhasil" />
<div class="max-w-2xl mx-auto">
<!-- Success header -->
<div class="text-center mb-10">
<!-- Checkmark animation (CSS only) -->
<div class="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4
animate-[scale-in_0.3s_ease-out]">
<svg class="w-10 h-10 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7"/>
</svg>
</div>
<h1 class="text-2xl font-bold text-gray-900">Pembayaran Berhasil!</h1>
<p class="text-gray-500 mt-1">Order #{{ order.order_number }} · {{ order.formatted_total }}</p>
</div>
<!-- Download items -->
<div class="space-y-4">
<h2 class="font-semibold text-gray-700 text-sm uppercase tracking-wide">Produk Kamu</h2>
<div v-for="item in items" :key="item.product_name"
class="bg-white rounded-xl border border-gray-100 p-4 flex items-center gap-4">
<div class="w-14 h-14 rounded-lg bg-gray-100 overflow-hidden flex-shrink-0">
<img v-if="item.product_thumb" :src="item.product_thumb" :alt="item.product_name"
class="w-full h-full object-cover" />
</div>
<div class="flex-1 min-w-0">
<p class="font-medium text-gray-900 truncate">{{ item.product_name }}</p>
<p class="text-xs text-gray-400 mt-0.5">
Bisa diunduh {{ item.download_limit }}x
</p>
</div>
<a :href="item.download_url"
class="bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-medium
hover:bg-indigo-700 transition-colors flex items-center gap-2 flex-shrink-0">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
Download
</a>
</div>
</div>
<p class="text-xs text-gray-400 text-center mt-6">
Link download juga dikirim ke email kamu.
</p>
</div>
</AppLayout>
</template>
Cart dan checkout flow sudah lengkap. Satu hal yang cukup subtle tapi penting di bagian ini: halaman Waiting dengan router.poll(3000) — ini memanfaatkan fitur polling bawaan Inertia 2.0 supaya user tidak perlu refresh manual sambil nunggu webhook masuk.
Di bagian selanjutnya kita bangun admin dashboard sederhana untuk manage produk dan pantau orders masuk.
Bagian 6: Admin Dashboard Sederhana
Admin dashboard kita buat pure Inertia — tidak pakai Filament atau package tambahan. Cukup untuk kebutuhan MVP: lihat statistik, manage produk, dan pantau orders masuk.
Admin Middleware dan Routes
Prompt yang saya pakai:
Buatkan AdminMiddleware untuk Laravel 12 di app/Http/Middleware/AdminMiddleware.php.
Logic:
- Kalau user belum login, redirect ke route('login')
- Kalau user sudah login tapi is_admin false, abort 403
- Kalau is_admin true, lanjutkan request
Buatkan juga konfigurasi routes untuk admin di routes/web.php:
- Prefix: /admin
- Name prefix: admin.
- Middleware: auth dan admin (AdminMiddleware)
- Routes:
- GET /admin → AdminController@dashboard → admin.dashboard
- Resource /admin/products → AdminProductController (index, create, store, edit, update, destroy)
- GET /admin/orders → AdminOrderController@index → admin.orders.index
- GET /admin/orders/{order} → AdminOrderController@show → admin.orders.show
Cara review hasilnya:
- Pastikan middleware terdaftar di
bootstrap/app.php— di Laravel 12 middleware alias tidak lagi diKernel.php. AI yang belum update pengetahuannya ke Laravel 12 sering masih generate kode untukKernel.phpyang sudah tidak dipakai. - Pastikan urutan middleware
['auth', 'admin']— auth dulu, baru admin. Kalau terbalik, user yang belum login akan dapat 403 bukan redirect ke halaman login.
Hasil generate (sudah divalidasi):
<?php
// app/Http/Middleware/AdminMiddleware.php
namespace App\\Http\\Middleware;
use Closure;
use Illuminate\\Http\\Request;
class AdminMiddleware
{
public function handle(Request $request, Closure $next)
{
if (!auth()->check()) {
return redirect()->route('login');
}
if (!auth()->user()->is_admin) {
abort(403, 'Akses ditolak.');
}
return $next($request);
}
}
Daftarkan alias di bootstrap/app.php — cara Laravel 12:
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'admin' => \\App\\Http\\Middleware\\AdminMiddleware::class,
]);
})
// routes/web.php — admin routes
Route::middleware(['auth', 'admin'])
->prefix('admin')
->name('admin.')
->group(function () {
Route::get('/', [AdminController::class, 'dashboard'])->name('dashboard');
Route::resource('products', AdminProductController::class);
Route::get('orders', [AdminOrderController::class, 'index'])->name('orders.index');
Route::get('orders/{order}', [AdminOrderController::class, 'show'])->name('orders.show');
});
Tambahkan kolom is_admin ke tabel users:
php artisan make:migration add_is_admin_to_users_table
// migration
$table->boolean('is_admin')->default(false)->after('email');
Dashboard Statistics
Prompt yang saya pakai:
Buatkan AdminController untuk Laravel 12 + Inertia dengan method dashboard().
Data yang perlu ditampilkan:
- stats:
- total_revenue: sum total dari orders yang paid
- total_orders: count orders yang paid
- total_products: count semua products
- revenue_today: sum total orders paid hari ini
- recent_orders: 10 order terbaru dengan relasi user, status apapun
- top_products: 5 produk dengan jumlah order terbanyak
Buatkan juga Pages/Admin/Dashboard.vue:
- Layout menggunakan AdminLayout (buatkan juga AdminLayout.vue dengan sidebar)
- Sidebar: Dashboard, Products, Orders
- 4 stat cards di atas: total revenue, total orders, total products, revenue today
- Tabel recent orders: order number, nama user, total, status badge, tanggal
- Status badge: pending=kuning, paid=hijau, expired=abu, failed=merah
- Top 5 products dalam bentuk simple list dengan rank number
- Pakai Tailwind CSS
Cara review hasilnya:
top_productsbutuhwithCount('orderItems')— pastikan nama relasinya benar sesuai model. Kalau di model namanyaitems()bukanorderItems(), query-nya haruswithCount('items').- Di Vue, pastikan
AdminLayout.vuemenggunakan<Link>dari Inertia bukan<router-link>dari vue-router. - Format currency di Vue sebaiknya pakai computed atau helper function — jangan format langsung di template karena susah di-maintain.
Hasil generate (sudah divalidasi):
<?php
// app/Http/Controllers/Admin/AdminController.php
namespace App\\Http\\Controllers\\Admin;
use App\\Http\\Controllers\\Controller;
use App\\Models\\Order;
use App\\Models\\Product;
use Inertia\\Inertia;
class AdminController extends Controller
{
public function dashboard()
{
return Inertia::render('Admin/Dashboard', [
'stats' => [
'total_revenue' => Order::paid()->sum('total'),
'total_orders' => Order::paid()->count(),
'total_products' => Product::count(),
'revenue_today' => Order::paid()
->whereDate('paid_at', today())
->sum('total'),
],
'recent_orders' => Order::with('user')
->latest()
->take(10)
->get()
->map(fn ($order) => [
'id' => $order->id,
'order_number' => $order->order_number,
'user_name' => $order->user->name,
'total' => $order->formatted_total,
'status' => $order->status,
'created_at' => $order->created_at->format('d M Y, H:i'),
]),
'top_products' => Product::withCount('orderItems')
->orderByDesc('order_items_count')
->take(5)
->get()
->map(fn ($product) => [
'name' => $product->name,
'total_sales' => $product->order_items_count,
'price' => $product->formatted_price,
]),
]);
}
}
<!-- resources/js/Layouts/AdminLayout.vue -->
<script setup>
import { Link, usePage } from '@inertiajs/vue3'
import { computed } from 'vue'
const page = usePage()
const currentRoute = computed(() => page.url)
const navItems = [
{ label: 'Dashboard', href: route('admin.dashboard'), match: '/admin' },
{ label: 'Products', href: route('admin.products.index'), match: '/admin/products' },
{ label: 'Orders', href: route('admin.orders.index'), match: '/admin/orders' },
]
const isActive = (match) => currentRoute.value.startsWith(match)
</script>
<template>
<div class="min-h-screen bg-gray-50 flex">
<!-- Sidebar -->
<aside class="w-56 bg-white border-r border-gray-200 flex flex-col">
<div class="p-6 border-b border-gray-100">
<Link :href="route('home')" class="text-lg font-bold text-indigo-600">
Digital Store
</Link>
<p class="text-xs text-gray-400 mt-1">Admin Panel</p>
</div>
<nav class="p-4 flex-1 space-y-1">
<Link
v-for="item in navItems"
:key="item.href"
:href="item.href"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors"
:class="isActive(item.match)
? 'bg-indigo-50 text-indigo-700 font-medium'
: 'text-gray-600 hover:bg-gray-50'"
>
{{ item.label }}
</Link>
</nav>
<div class="p-4 border-t border-gray-100">
<Link :href="route('logout')" method="post" as="button"
class="text-sm text-gray-400 hover:text-red-500 transition-colors">
Logout
</Link>
</div>
</aside>
<!-- Content -->
<div class="flex-1 flex flex-col min-w-0">
<header class="bg-white border-b border-gray-200 px-8 py-4">
<slot name="header" />
</header>
<main class="p-8 flex-1">
<slot />
</main>
</div>
</div>
</template>
<!-- resources/js/Pages/Admin/Dashboard.vue -->
<script setup>
import AdminLayout from '@/Layouts/AdminLayout.vue'
const props = defineProps({
stats: Object,
recentOrders: Array,
topProducts: Array,
})
const formatRupiah = (value) =>
'Rp ' + Number(value).toLocaleString('id-ID')
const statusConfig = {
paid: { label: 'Paid', class: 'bg-green-100 text-green-700' },
pending: { label: 'Pending', class: 'bg-yellow-100 text-yellow-700' },
expired: { label: 'Expired', class: 'bg-gray-100 text-gray-600' },
failed: { label: 'Failed', class: 'bg-red-100 text-red-700' },
}
</script>
<template>
<AdminLayout>
<template #header>
<h1 class="text-lg font-semibold text-gray-900">Dashboard</h1>
</template>
<!-- Stat cards -->
<div class="grid grid-cols-2 xl:grid-cols-4 gap-4 mb-8">
<div class="bg-white rounded-xl border border-gray-100 p-5">
<p class="text-xs text-gray-500 uppercase tracking-wide">Total Revenue</p>
<p class="text-2xl font-bold text-gray-900 mt-1">
{{ formatRupiah(stats.total_revenue) }}
</p>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-5">
<p class="text-xs text-gray-500 uppercase tracking-wide">Total Orders</p>
<p class="text-2xl font-bold text-gray-900 mt-1">{{ stats.total_orders }}</p>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-5">
<p class="text-xs text-gray-500 uppercase tracking-wide">Total Products</p>
<p class="text-2xl font-bold text-gray-900 mt-1">{{ stats.total_products }}</p>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-5">
<p class="text-xs text-gray-500 uppercase tracking-wide">Revenue Hari Ini</p>
<p class="text-2xl font-bold text-indigo-600 mt-1">
{{ formatRupiah(stats.revenue_today) }}
</p>
</div>
</div>
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
<!-- Recent Orders -->
<div class="xl:col-span-2 bg-white rounded-xl border border-gray-100 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-100">
<h2 class="font-semibold text-gray-900">Order Terbaru</h2>
</div>
<table class="w-full text-sm">
<thead>
<tr class="text-xs text-gray-400 uppercase tracking-wide border-b border-gray-50">
<th class="text-left px-6 py-3">Order</th>
<th class="text-left px-6 py-3">User</th>
<th class="text-right px-6 py-3">Total</th>
<th class="text-center px-6 py-3">Status</th>
<th class="text-right px-6 py-3">Tanggal</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
<tr v-for="order in recentOrders" :key="order.id"
class="hover:bg-gray-50 transition-colors">
<td class="px-6 py-3 font-mono text-xs text-gray-600">
{{ order.order_number }}
</td>
<td class="px-6 py-3 text-gray-900">{{ order.user_name }}</td>
<td class="px-6 py-3 text-right font-medium">{{ order.total }}</td>
<td class="px-6 py-3 text-center">
<span class="px-2 py-1 rounded-full text-xs font-medium"
:class="statusConfig[order.status]?.class">
{{ statusConfig[order.status]?.label ?? order.status }}
</span>
</td>
<td class="px-6 py-3 text-right text-gray-400 text-xs">
{{ order.created_at }}
</td>
</tr>
</tbody>
</table>
</div>
<!-- Top Products -->
<div class="bg-white rounded-xl border border-gray-100 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-100">
<h2 class="font-semibold text-gray-900">Produk Terlaris</h2>
</div>
<div class="divide-y divide-gray-50">
<div v-for="(product, index) in topProducts" :key="product.name"
class="px-6 py-4 flex items-center gap-4">
<span class="text-xl font-bold text-gray-200 w-6 text-center">
{{ index + 1 }}
</span>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">
{{ product.name }}
</p>
<p class="text-xs text-gray-400 mt-0.5">
{{ product.total_sales }} terjual · {{ product.price }}
</p>
</div>
</div>
</div>
</div>
</div>
</AdminLayout>
</template>
Product Management
Prompt yang saya pakai:
Buatkan AdminProductController untuk Laravel 12 + Inertia dengan full CRUD.
Method yang dibutuhkan:
- index(): list semua products dengan pagination 15/halaman, tampilkan is_published status
- create(): return form tambah produk baru
- store(Request $request): validasi dan simpan produk baru
File digital disimpan ke storage/app/private/products/ via Storage::putFile()
Thumbnail disimpan ke storage/app/public/thumbnails/
Auto-generate slug dari name
- edit(Product $product): return form edit
- update(Request $request, Product $product): update data
File dan thumbnail hanya di-replace kalau ada file baru yang diupload
- destroy(Product $product): soft delete
Validasi wajib:
- name: required, max 255
- description: required
- price: required, integer, min 1000
- file: required saat create, file, mimes:zip,pdf,rar, max 102400 (100MB)
- thumbnail: nullable, image, max 2048
Cara review hasilnya:
Storage::putFile('private/products', ...)— pastikan path pakai diskprivatebukanpublic. File digital tidak boleh bisa diakses langsung via URL.- Thumbnail sebaliknya disimpan ke disk
publicsupaya bisa ditampilkan di halaman produk. - Soft delete membutuhkan
SoftDeletestrait di model — verifikasi sudah ada dari bagian 2. - Auto-generate slug harus pastikan unique — kalau nama produk sama, slug bisa collision. Tambahkan angka suffix kalau perlu.
Hasil generate (sudah divalidasi):
<?php
// app/Http/Controllers/Admin/AdminProductController.php
namespace App\\Http\\Controllers\\Admin;
use App\\Http\\Controllers\\Controller;
use App\\Models\\Product;
use Illuminate\\Http\\Request;
use Illuminate\\Support\\Facades\\Storage;
use Illuminate\\Support\\Str;
use Inertia\\Inertia;
class AdminProductController extends Controller
{
public function index()
{
return Inertia::render('Admin/Products/Index', [
'products' => Product::latest()
->paginate(15)
->through(fn ($p) => [
'id' => $p->id,
'name' => $p->name,
'slug' => $p->slug,
'thumbnail' => $p->thumbnail
? Storage::url($p->thumbnail)
: null,
'price' => $p->formatted_price,
'category' => $p->category,
'is_published' => $p->is_published,
]),
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|max:255',
'description' => 'required',
'price' => 'required|integer|min:1000',
'category' => 'nullable|string|max:100',
'file' => 'required|file|mimes:zip,pdf,rar|max:102400',
'thumbnail' => 'nullable|image|max:2048',
]);
// Generate unique slug
$slug = Str::slug($validated['name']);
$count = Product::where('slug', 'like', "{$slug}%")->count();
if ($count > 0) {
$slug = "{$slug}-{$count}";
}
// Simpan file digital ke private storage
$filePath = $request->file('file')
->store('products', 'private');
// Simpan thumbnail ke public storage
$thumbnailPath = $request->hasFile('thumbnail')
? $request->file('thumbnail')->store('thumbnails', 'public')
: null;
Product::create([
...$validated,
'slug' => $slug,
'file_path' => $filePath,
'thumbnail' => $thumbnailPath,
]);
return redirect()->route('admin.products.index')
->with('success', 'Produk berhasil ditambahkan.');
}
public function update(Request $request, Product $product)
{
$validated = $request->validate([
'name' => 'required|max:255',
'description' => 'required',
'price' => 'required|integer|min:1000',
'category' => 'nullable|string|max:100',
'is_published' => 'boolean',
'file' => 'nullable|file|mimes:zip,pdf,rar|max:102400',
'thumbnail' => 'nullable|image|max:2048',
]);
// Replace file kalau ada yang baru diupload
if ($request->hasFile('file')) {
Storage::disk('private')->delete($product->file_path);
$validated['file_path'] = $request->file('file')->store('products', 'private');
}
if ($request->hasFile('thumbnail')) {
if ($product->thumbnail) {
Storage::disk('public')->delete($product->thumbnail);
}
$validated['thumbnail'] = $request->file('thumbnail')->store('thumbnails', 'public');
}
$product->update($validated);
return back()->with('success', 'Produk berhasil diupdate.');
}
public function destroy(Product $product)
{
$product->delete(); // soft delete
return back()->with('success', 'Produk dihapus.');
}
}
Admin dashboard sudah cukup untuk kebutuhan awal. Kalau nanti traffic dan kebutuhan fitur admin makin kompleks — role management, bulk actions, export CSV, audit log — saat itulah worth it untuk migrasi ke Filament 4. Untuk MVP, yang kita punya sekarang sudah lebih dari cukup.
Di bagian selanjutnya kita finishing: security hardening, optimasi performa, dan deployment checklist sebelum toko kamu bisa go live.
Bagian 7: Polish & Deployment
Kode sudah jalan di local. Sekarang kita siapkan supaya aman dan siap naik ke production. Ada tiga area yang perlu diperhatikan: security, performa, dan deployment checklist.
Security Hardening
Prompt yang saya pakai:
Saya punya toko produk digital Laravel 12 yang hampir siap production.
Tolong review dan buatkan implementasi untuk tiga hal keamanan berikut:
1. Rate limiting untuk endpoint download
- Max 10 request per menit per user
- Kalau melebihi, return response yang jelas bukan error 500
2. Pastikan file digital tidak bisa diakses langsung via URL
- Konfigurasi filesystem untuk disk "private"
- Konfirmasi bahwa Storage::download() dari disk private sudah aman
3. Queue untuk email konfirmasi order
- Buatkan SendOrderConfirmationEmail job (queueable)
- Isi email: order number, list produk, link download masing-masing
- Gunakan Laravel Mailable
Tidak perlu install package tambahan.
Cara review hasilnya:
- Pastikan disk
privatediconfig/filesystems.phpsudah ada dengan'visibility' => 'private'dan root di luar folderpublic. AI kadang lupa ini dan langsung pakai disklocalyang root-nya distorage/app— yang sebenarnya sudah benar, tapi perlu dikonfirmasi eksplisit. - Job harus implement
ShouldQueueinterface — kalau tidak,dispatch()akan jalan synchronous dan webhook bisa timeout karena nunggu email terkirim dulu. - Cek bahwa Mailable menggunakan data dari job (bukan re-query), supaya kalau ada delay di queue, data yang dikirim ke email tetap akurat saat order dibuat.
Hasil generate (sudah divalidasi):
// config/filesystems.php — tambahkan disk private kalau belum ada
'disks' => [
// ...
'private' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'visibility' => 'private',
// Tidak ada 'url' — file memang tidak boleh diakses via URL langsung
],
],
<?php
// app/Jobs/SendOrderConfirmationEmail.php
namespace App\\Jobs;
use App\\Mail\\OrderConfirmationMail;
use App\\Models\\Order;
use Illuminate\\Bus\\Queueable;
use Illuminate\\Contracts\\Queue\\ShouldQueue;
use Illuminate\\Foundation\\Bus\\Dispatchable;
use Illuminate\\Queue\\InteractsWithQueue;
use Illuminate\\Queue\\SerializesModels;
use Illuminate\\Support\\Facades\\Mail;
class SendOrderConfirmationEmail implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3; // retry 3x kalau gagal
public int $backoff = 60; // tunggu 60 detik sebelum retry
public function __construct(public Order $order) {}
public function handle(): void
{
$this->order->load('items.product');
Mail::to($this->order->user->email)
->send(new OrderConfirmationMail($this->order));
}
}
<?php
// app/Mail/OrderConfirmationMail.php
namespace App\\Mail;
use App\\Models\\Order;
use Illuminate\\Bus\\Queueable;
use Illuminate\\Mail\\Mailable;
use Illuminate\\Mail\\Mailables\\Content;
use Illuminate\\Mail\\Mailables\\Envelope;
use Illuminate\\Queue\\SerializesModels;
class OrderConfirmationMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(public Order $order) {}
public function envelope(): Envelope
{
return new Envelope(
subject: "Pembayaran Berhasil - Order #{$this->order->order_number}",
);
}
public function content(): Content
{
// Siapkan data download links untuk tiap produk
$items = $this->order->items->map(fn ($item) => [
'name' => $item->product->name,
'download_url' => route('products.download', $item->product),
'download_limit' => $item->product->download_limit,
]);
return new Content(
view: 'emails.order-confirmation',
with: [
'order' => $this->order,
'items' => $items,
'userName' => $this->order->user->name,
],
);
}
}
<!-- resources/views/emails/order-confirmation.blade.php -->
<!DOCTYPE html>
<html>
<body style="font-family: sans-serif; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #4f46e5;">Pembayaran Berhasil! 🎉</h2>
<p>Hei {{ $userName }},</p>
<p>Terima kasih sudah berbelanja. Order <strong>#{{ $order->order_number }}</strong>
senilai <strong>{{ $order->formatted_total }}</strong> sudah dikonfirmasi.</p>
<h3 style="border-bottom: 1px solid #eee; padding-bottom: 8px;">Produk Kamu</h3>
@foreach ($items as $item)
<div style="padding: 12px 0; border-bottom: 1px solid #f5f5f5;">
<p style="margin: 0; font-weight: bold;">{{ $item['name'] }}</p>
<p style="margin: 4px 0; font-size: 13px; color: #666;">
Bisa diunduh {{ $item['download_limit'] }}x
</p>
<a href="{{ $item['download_url'] }}"
style="display: inline-block; margin-top: 8px; padding: 8px 16px;
background: #4f46e5; color: white; text-decoration: none;
border-radius: 6px; font-size: 13px;">
Download
</a>
</div>
@endforeach
<p style="margin-top: 24px; font-size: 13px; color: #999;">
Link download hanya bisa diakses setelah login ke akun kamu.
</p>
</body>
</html>
Jalankan queue worker di local untuk test:
php artisan queue:work
Performance Optimization
Prompt yang saya pakai:
Untuk aplikasi toko digital Laravel 12 saya, buatkan optimasi berikut:
1. Database indexes yang perlu ditambahkan via migration
- Kolom yang sering di-query: products.slug, products.is_published,
orders.xendit_invoice_id, downloads.(user_id + product_id)
2. Caching untuk data yang jarang berubah
- List categories untuk filter produk, cache 1 jam
- Implementasi di ProductController menggunakan Laravel Cache
3. Eager loading yang konsisten
- Identifikasi N+1 query yang mungkin terjadi di ProductController
dan OrderController, tambahkan with() yang tepat
Tulis sebagai migration untuk indexes, dan update code untuk caching
dan eager loading.
Cara review hasilnya:
- Index
(user_id, product_id)di tabel downloads harus composite index dalam satu$table->index(['user_id', 'product_id']), bukan dua index terpisah. Dua index terpisah tidak membantu query yang filter keduanya sekaligus. - Cache key untuk categories sebaiknya include versi atau timestamp kalau admin sering update — atau cukup
Cache::forget('categories')diAdminProductControllersetiap kali ada perubahan produk. - Eager loading di
recent_ordersdi dashboard sudah ada (with('user')), tapi di halaman order detail perlu tambahanwith('items.product').
Hasil generate (sudah divalidasi):
// database/migrations/xxxx_add_performance_indexes.php
public function up(): void
{
// Index untuk products
Schema::table('products', function (Blueprint $table) {
$table->index('slug');
$table->index('is_published');
$table->index(['is_published', 'created_at']); // composite untuk query published + latest
});
// Index untuk orders
Schema::table('orders', function (Blueprint $table) {
$table->index('xendit_invoice_id');
$table->index(['user_id', 'status']); // query "orders milik user ini yang paid"
});
// Index untuk downloads — composite karena query selalu filter keduanya
Schema::table('downloads', function (Blueprint $table) {
$table->index(['user_id', 'product_id']);
});
}
// Update ProductController — tambahkan caching untuk categories
public function index()
{
$category = request('category');
// Cache categories 1 jam — jarang berubah, sering diakses
$categories = Cache::remember('product_categories', 3600, fn () =>
Product::published()->distinct()->pluck('category')->filter()->values()
);
return Inertia::render('Products/Index', [
'featuredProducts' => Product::published()->latest()->take(4)->get(),
'categories' => $categories,
'activeCategory' => $category,
'allProducts' => Inertia::defer(fn () =>
Product::published()
->when($category, fn ($q) => $q->where('category', $category))
->latest()
->paginate(12)
),
]);
}
// AdminProductController — invalidate cache setiap kali produk berubah
public function store(Request $request) {
// ... store logic ...
Cache::forget('product_categories'); // invalidate cache
return redirect()->route('admin.products.index')->with('success', '...');
}
public function update(Request $request, Product $product) {
// ... update logic ...
Cache::forget('product_categories');
return back()->with('success', '...');
}
Deployment Checklist
Prompt yang saya pakai:
Buatkan deployment checklist lengkap untuk aplikasi Laravel 12 + Inertia + Vue
yang akan di-deploy ke VPS (DigitalOcean/Ubuntu) dengan Nginx.
Checklist harus cover:
1. Environment & konfigurasi
2. Xendit setup (switch ke production)
3. Server requirements
4. Build dan optimize commands
5. Queue dan scheduler setup
6. Hal yang sering lupa tapi krusial
Format sebagai checklist yang bisa di-print atau disimpan.
Hasil generate (sudah divalidasi):
╔══════════════════════════════════════════════════════╗
║ DEPLOYMENT CHECKLIST — DIGITAL STORE ║
╚══════════════════════════════════════════════════════╝
□ ENVIRONMENT
□ APP_ENV=production
□ APP_DEBUG=false
□ APP_URL=https://yourdomain.com
□ XENDIT_SECRET_KEY= (production key, bukan test!)
□ XENDIT_WEBHOOK_TOKEN= (sama dengan yang di Xendit Dashboard)
□ QUEUE_CONNECTION=database (atau redis kalau ada)
□ SESSION_DRIVER=file (atau database/redis)
□ MAIL_* — konfigurasi SMTP production
□ XENDIT DASHBOARD
□ Switch ke Live Mode
□ Set webhook URL: <https://yourdomain.com/webhook/xendit>
□ Verify business account (wajib untuk disbursement)
□ Test satu transaksi kecil dengan payment method real
□ SERVER
□ PHP 8.2+ terinstall dengan extension: bcmath, ctype,
curl, json, mbstring, openssl, pdo, tokenizer, xml
□ Composer terinstall
□ Node.js 20+ untuk build assets
□ SSL certificate aktif (Let's Encrypt / Cloudflare)
□ Storage link dibuat: php artisan storage:link
□ BUILD & OPTIMIZE
□ npm run build
□ composer install --optimize-autoloader --no-dev
□ php artisan config:cache
□ php artisan route:cache
□ php artisan view:cache
□ php artisan event:cache
□ QUEUE WORKER (wajib untuk email konfirmasi)
□ Setup Supervisor untuk queue:work
□ Supervisor config di /etc/supervisor/conf.d/digital-store-worker.conf
□ supervisorctl reread && supervisorctl update
□ supervisorctl start digital-store-worker:*
□ SCHEDULER (kalau ada cron jobs)
□ Tambahkan ke crontab: * * * * * cd /path && php artisan schedule:run
□ YANG SERING LUPA
□ chmod -R 775 storage bootstrap/cache
□ chown -R www-data:www-data storage bootstrap/cache
□ php artisan migrate --force (jangan lupa --force di production)
□ Pastikan folder storage/app/private ada dan tidak bisa diakses web
□ Test webhook dengan transaksi real sebelum announce ke public
□ Monitor storage usage — file upload bisa cepat penuh
Supervisor config untuk queue worker:
; /etc/supervisor/conf.d/digital-store-worker.conf
[program:digital-store-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/digital-store/artisan queue:work --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/www/digital-store/storage/logs/worker.log
stopwaitsecs=3600
Nginx config snippet untuk handle upload file besar (100MB):
# /etc/nginx/sites-available/digital-store
server {
listen 443 ssl;
server_name yourdomain.com;
root /var/www/digital-store/public;
# Izinkan upload sampai 110MB (sedikit di atas limit Laravel 100MB)
client_max_body_size 110M;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \\.php$ {
fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
# Timeout untuk upload file besar
fastcgi_read_timeout 300;
}
# Blokir akses langsung ke storage/app
location ~ ^/storage/app {
deny all;
}
}
Satu hal yang paling sering bikin panik saat go live: lupa switch Xendit dari test mode ke live mode. Akibatnya semua pembayaran masuk ke sandbox dan tidak ada uang yang benar-benar diterima. Double check ini sebelum announce ke publik.
Di bagian terakhir kita wrap up semua yang sudah dipelajari dan saya kasih rekomendasi kelas di BuildWithAngga untuk kamu yang mau lanjut ke level berikutnya.
Bagian 8: Apa Selanjutnya?
Kita sudah bangun cukup banyak dari nol. Mari recap dulu apa yang sudah dikuasai, lalu saya kasih arah yang jelas untuk langkah berikutnya.
Yang Sudah Kamu Kuasai
Bukan cuma soal kodenya — yang lebih penting adalah cara kerjanya.
Kamu sekarang tahu bagaimana memecah sebuah fitur menjadi prompt yang spesifik, bagaimana membaca hasil generate AI dan tahu persis bagian mana yang perlu dicek ulang, dan bagaimana menggabungkan output dari beberapa prompt menjadi satu sistem yang kohesif. Itu skill vibe coding yang sesungguhnya.
Dari sisi teknis, project ini sudah cover:
Laravel 12 + Inertia 2.0 + Vue 3 — arsitektur modern yang bisa kamu pakai untuk hampir semua jenis web app. Deferred props untuk performa, prefetching untuk UX yang smooth, polling untuk real-time tanpa WebSocket.
Xendit Payment Integration — dari create invoice, handle webhook dengan idempotency yang benar, sampai secure file delivery. Ini pattern yang sama yang akan kamu pakai di project e-commerce manapun.
Secure Digital Product Delivery — private storage, download tracking, rate limiting. Bukan sekadar simpan file dan kasih link.
Admin Dashboard — tanpa package, pure Inertia, cukup untuk MVP dan bisa di-extend sesuai kebutuhan.
Deployment-ready — queue worker, Nginx config, checklist yang bisa langsung dipakai.
Yang Bisa Dikembangkan Selanjutnya
Project ini adalah fondasi. Dari sini kamu bisa extend ke banyak arah:
Kalau mau monetize lebih agresif: tambahkan sistem affiliate — setiap referral dapat komisi. Atau bundle produk — jual beberapa item sekaligus dengan harga lebih murah.
Kalau mau scale ke lebih banyak seller: ubah jadi multi-vendor marketplace — seller bisa daftar dan upload produk mereka sendiri, kamu ambil platform fee.
Kalau mau recurring revenue: tambahkan subscription/membership — user bayar bulanan untuk akses semua produk atau kategori tertentu.
Semua extension ini butuh pemahaman yang lebih dalam tentang Laravel, arsitektur aplikasi, dan payment flow. Dan di sinilah BuildWithAngga bisa membantu.
Belajar Lebih Dalam di BuildWithAngga
BuildWithAngga punya 900.000+ students dan ratusan kelas yang dirancang spesifik untuk developer Indonesia — dari yang baru mulai sampai yang sudah kerja dan mau naik level.
Kalau kamu merasa ada gap setelah baca artikel ini — misalnya bagian Eloquent relationships masih bingung, atau Vue 3 Composition API belum terlalu nyaman — mulai dari kelas gratis dulu:
| Kelas Gratis | Yang Kamu Dapat |
|---|---|
| Laravel Fundamental | MVC, Eloquent, Auth dari nol |
| Vue.js Fundamental | Composition API, Components, reactivity |
| JavaScript Fundamentals | ES6+, async/await, array methods |
| Tailwind CSS | Utility-first, responsive design |
| SQL for Beginners | Query, JOIN, database design |
Kalau sudah comfortable dengan dasar-dasarnya dan mau bangun project yang lebih kompleks dan production-ready, kelas premium BWA dirancang untuk itu:
| Kelas Premium | Cocok Untuk |
|---|---|
| Full-Stack Laravel + Inertia + Vue | Bangun app seperti yang kita buat di artikel ini, lebih dalam |
| Laravel E-Commerce Complete | Payment, shipping, inventory, multi-payment gateway |
| Laravel API Development | Backend untuk mobile app atau frontend terpisah |
| SaaS dengan Laravel | Subscription, multi-tenancy, billing |
┌─────────────────────────────────────────────────────────────┐
│ BENEFIT KELAS PREMIUM BWA │
├─────────────────────────────────────────────────────────────┤
│ │
│ 📦 PROJECT PORTFOLIO-READY │
│ Source code lengkap dari nol sampai deploy │
│ Bisa langsung dipakai atau dijual ke klien │
│ │
│ ♾️ AKSES SEUMUR HIDUP │
│ Beli sekali, akses selamanya │
│ Update materi gratis │
│ Termasuk semua materi bonus │
│ │
│ 👨🏫 KONSULTASI MENTOR │
│ Tanya langsung via forum diskusi │
│ Code review dari praktisi │
│ Career guidance │
│ │
│ 📜 SERTIFIKAT RESMI │
│ Bukti kompetensi yang bisa di-share │
│ LinkedIn-ready │
│ Nilai plus untuk CV dan portofolio │
│ │
│ 👥 KOMUNITAS 900.000+ STUDENTS │
│ Networking sesama developer Indonesia │
│ Info lowongan dan project freelance │
│ Sharing pengalaman real dari dunia kerja │
│ │
└─────────────────────────────────────────────────────────────┘
Penutup
Vibe coding bukan shortcut untuk menghindari belajar. Justru sebaliknya — kamu perlu paham cukup banyak untuk bisa menulis prompt yang tepat, review hasilnya dengan kritis, dan tahu kapan hasil generate AI perlu dikoreksi.
Yang berubah adalah cara kamu belajar dan bekerja. Boilerplate yang dulu makan waktu berjam-jam sekarang bisa selesai dalam menit. Waktu yang tersisa bisa kamu pakai untuk hal yang lebih penting: memahami arsitektur, menjaga kualitas kode, dan yang paling menarik — actually shipping produk.
Toko digital yang kita bangun di artikel ini bukan contoh fiktif. Dengan stack dan pattern yang sama, kamu bisa launch versi pertama dalam beberapa hari, terima pembayaran nyata, dan iterate dari feedback user nyata.
Itu yang lebih penting dari teknologi apapun yang kita pakai.
Angga Risky Setiawan Founder, BuildWithAngga