Integrasi payment gateway adalah fitur essential untuk aplikasi e-commerce dan Point of Sales modern. Midtrans adalah salah satu payment gateway terpopuler di Indonesia dengan dukungan berbagai metode pembayaran โ dari transfer bank, e-wallet, hingga kartu kredit. Di tutorial ini, kita akan membangun aplikasi POS sederhana dengan Filament 4 dan Laravel, kemudian mengintegrasikan Midtrans Snap untuk pembayaran online. Kamu akan belajar mulai dari setup project, database design, membuat interface kasir, generate Snap token, hingga handling webhook untuk update status pembayaran otomatis.
Bagian 1: Intro & Persiapan
Apa itu Midtrans?
Midtrans adalah payment gateway Indonesia yang menyediakan infrastruktur pembayaran online. Dengan satu integrasi, aplikasi kamu bisa menerima berbagai metode pembayaran:
METODE PEMBAYARAN MIDTRANS:
๐ณ Kartu Kredit/Debit
โโโ Visa, Mastercard, JCB, Amex
๐ฆ Bank Transfer
โโโ BCA, BNI, BRI, Mandiri, Permata
โโโ Virtual Account otomatis
๐ฑ E-Wallet
โโโ GoPay, ShopeePay, OVO, DANA, LinkAja
๐ช Convenience Store
โโโ Indomaret, Alfamart
๐ฐ Paylater
โโโ Akulaku, Kredivo
Flow Pembayaran Midtrans Snap
Midtrans Snap adalah solusi pembayaran yang menampilkan popup untuk user memilih metode bayar. Ini flow-nya:
SNAP PAYMENT FLOW:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ โ
โ 1. User checkout di aplikasi โ
โ โ โ
โ โผ โ
โ 2. Backend request Snap Token ke Midtrans API โ
โ โ โ
โ โผ โ
โ 3. Midtrans return Snap Token โ
โ โ โ
โ โผ โ
โ 4. Frontend tampilkan Snap Popup (pakai token) โ
โ โ โ
โ โผ โ
โ 5. User pilih metode bayar & selesaikan pembayaran โ
โ โ โ
โ โผ โ
โ 6. Midtrans kirim webhook ke server kita โ
โ โ โ
โ โผ โ
โ 7. Backend update status order berdasarkan webhook โ
โ โ โ
โ โผ โ
โ 8. User redirect ke halaman sukses/pending โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Sandbox vs Production
Midtrans menyediakan dua environment:
ENVIRONMENT:
๐งช SANDBOX (Testing)
โโโ Gratis, tidak ada biaya
โโโ Transaksi tidak real
โโโ Untuk development & testing
โโโ URL: dashboard.sandbox.midtrans.com
โโโ Keys berbeda dari production
๐ PRODUCTION (Live)
โโโ Transaksi real dengan uang asli
โโโ Ada fee per transaksi
โโโ Butuh verifikasi bisnis
โโโ URL: dashboard.midtrans.com
โโโ Jangan pakai untuk testing!
Di tutorial ini kita pakai Sandbox untuk testing.
Daftar Akun Midtrans Sandbox
Step 1: Buat Akun
- Buka https://dashboard.sandbox.midtrans.com
- Klik "Sign Up" atau "Daftar"
- Isi form: email, password, nama bisnis
- Verifikasi email yang dikirim Midtrans
- Login ke dashboard
Step 2: Dapatkan API Keys
Setelah login ke dashboard Sandbox:
- Klik menu Settings di sidebar
- Pilih Access Keys
- Kamu akan lihat 3 keys:
API KEYS:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Merchant ID : G123456789 โ
โ Client Key : SB-Mid-client-xxxxxxxxxx โ
โ Server Key : SB-Mid-server-xxxxxxxxxx โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ ๏ธ PENTING:
โโโ Client Key โ Untuk frontend (boleh public)
โโโ Server Key โ Untuk backend (RAHASIA!)
โโโ Jangan pernah expose Server Key di frontend/GitHub!
Copy ketiga keys ini, kita akan pakai di langkah selanjutnya.
Step 3: Setup Notification URL (Nanti)
Notification URL (webhook) akan kita setup setelah aplikasi jadi. Untuk development, kita akan pakai ngrok untuk expose localhost.
Bagian 2: Setup Project
Install Laravel 12
# Buat project baru
composer create-project laravel/laravel pos-midtrans
# Masuk ke folder
cd pos-midtrans
Setup Database
Untuk simple, kita pakai SQLite:
# Edit .env
# Ubah DB_CONNECTION=sqlite
# Hapus/comment DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD
Buat file database:
# Mac/Linux
touch database/database.sqlite
# Windows PowerShell
New-Item database/database.sqlite -ItemType File
Install Filament 4
# Install Filament
composer require filament/filament:"^4.0"
# Setup panel admin
php artisan filament:install --panels
# Ketik: admin
Install Midtrans PHP Library
composer require midtrans/midtrans-php
Jalankan Migration & Buat User
# Migrate
php artisan migrate
# Buat user admin
php artisan make:filament-user
# Name: Admin
# Email: [email protected]
# Password: password
Konfigurasi Environment
Edit file .env dan tambahkan konfigurasi Midtrans:
# Midtrans Configuration
MIDTRANS_MERCHANT_ID=G123456789
MIDTRANS_CLIENT_KEY=SB-Mid-client-xxxxxxxxxx
MIDTRANS_SERVER_KEY=SB-Mid-server-xxxxxxxxxx
MIDTRANS_IS_PRODUCTION=false
MIDTRANS_IS_SANITIZED=true
MIDTRANS_IS_3DS=true
Ganti value dengan keys dari dashboard Midtrans Sandbox kamu.
Buat Config File Midtrans
Buat file config/midtrans.php:
<?php
return [
/*
|--------------------------------------------------------------------------
| Midtrans Merchant ID
|--------------------------------------------------------------------------
*/
'merchant_id' => env('MIDTRANS_MERCHANT_ID'),
/*
|--------------------------------------------------------------------------
| Midtrans Client Key (untuk frontend)
|--------------------------------------------------------------------------
*/
'client_key' => env('MIDTRANS_CLIENT_KEY'),
/*
|--------------------------------------------------------------------------
| Midtrans Server Key (untuk backend - RAHASIA!)
|--------------------------------------------------------------------------
*/
'server_key' => env('MIDTRANS_SERVER_KEY'),
/*
|--------------------------------------------------------------------------
| Production Mode
|--------------------------------------------------------------------------
| Set true untuk production, false untuk sandbox
*/
'is_production' => env('MIDTRANS_IS_PRODUCTION', false),
/*
|--------------------------------------------------------------------------
| Sanitization
|--------------------------------------------------------------------------
| Sanitize input untuk keamanan
*/
'is_sanitized' => env('MIDTRANS_IS_SANITIZED', true),
/*
|--------------------------------------------------------------------------
| 3D Secure
|--------------------------------------------------------------------------
| Enable 3DS untuk kartu kredit
*/
'is_3ds' => env('MIDTRANS_IS_3DS', true),
/*
|--------------------------------------------------------------------------
| Snap URL
|--------------------------------------------------------------------------
| URL untuk load Snap.js
*/
'snap_url' => env('MIDTRANS_IS_PRODUCTION', false)
? '<https://app.midtrans.com/snap/snap.js>'
: '<https://app.sandbox.midtrans.com/snap/snap.js>',
];
Buat Midtrans Service (Helper)
Buat file app/Services/MidtransService.php:
<?php
namespace App\\Services;
use Midtrans\\Config;
use Midtrans\\Snap;
class MidtransService
{
public function __construct()
{
Config::$serverKey = config('midtrans.server_key');
Config::$isProduction = config('midtrans.is_production');
Config::$isSanitized = config('midtrans.is_sanitized');
Config::$is3ds = config('midtrans.is_3ds');
}
/**
* Generate Snap Token untuk pembayaran
*/
public function createSnapToken(array $params): string
{
return Snap::getSnapToken($params);
}
/**
* Generate Snap Redirect URL
*/
public function createSnapUrl(array $params): string
{
return Snap::createTransaction($params)->redirect_url;
}
}
Test Setup
Jalankan server dan pastikan semuanya working:
php artisan serve
Buka http://localhost:8000/admin dan login dengan credentials yang tadi dibuat.
CHECKPOINT โ
โ๏ธ Laravel 12 terinstall
โ๏ธ Filament 4 terinstall
โ๏ธ Database SQLite ready
โ๏ธ Package midtrans/midtrans-php terinstall
โ๏ธ Config midtrans.php dibuat
โ๏ธ MidtransService helper dibuat
โ๏ธ Environment variables di-set
โ๏ธ Bisa login ke admin panel
Bagian 3: Database Design
ERD Point of Sales
Ini struktur database untuk aplikasi POS kita:
DATABASE DESIGN:
โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโ
โ categories โ โ products โ
โโโโโโโโโโโโโโโโโโโค โโโโโโโโโโโโโโโโโโโค
โ id โโโโโโ โ id โ
โ name โ โ โ category_id โโโโโโโ
โ slug โ โโโโโโโบโ name โ
โ is_active โ โ sku โ
โ timestamps โ โ price โ
โโโโโโโโโโโโโโโโโโโ โ stock โ
โ image โ
โ is_active โ
โ timestamps โ
โโโโโโโโโโโโโโโโโโโ
โ
โ (referenced by)
โผ
โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโ
โ orders โ โ order_items โ
โโโโโโโโโโโโโโโโโโโค โโโโโโโโโโโโโโโโโโโค
โ id โโโโโโ โ id โ
โ order_number โ โ โ order_id โโโโโโโ
โ customer_name โ โโโโโโโบโ product_id โโโโโบ products
โ customer_email โ โ product_name โ
โ customer_phone โ โ quantity โ
โ subtotal โ โ price โ
โ tax โ โ subtotal โ
โ total โ โ timestamps โ
โ status โ โโโโโโโโโโโโโโโโโโโ
โ payment_method โ
โ snap_token โ
โ midtrans_order_idโ
โ paid_at โ
โ timestamps โ
โโโโโโโโโโโโโโโโโโโ
RELASI:
โโโ Category hasMany Products
โโโ Product belongsTo Category
โโโ Order hasMany OrderItems
โโโ OrderItem belongsTo Order
โโโ OrderItem belongsTo Product
Migration 1: Categories
php artisan make:migration create_categories_table
Edit file migration:
<?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->boolean('is_active')->default(true);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('categories');
}
};
Migration 2: Products
php artisan make:migration 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('sku')->unique();
$table->decimal('price', 12, 2);
$table->integer('stock')->default(0);
$table->string('image')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('products');
}
};
Migration 3: Orders
php artisan make:migration 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 information
$table->string('customer_name');
$table->string('customer_email')->nullable();
$table->string('customer_phone')->nullable();
// Order amounts
$table->decimal('subtotal', 12, 2);
$table->decimal('tax', 12, 2)->default(0);
$table->decimal('total', 12, 2);
// Payment status & info
$table->enum('status', [
'pending', // Menunggu pembayaran
'paid', // Sudah dibayar
'failed', // Pembayaran gagal
'expired', // Kadaluarsa
'refunded', // Dikembalikan
])->default('pending');
$table->string('payment_method')->nullable();
$table->string('snap_token')->nullable();
$table->string('midtrans_order_id')->nullable();
$table->timestamp('paid_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('orders');
}
};
Penjelasan field penting:
order_numberโ Nomor order untuk display (ORD-20250112-XXXXXX)midtrans_order_idโ ID unik untuk Midtrans (harus unique per transaksi)snap_tokenโ Token untuk menampilkan Snap popupstatusโ Status pembayaran yang akan di-update via webhookpaid_atโ Timestamp kapan pembayaran berhasil
Migration 4: Order Items
php artisan make:migration 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()->cascadeOnDelete();
// Snapshot data (harga & nama saat transaksi)
$table->string('product_name');
$table->integer('quantity');
$table->decimal('price', 12, 2);
$table->decimal('subtotal', 12, 2);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('order_items');
}
};
Kenapa snapshot product_name dan price?
Karena harga produk bisa berubah sewaktu-waktu. Kita simpan harga saat transaksi supaya history akurat.
Jalankan Migration
php artisan migrate
Model: Category
php artisan make:model Category
Edit app/Models/Category.php:
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class Category extends Model
{
protected $fillable = [
'name',
'slug',
'is_active',
];
protected $casts = [
'is_active' => 'boolean',
];
public function products(): HasMany
{
return $this->hasMany(Product::class);
}
}
Model: Product
php artisan make:model Product
Edit app/Models/Product.php:
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class Product extends Model
{
protected $fillable = [
'category_id',
'name',
'sku',
'price',
'stock',
'image',
'is_active',
];
protected $casts = [
'price' => 'decimal:2',
'is_active' => 'boolean',
];
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
public function orderItems(): HasMany
{
return $this->hasMany(OrderItem::class);
}
}
Model: Order
php artisan make:model Order
Edit app/Models/Order.php:
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class Order extends Model
{
protected $fillable = [
'order_number',
'customer_name',
'customer_email',
'customer_phone',
'subtotal',
'tax',
'total',
'status',
'payment_method',
'snap_token',
'midtrans_order_id',
'paid_at',
];
protected $casts = [
'subtotal' => 'decimal:2',
'tax' => 'decimal:2',
'total' => 'decimal:2',
'paid_at' => 'datetime',
];
public function items(): HasMany
{
return $this->hasMany(OrderItem::class);
}
/**
* Generate unique order number
*/
public static function generateOrderNumber(): string
{
$date = date('Ymd');
$random = strtoupper(substr(uniqid(), -6));
return "ORD-{$date}-{$random}";
}
/**
* Generate unique Midtrans order ID
*/
public static function generateMidtransOrderId(): string
{
return 'MID-' . time() . '-' . strtoupper(substr(uniqid(), -6));
}
/**
* Check if order is paid
*/
public function isPaid(): bool
{
return $this->status === 'paid';
}
/**
* Check if order is pending
*/
public function isPending(): bool
{
return $this->status === 'pending';
}
}
Model: OrderItem
php artisan make:model OrderItem
Edit app/Models/OrderItem.php:
<?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',
'product_name',
'quantity',
'price',
'subtotal',
];
protected $casts = [
'price' => 'decimal:2',
'subtotal' => 'decimal:2',
];
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
}
Verifikasi Database
Cek apakah semua sudah benar:
php artisan tinker
>>> Schema::hasTable('categories')
=> true
>>> Schema::hasTable('products')
=> true
>>> Schema::hasTable('orders')
=> true
>>> Schema::hasTable('order_items')
=> true
>>> exit
CHECKPOINT โ
โ๏ธ 4 migration files dibuat
โ๏ธ Migration berhasil dijalankan
โ๏ธ 4 model dibuat:
โโโ Category (hasMany products)
โโโ Product (belongsTo category)
โโโ Order (hasMany items, helper methods)
โโโ OrderItem (belongsTo order & product)
โ๏ธ Helper methods untuk generate order number
โ๏ธ Snapshot fields untuk harga & nama produk
Database siap! Selanjutnya kita akan isi dengan data dummy menggunakan Seeder.
Bagian 4: Seeder Data Dummy
Database sudah siap, sekarang isi dengan data produk untuk testing.
CategorySeeder
php artisan make:seeder CategorySeeder
<?php
namespace Database\\Seeders;
use App\\Models\\Category;
use Illuminate\\Database\\Seeder;
class CategorySeeder extends Seeder
{
public function run(): void
{
$categories = [
['name' => 'Makanan', 'slug' => 'makanan'],
['name' => 'Minuman', 'slug' => 'minuman'],
['name' => 'Snack', 'slug' => 'snack'],
['name' => 'Dessert', 'slug' => 'dessert'],
];
foreach ($categories as $category) {
Category::create([
'name' => $category['name'],
'slug' => $category['slug'],
'is_active' => true,
]);
}
}
}
ProductSeeder
php artisan make:seeder ProductSeeder
<?php
namespace Database\\Seeders;
use App\\Models\\Product;
use App\\Models\\Category;
use Illuminate\\Database\\Seeder;
class ProductSeeder extends Seeder
{
public function run(): void
{
$products = [
// Makanan
['category' => 'Makanan', 'name' => 'Nasi Goreng Spesial', 'sku' => 'MKN001', 'price' => 25000, 'stock' => 50],
['category' => 'Makanan', 'name' => 'Mie Goreng Jawa', 'sku' => 'MKN002', 'price' => 22000, 'stock' => 50],
['category' => 'Makanan', 'name' => 'Ayam Geprek Sambal Matah', 'sku' => 'MKN003', 'price' => 23000, 'stock' => 30],
['category' => 'Makanan', 'name' => 'Nasi Ayam Bakar', 'sku' => 'MKN004', 'price' => 28000, 'stock' => 25],
['category' => 'Makanan', 'name' => 'Soto Ayam', 'sku' => 'MKN005', 'price' => 20000, 'stock' => 35],
['category' => 'Makanan', 'name' => 'Gado-Gado', 'sku' => 'MKN006', 'price' => 18000, 'stock' => 30],
// Minuman
['category' => 'Minuman', 'name' => 'Es Teh Manis', 'sku' => 'MNM001', 'price' => 5000, 'stock' => 100],
['category' => 'Minuman', 'name' => 'Es Jeruk Segar', 'sku' => 'MNM002', 'price' => 8000, 'stock' => 100],
['category' => 'Minuman', 'name' => 'Kopi Susu Gula Aren', 'sku' => 'MNM003', 'price' => 18000, 'stock' => 50],
['category' => 'Minuman', 'name' => 'Jus Alpukat', 'sku' => 'MNM004', 'price' => 15000, 'stock' => 30],
['category' => 'Minuman', 'name' => 'Es Campur', 'sku' => 'MNM005', 'price' => 12000, 'stock' => 40],
['category' => 'Minuman', 'name' => 'Teh Tarik', 'sku' => 'MNM006', 'price' => 10000, 'stock' => 60],
// Snack
['category' => 'Snack', 'name' => 'Kentang Goreng', 'sku' => 'SNK001', 'price' => 15000, 'stock' => 40],
['category' => 'Snack', 'name' => 'Cireng Isi Ayam', 'sku' => 'SNK002', 'price' => 12000, 'stock' => 50],
['category' => 'Snack', 'name' => 'Pisang Goreng Coklat', 'sku' => 'SNK003', 'price' => 10000, 'stock' => 40],
['category' => 'Snack', 'name' => 'Tahu Crispy', 'sku' => 'SNK004', 'price' => 8000, 'stock' => 60],
['category' => 'Snack', 'name' => 'Risol Mayo', 'sku' => 'SNK005', 'price' => 7000, 'stock' => 50],
// Dessert
['category' => 'Dessert', 'name' => 'Es Krim Vanilla', 'sku' => 'DST001', 'price' => 12000, 'stock' => 30],
['category' => 'Dessert', 'name' => 'Pudding Coklat', 'sku' => 'DST002', 'price' => 10000, 'stock' => 25],
['category' => 'Dessert', 'name' => 'Brownies', 'sku' => 'DST003', 'price' => 15000, 'stock' => 20],
['category' => 'Dessert', 'name' => 'Klepon', 'sku' => 'DST004', 'price' => 8000, 'stock' => 40],
];
foreach ($products as $product) {
$category = Category::where('name', $product['category'])->first();
if ($category) {
Product::create([
'category_id' => $category->id,
'name' => $product['name'],
'sku' => $product['sku'],
'price' => $product['price'],
'stock' => $product['stock'],
'is_active' => true,
]);
}
}
}
}
Update DatabaseSeeder
<?php
namespace Database\\Seeders;
use Illuminate\\Database\\Seeder;
class DatabaseSeeder extends Seeder
{
public function run(): void
{
$this->call([
CategorySeeder::class,
ProductSeeder::class,
]);
}
}
Jalankan Seeder
php artisan migrate:fresh --seed
Buat ulang user admin:
php artisan make:filament-user
# Name: Admin
# Email: [email protected]
# Password: password
Verifikasi
php artisan tinker
>>> App\\Models\\Category::count()
=> 4
>>> App\\Models\\Product::count()
=> 21
>>> exit
Data siap! โ
Bagian 5: Filament Resources
CategoryResource
php artisan make:filament-resource Category --generate
Edit app/Filament/Resources/CategoryResource.php:
<?php
namespace App\\Filament\\Resources;
use App\\Filament\\Resources\\CategoryResource\\Pages;
use App\\Models\\Category;
use Filament\\Forms;
use Filament\\Forms\\Form;
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 ?string $navigationLabel = 'Kategori';
protected static ?int $navigationSort = 1;
public static function form(Form $form): Form
{
return $form
->schema([
Forms\\Components\\Section::make()
->schema([
Forms\\Components\\TextInput::make('name')
->label('Nama Kategori')
->required()
->maxLength(255)
->live(onBlur: true)
->afterStateUpdated(function (string $operation, $state, Forms\\Set $set) {
if ($operation !== 'create') return;
$set('slug', Str::slug($state));
}),
Forms\\Components\\TextInput::make('slug')
->label('Slug')
->required()
->maxLength(255)
->unique(ignoreRecord: true),
Forms\\Components\\Toggle::make('is_active')
->label('Aktif')
->default(true),
])->columns(2),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\\Columns\\TextColumn::make('name')
->label('Nama')
->searchable()
->sortable(),
Tables\\Columns\\TextColumn::make('slug')
->label('Slug')
->searchable(),
Tables\\Columns\\TextColumn::make('products_count')
->label('Produk')
->counts('products')
->badge()
->color('primary'),
Tables\\Columns\\IconColumn::make('is_active')
->label('Aktif')
->boolean(),
Tables\\Columns\\TextColumn::make('created_at')
->label('Dibuat')
->dateTime('d M Y')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\\Filters\\TernaryFilter::make('is_active')
->label('Status Aktif'),
])
->actions([
Tables\\Actions\\EditAction::make(),
Tables\\Actions\\DeleteAction::make(),
])
->bulkActions([
Tables\\Actions\\BulkActionGroup::make([
Tables\\Actions\\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [];
}
public static function getPages(): array
{
return [
'index' => Pages\\ListCategories::route('/'),
'create' => Pages\\CreateCategory::route('/create'),
'edit' => Pages\\EditCategory::route('/{record}/edit'),
];
}
}
ProductResource
php artisan make:filament-resource Product --generate
Edit app/Filament/Resources/ProductResource.php:
<?php
namespace App\\Filament\\Resources;
use App\\Filament\\Resources\\ProductResource\\Pages;
use App\\Models\\Product;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
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 ?string $navigationLabel = 'Produk';
protected static ?int $navigationSort = 2;
public static function form(Form $form): Form
{
return $form
->schema([
Forms\\Components\\Section::make('Informasi Produk')
->schema([
Forms\\Components\\Select::make('category_id')
->label('Kategori')
->relationship('category', 'name')
->required()
->searchable()
->preload(),
Forms\\Components\\TextInput::make('name')
->label('Nama Produk')
->required()
->maxLength(255),
Forms\\Components\\TextInput::make('sku')
->label('SKU')
->required()
->unique(ignoreRecord: true)
->maxLength(50),
Forms\\Components\\Toggle::make('is_active')
->label('Aktif')
->default(true),
])->columns(2),
Forms\\Components\\Section::make('Harga & Stok')
->schema([
Forms\\Components\\TextInput::make('price')
->label('Harga')
->required()
->numeric()
->prefix('Rp')
->minValue(0),
Forms\\Components\\TextInput::make('stock')
->label('Stok')
->required()
->numeric()
->default(0)
->minValue(0),
])->columns(2),
Forms\\Components\\Section::make('Gambar')
->schema([
Forms\\Components\\FileUpload::make('image')
->label('Foto Produk')
->image()
->directory('products')
->maxSize(2048)
->imageEditor(),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\\Columns\\ImageColumn::make('image')
->label('Foto')
->circular()
->defaultImageUrl(fn () => '<https://ui-avatars.com/api/?name=P&background=e2e8f0>'),
Tables\\Columns\\TextColumn::make('name')
->label('Nama')
->searchable()
->sortable()
->description(fn (Product $record) => $record->sku),
Tables\\Columns\\TextColumn::make('category.name')
->label('Kategori')
->sortable()
->badge()
->color('gray'),
Tables\\Columns\\TextColumn::make('price')
->label('Harga')
->money('IDR')
->sortable(),
Tables\\Columns\\TextColumn::make('stock')
->label('Stok')
->sortable()
->badge()
->color(fn (int $state): string => match (true) {
$state <= 10 => 'danger',
$state <= 30 => 'warning',
default => 'success',
}),
Tables\\Columns\\IconColumn::make('is_active')
->label('Aktif')
->boolean(),
])
->defaultSort('name')
->filters([
Tables\\Filters\\SelectFilter::make('category')
->relationship('category', 'name')
->label('Kategori')
->preload(),
Tables\\Filters\\TernaryFilter::make('is_active')
->label('Status Aktif'),
Tables\\Filters\\Filter::make('low_stock')
->label('Stok Rendah (โค10)')
->query(fn ($query) => $query->where('stock', '<=', 10)),
])
->actions([
Tables\\Actions\\EditAction::make(),
Tables\\Actions\\DeleteAction::make(),
])
->bulkActions([
Tables\\Actions\\BulkActionGroup::make([
Tables\\Actions\\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [];
}
public static function getPages(): array
{
return [
'index' => Pages\\ListProducts::route('/'),
'create' => Pages\\CreateProduct::route('/create'),
'edit' => Pages\\EditProduct::route('/{record}/edit'),
];
}
}
Test Resources
Buka browser http://localhost:8000/admin:
CHECKPOINT โ
โ๏ธ Menu "Master Data" muncul di sidebar
โ๏ธ Kategori: 4 data tampil dengan products count
โ๏ธ Produk: 21 data tampil dengan kategori badge
โ๏ธ Filter stok rendah working
โ๏ธ CRUD working untuk keduanya
Selanjutnya kita akan bikin halaman Kasir untuk interface POS dan integrasi Midtrans.
Bagian 6: Halaman Kasir - POS Interface
Sekarang bagian seru โ bikin interface kasir untuk memilih produk dan checkout.
Buat Custom Filament Page
php artisan make:filament-page Cashier
Edit Cashier Page
Edit app/Filament/Pages/Cashier.php:
<?php
namespace App\\Filament\\Pages;
use App\\Models\\Order;
use App\\Models\\Product;
use Filament\\Pages\\Page;
use Filament\\Notifications\\Notification;
use Illuminate\\Support\\Str;
use Livewire\\Attributes\\Computed;
class Cashier extends Page
{
protected static ?string $navigationIcon = 'heroicon-o-shopping-cart';
protected static ?string $navigationLabel = 'Kasir';
protected static ?int $navigationSort = 0;
protected static string $view = 'filament.pages.cashier';
// Cart state
public array $cart = [];
// Customer info
public string $customerName = '';
public string $customerEmail = '';
public string $customerPhone = '';
// Category filter
public ?int $selectedCategory = null;
public function mount(): void
{
$this->cart = session('pos_cart', []);
}
#[Computed]
public function products()
{
$query = Product::where('is_active', true)
->where('stock', '>', 0)
->with('category');
if ($this->selectedCategory) {
$query->where('category_id', $this->selectedCategory);
}
return $query->orderBy('name')->get();
}
#[Computed]
public function categories()
{
return \\App\\Models\\Category::where('is_active', true)
->orderBy('name')
->get();
}
public function filterByCategory(?int $categoryId): void
{
$this->selectedCategory = $categoryId;
}
public function addToCart(int $productId): void
{
$product = Product::find($productId);
if (!$product || !$product->is_active) {
Notification::make()
->title('Produk tidak tersedia')
->danger()
->send();
return;
}
$cartKey = (string) $productId;
if (isset($this->cart[$cartKey])) {
// Cek stok sebelum tambah
if ($this->cart[$cartKey]['quantity'] >= $product->stock) {
Notification::make()
->title('Stok tidak mencukupi')
->body("Stok tersedia: {$product->stock}")
->warning()
->send();
return;
}
$this->cart[$cartKey]['quantity']++;
} else {
$this->cart[$cartKey] = [
'id' => $product->id,
'name' => $product->name,
'price' => (float) $product->price,
'quantity' => 1,
'stock' => $product->stock,
];
}
$this->updateCartSession();
Notification::make()
->title('Ditambahkan ke keranjang')
->success()
->duration(1000)
->send();
}
public function incrementQuantity(int $productId): void
{
$cartKey = (string) $productId;
if (!isset($this->cart[$cartKey])) return;
if ($this->cart[$cartKey]['quantity'] >= $this->cart[$cartKey]['stock']) {
Notification::make()
->title('Stok tidak mencukupi')
->warning()
->send();
return;
}
$this->cart[$cartKey]['quantity']++;
$this->updateCartSession();
}
public function decrementQuantity(int $productId): void
{
$cartKey = (string) $productId;
if (!isset($this->cart[$cartKey])) return;
$this->cart[$cartKey]['quantity']--;
if ($this->cart[$cartKey]['quantity'] <= 0) {
unset($this->cart[$cartKey]);
}
$this->updateCartSession();
}
public function removeFromCart(int $productId): void
{
$cartKey = (string) $productId;
unset($this->cart[$cartKey]);
$this->updateCartSession();
}
public function clearCart(): void
{
$this->cart = [];
$this->customerName = '';
$this->customerEmail = '';
$this->customerPhone = '';
session()->forget('pos_cart');
}
private function updateCartSession(): void
{
session(['pos_cart' => $this->cart]);
}
#[Computed]
public function subtotal(): float
{
return collect($this->cart)->sum(fn ($item) => $item['price'] * $item['quantity']);
}
#[Computed]
public function tax(): float
{
return $this->subtotal * 0.11; // PPN 11%
}
#[Computed]
public function total(): float
{
return $this->subtotal + $this->tax;
}
#[Computed]
public function cartCount(): int
{
return collect($this->cart)->sum('quantity');
}
}
Buat Blade View
Buat file resources/views/filament/pages/cashier.blade.php:
<x-filament-panels::page>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
{{-- Product Grid (2 columns on large screen) --}}
<div class="lg:col-span-2 space-y-4">
{{-- Category Filter --}}
<div class="flex flex-wrap gap-2">
<button
wire:click="filterByCategory(null)"
@class([
'px-4 py-2 rounded-lg text-sm font-medium transition',
'bg-primary-600 text-white' => !$this->selectedCategory,
'bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600' => $this->selectedCategory,
])
>
Semua
</button>
@foreach($this->categories as $category)
<button
wire:click="filterByCategory({{ $category->id }})"
@class([
'px-4 py-2 rounded-lg text-sm font-medium transition',
'bg-primary-600 text-white' => $this->selectedCategory === $category->id,
'bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600' => $this->selectedCategory !== $category->id,
])
>
{{ $category->name }}
</button>
@endforeach
</div>
{{-- Products Grid --}}
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-4">
@forelse($this->products as $product)
<div
wire:click="addToCart({{ $product->id }})"
wire:key="product-{{ $product->id }}"
class="cursor-pointer bg-white dark:bg-gray-800 rounded-xl shadow-sm hover:shadow-md transition-all duration-200 overflow-hidden border border-gray-200 dark:border-gray-700 hover:border-primary-500"
>
{{-- Product Image --}}
<div class="aspect-square bg-gray-100 dark:bg-gray-700 relative">
@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">
<x-heroicon-o-photo class="w-12 h-12" />
</div>
@endif
{{-- Stock Badge --}}
<div class="absolute top-2 right-2">
<span @class([
'px-2 py-1 text-xs font-medium rounded-full',
'bg-green-100 text-green-800' => $product->stock > 10,
'bg-yellow-100 text-yellow-800' => $product->stock <= 10 && $product->stock > 0,
])>
{{ $product->stock }}
</span>
</div>
</div>
{{-- Product Info --}}
<div class="p-3">
<h3 class="font-medium text-sm text-gray-900 dark:text-white truncate">
{{ $product->name }}
</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ $product->category->name }}
</p>
<p class="mt-1 text-primary-600 dark:text-primary-400 font-bold">
Rp {{ number_format($product->price, 0, ',', '.') }}
</p>
</div>
</div>
@empty
<div class="col-span-full text-center py-12 text-gray-500">
<x-heroicon-o-cube class="w-12 h-12 mx-auto mb-2" />
<p>Tidak ada produk tersedia</p>
</div>
@endforelse
</div>
</div>
{{-- Cart Sidebar --}}
<div class="lg:col-span-1">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 sticky top-4">
{{-- Cart Header --}}
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold flex items-center gap-2">
<x-heroicon-o-shopping-cart class="w-5 h-5" />
Keranjang
@if($this->cartCount > 0)
<span class="bg-primary-600 text-white text-xs px-2 py-0.5 rounded-full">
{{ $this->cartCount }}
</span>
@endif
</h2>
@if(count($cart) > 0)
<button
wire:click="clearCart"
wire:confirm="Hapus semua item dari keranjang?"
class="text-red-500 hover:text-red-700 text-sm"
>
Hapus Semua
</button>
@endif
</div>
</div>
{{-- Cart Items --}}
<div class="p-4 max-h-[400px] overflow-y-auto">
@forelse($cart as $item)
<div wire:key="cart-{{ $item['id'] }}" class="flex gap-3 py-3 border-b border-gray-100 dark:border-gray-700 last:border-0">
<div class="flex-1 min-w-0">
<h4 class="font-medium text-sm truncate">{{ $item['name'] }}</h4>
<p class="text-xs text-gray-500">
Rp {{ number_format($item['price'], 0, ',', '.') }}
</p>
</div>
<div class="flex items-center gap-2">
<button
wire:click="decrementQuantity({{ $item['id'] }})"
class="w-7 h-7 rounded-full bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center"
>
<x-heroicon-o-minus class="w-4 h-4" />
</button>
<span class="w-8 text-center font-medium">{{ $item['quantity'] }}</span>
<button
wire:click="incrementQuantity({{ $item['id'] }})"
class="w-7 h-7 rounded-full bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center"
>
<x-heroicon-o-plus class="w-4 h-4" />
</button>
</div>
<div class="text-right">
<p class="font-medium text-sm">
Rp {{ number_format($item['price'] * $item['quantity'], 0, ',', '.') }}
</p>
<button
wire:click="removeFromCart({{ $item['id'] }})"
class="text-red-500 hover:text-red-700 text-xs"
>
Hapus
</button>
</div>
</div>
@empty
<div class="text-center py-8 text-gray-500">
<x-heroicon-o-shopping-cart class="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>Keranjang kosong</p>
<p class="text-xs">Klik produk untuk menambahkan</p>
</div>
@endforelse
</div>
{{-- Cart Summary --}}
@if(count($cart) > 0)
<div class="p-4 border-t border-gray-200 dark:border-gray-700 space-y-3">
<div class="flex justify-between text-sm">
<span class="text-gray-500">Subtotal</span>
<span>Rp {{ number_format($this->subtotal, 0, ',', '.') }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-500">PPN (11%)</span>
<span>Rp {{ number_format($this->tax, 0, ',', '.') }}</span>
</div>
<div class="flex justify-between text-lg font-bold pt-2 border-t border-gray-200 dark:border-gray-700">
<span>Total</span>
<span class="text-primary-600">Rp {{ number_format($this->total, 0, ',', '.') }}</span>
</div>
{{-- Customer Form --}}
<div class="pt-3 space-y-2">
<input
wire:model="customerName"
type="text"
placeholder="Nama Customer *"
class="w-full rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 text-sm"
>
<input
wire:model="customerPhone"
type="text"
placeholder="No. HP (opsional)"
class="w-full rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 text-sm"
>
<input
wire:model="customerEmail"
type="email"
placeholder="Email (opsional)"
class="w-full rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 text-sm"
>
</div>
{{-- Checkout Button --}}
<button
wire:click="checkout"
wire:loading.attr="disabled"
class="w-full py-3 bg-primary-600 hover:bg-primary-700 disabled:bg-gray-400 text-white font-bold rounded-lg transition flex items-center justify-center gap-2"
>
<span wire:loading.remove wire:target="checkout">
<x-heroicon-o-credit-card class="w-5 h-5" />
</span>
<span wire:loading wire:target="checkout">
<x-heroicon-o-arrow-path class="w-5 h-5 animate-spin" />
</span>
<span wire:loading.remove wire:target="checkout">Bayar Sekarang</span>
<span wire:loading wire:target="checkout">Memproses...</span>
</button>
</div>
@endif
</div>
</div>
</div>
{{-- Midtrans Snap Script --}}
@push('scripts')
<script src="{{ config('midtrans.snap_url') }}" data-client-key="{{ config('midtrans.client_key') }}"></script>
<script>
document.addEventListener('livewire:init', () => {
Livewire.on('openSnapPopup', ({ snapToken, orderId }) => {
window.snap.pay(snapToken, {
onSuccess: function(result) {
window.location.href = '/admin/orders/' + orderId + '?payment=success';
},
onPending: function(result) {
window.location.href = '/admin/orders/' + orderId + '?payment=pending';
},
onError: function(result) {
window.location.href = '/admin/orders/' + orderId + '?payment=error';
},
onClose: function() {
Livewire.dispatch('paymentCancelled');
}
});
});
});
</script>
@endpush
</x-filament-panels::page>
Bagian 7: Checkout & Midtrans Snap
Sekarang tambahkan method checkout yang akan generate Snap token dan menampilkan popup pembayaran.
Tambah Method Checkout di Cashier.php
Tambahkan method ini di app/Filament/Pages/Cashier.php:
public function checkout(): void
{
// Validasi cart
if (empty($this->cart)) {
Notification::make()
->title('Keranjang kosong!')
->danger()
->send();
return;
}
// Validasi customer name
if (empty(trim($this->customerName))) {
Notification::make()
->title('Nama customer wajib diisi!')
->danger()
->send();
return;
}
// Validasi stok sekali lagi
foreach ($this->cart as $item) {
$product = Product::find($item['id']);
if (!$product || $product->stock < $item['quantity']) {
Notification::make()
->title('Stok tidak mencukupi')
->body("Produk {$item['name']} stok tersedia: " . ($product->stock ?? 0))
->danger()
->send();
return;
}
}
try {
// Create order
$order = Order::create([
'order_number' => Order::generateOrderNumber(),
'customer_name' => trim($this->customerName),
'customer_email' => $this->customerEmail ?: null,
'customer_phone' => $this->customerPhone ?: null,
'subtotal' => $this->subtotal,
'tax' => $this->tax,
'total' => $this->total,
'status' => 'pending',
'midtrans_order_id' => Order::generateMidtransOrderId(),
]);
// Create order items & decrease stock
foreach ($this->cart as $item) {
$order->items()->create([
'product_id' => $item['id'],
'product_name' => $item['name'],
'quantity' => $item['quantity'],
'price' => $item['price'],
'subtotal' => $item['price'] * $item['quantity'],
]);
// Decrease stock
Product::where('id', $item['id'])->decrement('stock', $item['quantity']);
}
// Generate Snap Token
$snapToken = $this->generateSnapToken($order);
// Save snap token
$order->update(['snap_token' => $snapToken]);
// Clear cart
$this->clearCart();
// Dispatch event to open Snap popup
$this->dispatch('openSnapPopup', snapToken: $snapToken, orderId: $order->id);
} catch (\\Exception $e) {
\\Log::error('Checkout Error: ' . $e->getMessage());
Notification::make()
->title('Terjadi kesalahan')
->body('Silakan coba lagi atau hubungi admin.')
->danger()
->send();
}
}
private function generateSnapToken(Order $order): string
{
// Setup Midtrans configuration
\\Midtrans\\Config::$serverKey = config('midtrans.server_key');
\\Midtrans\\Config::$isProduction = config('midtrans.is_production');
\\Midtrans\\Config::$isSanitized = config('midtrans.is_sanitized');
\\Midtrans\\Config::$is3ds = config('midtrans.is_3ds');
// Build item details
$itemDetails = $order->items->map(fn ($item) => [
'id' => (string) $item->product_id,
'price' => (int) $item->price,
'quantity' => (int) $item->quantity,
'name' => substr($item->product_name, 0, 50), // Max 50 chars
])->toArray();
// Add tax as separate item
if ($order->tax > 0) {
$itemDetails[] = [
'id' => 'TAX',
'price' => (int) $order->tax,
'quantity' => 1,
'name' => 'PPN 11%',
];
}
// Build params
$params = [
'transaction_details' => [
'order_id' => $order->midtrans_order_id,
'gross_amount' => (int) $order->total,
],
'customer_details' => [
'first_name' => $order->customer_name,
'email' => $order->customer_email ?: '[email protected]',
'phone' => $order->customer_phone ?: '08123456789',
],
'item_details' => $itemDetails,
];
return \\Midtrans\\Snap::getSnapToken($params);
}
#[On('paymentCancelled')]
public function handlePaymentCancelled(): void
{
Notification::make()
->title('Pembayaran dibatalkan')
->body('Order tetap tersimpan. Anda bisa melanjutkan pembayaran nanti.')
->warning()
->send();
}
Jangan lupa tambah import di atas file:
use Livewire\\Attributes\\On;
Penjelasan Flow Checkout
CHECKOUT FLOW:
1. User klik "Bayar Sekarang"
โ
โผ
2. Validasi: cart tidak kosong, nama diisi, stok cukup
โ
โผ
3. Create Order di database (status: pending)
โ
โผ
4. Create OrderItems untuk setiap produk di cart
โ
โผ
5. Decrease stock produk
โ
โผ
6. Generate Snap Token via Midtrans API
โ
โผ
7. Simpan snap_token ke order
โ
โผ
8. Clear cart
โ
โผ
9. Dispatch event 'openSnapPopup' ke frontend
โ
โผ
10. JavaScript buka Snap popup dengan token
โ
โผ
11. User pilih metode bayar & selesaikan pembayaran
โ
โผ
12. Callback: onSuccess/onPending/onError/onClose
โ
โผ
13. Redirect ke halaman order detail
Test Checkout
- Buka
http://localhost:8000/admin/cashier - Klik beberapa produk untuk menambah ke cart
- Isi nama customer
- Klik "Bayar Sekarang"
- Popup Midtrans Snap akan muncul
- Pilih metode pembayaran (di Sandbox bisa pakai test card)
Test Card Midtrans Sandbox:
SUCCESS:
โโโ Card Number: 4811 1111 1111 1114
โโโ CVV: 123
โโโ Exp Date: Any future date (e.g., 01/25)
โโโ OTP: 112233
FAILURE:
โโโ Card Number: 4911 1111 1111 1113
โโโ (akan menampilkan error)
Bagian 8: Webhook Handler
Webhook adalah endpoint yang dipanggil Midtrans saat ada update status pembayaran. Ini penting supaya status order di database kita selalu sinkron.
Buat Controller
php artisan make:controller MidtransWebhookController
Edit app/Http/Controllers/MidtransWebhookController.php:
<?php
namespace App\\Http\\Controllers;
use App\\Models\\Order;
use App\\Models\\Product;
use Illuminate\\Http\\Request;
use Illuminate\\Http\\JsonResponse;
use Illuminate\\Support\\Facades\\Log;
class MidtransWebhookController extends Controller
{
public function handle(Request $request): JsonResponse
{
// Log incoming webhook for debugging
Log::info('Midtrans Webhook Received', [
'payload' => $request->all(),
]);
$payload = $request->all();
// Validate required fields
if (!isset($payload['order_id'], $payload['status_code'], $payload['gross_amount'], $payload['signature_key'])) {
Log::warning('Midtrans Webhook: Missing required fields');
return response()->json(['message' => 'Invalid payload'], 400);
}
// Verify signature
$serverKey = config('midtrans.server_key');
$orderId = $payload['order_id'];
$statusCode = $payload['status_code'];
$grossAmount = $payload['gross_amount'];
$signatureKey = $payload['signature_key'];
$expectedSignature = hash('sha512', $orderId . $statusCode . $grossAmount . $serverKey);
if ($signatureKey !== $expectedSignature) {
Log::warning('Midtrans Webhook: Invalid signature', [
'order_id' => $orderId,
'expected' => $expectedSignature,
'received' => $signatureKey,
]);
return response()->json(['message' => 'Invalid signature'], 403);
}
// Find order by midtrans_order_id
$order = Order::where('midtrans_order_id', $orderId)->first();
if (!$order) {
Log::warning('Midtrans Webhook: Order not found', ['order_id' => $orderId]);
return response()->json(['message' => 'Order not found'], 404);
}
// Get transaction status
$transactionStatus = $payload['transaction_status'] ?? null;
$paymentType = $payload['payment_type'] ?? null;
$fraudStatus = $payload['fraud_status'] ?? null;
Log::info('Midtrans Webhook: Processing', [
'order_id' => $orderId,
'transaction_status' => $transactionStatus,
'payment_type' => $paymentType,
'fraud_status' => $fraudStatus,
]);
// Update order based on transaction status
$this->updateOrderStatus($order, $transactionStatus, $paymentType, $fraudStatus);
return response()->json(['message' => 'OK']);
}
private function updateOrderStatus(Order $order, ?string $transactionStatus, ?string $paymentType, ?string $fraudStatus): void
{
// Skip if order already in final state
if (in_array($order->status, ['paid', 'refunded'])) {
Log::info('Midtrans Webhook: Order already in final state', [
'order_id' => $order->midtrans_order_id,
'current_status' => $order->status,
]);
return;
}
switch ($transactionStatus) {
case 'capture':
// For credit card: check fraud status
if ($fraudStatus === 'accept') {
$this->markAsPaid($order, $paymentType);
} elseif ($fraudStatus === 'challenge') {
// Need manual review - keep as pending
$order->update(['payment_method' => $paymentType]);
}
break;
case 'settlement':
// Payment completed (bank transfer, e-wallet, etc.)
$this->markAsPaid($order, $paymentType);
break;
case 'pending':
// Waiting for payment
$order->update([
'status' => 'pending',
'payment_method' => $paymentType,
]);
break;
case 'deny':
// Payment denied
$this->markAsFailed($order);
break;
case 'cancel':
// Payment cancelled
$this->markAsFailed($order);
break;
case 'expire':
// Payment expired
$this->markAsExpired($order);
break;
case 'refund':
case 'partial_refund':
// Refunded
$order->update(['status' => 'refunded']);
break;
}
}
private function markAsPaid(Order $order, ?string $paymentType): void
{
$order->update([
'status' => 'paid',
'payment_method' => $paymentType,
'paid_at' => now(),
]);
Log::info('Order marked as paid', [
'order_id' => $order->midtrans_order_id,
'order_number' => $order->order_number,
]);
}
private function markAsFailed(Order $order): void
{
$order->update(['status' => 'failed']);
$this->restoreStock($order);
Log::info('Order marked as failed, stock restored', [
'order_id' => $order->midtrans_order_id,
]);
}
private function markAsExpired(Order $order): void
{
$order->update(['status' => 'expired']);
$this->restoreStock($order);
Log::info('Order marked as expired, stock restored', [
'order_id' => $order->midtrans_order_id,
]);
}
private function restoreStock(Order $order): void
{
foreach ($order->items as $item) {
Product::where('id', $item->product_id)
->increment('stock', $item->quantity);
}
}
}
Tambah Route
Edit routes/web.php:
<?php
use App\\Http\\Controllers\\MidtransWebhookController;
use Illuminate\\Support\\Facades\\Route;
Route::get('/', function () {
return redirect('/admin');
});
// Midtrans Webhook - exclude from CSRF protection
Route::post('/midtrans/webhook', [MidtransWebhookController::class, 'handle'])
->name('midtrans.webhook')
->withoutMiddleware([\\App\\Http\\Middleware\\VerifyCsrfToken::class]);
โ ๏ธ PENTING: Webhook harus exclude dari CSRF protection karena request datang dari server Midtrans, bukan dari browser user.
Testing Webhook dengan ngrok
Untuk testing di localhost, gunakan ngrok untuk expose server kamu ke internet.
Install ngrok:
# Download dari <https://ngrok.com/download>
# Atau via Homebrew (Mac)
brew install ngrok
Jalankan ngrok:
# Di terminal baru, jalankan
ngrok http 8000
Output:
Session Status online
Forwarding <https://abc123.ngrok.io> -> <http://localhost:8000>
Set Webhook URL di Midtrans:
- Login ke Midtrans Dashboard Sandbox
- Pergi ke Settings โ Configuration
- Di bagian Payment Notification URL, masukkan:
<https://abc123.ngrok.io/midtrans/webhook> - Klik Update
Test Webhook:
- Buat order baru melalui halaman Kasir
- Selesaikan pembayaran di Snap popup
- Cek log Laravel:
tail -f storage/logs/laravel.log - Akan muncul log "Midtrans Webhook Received" dan "Order marked as paid"
Webhook Flow
WEBHOOK FLOW:
User bayar di Snap
โ
โผ
Midtrans proses pembayaran
โ
โผ
Midtrans kirim POST ke /midtrans/webhook
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Webhook Handler โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ 1. Log payload untuk debugging โ
โ 2. Validate signature (SHA512) โ
โ 3. Find order by midtrans_order_id โ
โ 4. Update status based on: โ
โ - capture/settlement โ paid โ
โ - pending โ pending โ
โ - deny/cancel โ failed โ
โ - expire โ expired โ
โ - refund โ refunded โ
โ 5. Restore stock jika gagal/expired โ
โ 6. Return 200 OK โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
Order status updated di database โ
Checkpoint โ
โ๏ธ Halaman Kasir dengan product grid
โ๏ธ Keranjang dengan session storage
โ๏ธ Category filter working
โ๏ธ Quantity +/- working
โ๏ธ Subtotal, PPN, Total calculation
โ๏ธ Checkout create order di database
โ๏ธ Snap Token generated
โ๏ธ Snap popup muncul
โ๏ธ Payment methods tersedia
โ๏ธ Webhook endpoint working
โ๏ธ Signature verification
โ๏ธ Order status update otomatis
โ๏ธ Stock restore on failure/expire
Selanjutnya kita bikin OrderResource untuk management order dan stats widget.
Bagian 9: Order Management
Sekarang bikin resource untuk manage orders โ lihat daftar transaksi, detail order, dan statistik penjualan.
OrderResource
php artisan make:filament-resource Order --generate
Edit app/Filament/Resources/OrderResource.php:
<?php
namespace App\\Filament\\Resources;
use App\\Filament\\Resources\\OrderResource\\Pages;
use App\\Filament\\Resources\\OrderResource\\RelationManagers;
use App\\Models\\Order;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Filament\\Infolists;
use Filament\\Infolists\\Infolist;
class OrderResource extends Resource
{
protected static ?string $model = Order::class;
protected static ?string $navigationIcon = 'heroicon-o-clipboard-document-list';
protected static ?string $navigationLabel = 'Orders';
protected static ?string $navigationGroup = 'Transaksi';
protected static ?int $navigationSort = 1;
public static function form(Form $form): Form
{
return $form
->schema([
Forms\\Components\\Section::make('Informasi Order')
->schema([
Forms\\Components\\TextInput::make('order_number')
->label('No. Order')
->disabled(),
Forms\\Components\\TextInput::make('midtrans_order_id')
->label('Midtrans Order ID')
->disabled(),
Forms\\Components\\Select::make('status')
->label('Status')
->options([
'pending' => 'Pending',
'paid' => 'Paid',
'failed' => 'Failed',
'expired' => 'Expired',
'refunded' => 'Refunded',
])
->disabled(),
Forms\\Components\\TextInput::make('payment_method')
->label('Metode Pembayaran')
->disabled(),
])->columns(2),
Forms\\Components\\Section::make('Customer')
->schema([
Forms\\Components\\TextInput::make('customer_name')
->label('Nama')
->disabled(),
Forms\\Components\\TextInput::make('customer_email')
->label('Email')
->disabled(),
Forms\\Components\\TextInput::make('customer_phone')
->label('No. HP')
->disabled(),
])->columns(3),
Forms\\Components\\Section::make('Pembayaran')
->schema([
Forms\\Components\\TextInput::make('subtotal')
->label('Subtotal')
->prefix('Rp')
->disabled(),
Forms\\Components\\TextInput::make('tax')
->label('PPN')
->prefix('Rp')
->disabled(),
Forms\\Components\\TextInput::make('total')
->label('Total')
->prefix('Rp')
->disabled(),
Forms\\Components\\DateTimePicker::make('paid_at')
->label('Waktu Bayar')
->disabled(),
])->columns(4),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\\Columns\\TextColumn::make('order_number')
->label('No. Order')
->searchable()
->sortable()
->copyable()
->copyMessage('Order number disalin!'),
Tables\\Columns\\TextColumn::make('customer_name')
->label('Customer')
->searchable()
->description(fn (Order $record) => $record->customer_phone ?? '-'),
Tables\\Columns\\TextColumn::make('items_count')
->label('Items')
->counts('items')
->badge()
->color('gray'),
Tables\\Columns\\TextColumn::make('total')
->label('Total')
->money('IDR')
->sortable()
->weight('bold'),
Tables\\Columns\\TextColumn::make('status')
->label('Status')
->badge()
->color(fn (string $state): string => match ($state) {
'pending' => 'warning',
'paid' => 'success',
'failed' => 'danger',
'expired' => 'gray',
'refunded' => 'info',
})
->icon(fn (string $state): string => match ($state) {
'pending' => 'heroicon-o-clock',
'paid' => 'heroicon-o-check-circle',
'failed' => 'heroicon-o-x-circle',
'expired' => 'heroicon-o-exclamation-circle',
'refunded' => 'heroicon-o-arrow-uturn-left',
}),
Tables\\Columns\\TextColumn::make('payment_method')
->label('Metode')
->badge()
->color('primary')
->placeholder('-'),
Tables\\Columns\\TextColumn::make('paid_at')
->label('Waktu Bayar')
->dateTime('d M Y H:i')
->sortable()
->placeholder('-'),
Tables\\Columns\\TextColumn::make('created_at')
->label('Dibuat')
->dateTime('d M Y H:i')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('created_at', 'desc')
->filters([
Tables\\Filters\\SelectFilter::make('status')
->label('Status')
->options([
'pending' => 'Pending',
'paid' => 'Paid',
'failed' => 'Failed',
'expired' => 'Expired',
'refunded' => 'Refunded',
]),
Tables\\Filters\\Filter::make('date_range')
->form([
Forms\\Components\\DatePicker::make('from')
->label('Dari Tanggal'),
Forms\\Components\\DatePicker::make('until')
->label('Sampai Tanggal'),
])
->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));
})
->indicateUsing(function (array $data): array {
$indicators = [];
if ($data['from'] ?? null) {
$indicators['from'] = 'Dari: ' . \\Carbon\\Carbon::parse($data['from'])->format('d M Y');
}
if ($data['until'] ?? null) {
$indicators['until'] = 'Sampai: ' . \\Carbon\\Carbon::parse($data['until'])->format('d M Y');
}
return $indicators;
}),
Tables\\Filters\\Filter::make('paid_today')
->label('Dibayar Hari Ini')
->query(fn ($query) => $query->where('status', 'paid')->whereDate('paid_at', today())),
])
->actions([
Tables\\Actions\\ViewAction::make(),
Tables\\Actions\\Action::make('retry_payment')
->label('Retry Payment')
->icon('heroicon-o-arrow-path')
->color('warning')
->visible(fn (Order $record) => $record->status === 'pending' && $record->snap_token)
->url(fn (Order $record) => route('filament.admin.pages.cashier') . '?retry=' . $record->id)
->openUrlInNewTab(),
Tables\\Actions\\Action::make('mark_expired')
->label('Mark Expired')
->icon('heroicon-o-x-circle')
->color('danger')
->visible(fn (Order $record) => $record->status === 'pending')
->requiresConfirmation()
->modalHeading('Tandai Order Expired?')
->modalDescription('Stock akan dikembalikan. Action ini tidak bisa dibatalkan.')
->action(function (Order $record) {
$record->update(['status' => 'expired']);
// Restore stock
foreach ($record->items as $item) {
\\App\\Models\\Product::where('id', $item->product_id)
->increment('stock', $item->quantity);
}
}),
])
->bulkActions([
Tables\\Actions\\BulkActionGroup::make([
Tables\\Actions\\DeleteBulkAction::make()
->visible(fn () => auth()->user()?->is_admin ?? false),
]),
]);
}
public static function infolist(Infolist $infolist): Infolist
{
return $infolist
->schema([
Infolists\\Components\\Section::make('Informasi Order')
->schema([
Infolists\\Components\\TextEntry::make('order_number')
->label('No. Order')
->copyable(),
Infolists\\Components\\TextEntry::make('midtrans_order_id')
->label('Midtrans ID')
->copyable(),
Infolists\\Components\\TextEntry::make('status')
->label('Status')
->badge()
->color(fn (string $state): string => match ($state) {
'pending' => 'warning',
'paid' => 'success',
'failed' => 'danger',
'expired' => 'gray',
'refunded' => 'info',
}),
Infolists\\Components\\TextEntry::make('payment_method')
->label('Metode Pembayaran')
->placeholder('-'),
])->columns(4),
Infolists\\Components\\Section::make('Customer')
->schema([
Infolists\\Components\\TextEntry::make('customer_name')
->label('Nama'),
Infolists\\Components\\TextEntry::make('customer_email')
->label('Email')
->placeholder('-'),
Infolists\\Components\\TextEntry::make('customer_phone')
->label('No. HP')
->placeholder('-'),
])->columns(3),
Infolists\\Components\\Section::make('Detail Pembayaran')
->schema([
Infolists\\Components\\TextEntry::make('subtotal')
->label('Subtotal')
->money('IDR'),
Infolists\\Components\\TextEntry::make('tax')
->label('PPN (11%)')
->money('IDR'),
Infolists\\Components\\TextEntry::make('total')
->label('Total')
->money('IDR')
->weight('bold')
->size('lg'),
Infolists\\Components\\TextEntry::make('paid_at')
->label('Waktu Bayar')
->dateTime('d M Y H:i:s')
->placeholder('Belum dibayar'),
])->columns(4),
Infolists\\Components\\Section::make('Waktu')
->schema([
Infolists\\Components\\TextEntry::make('created_at')
->label('Dibuat')
->dateTime('d M Y H:i:s'),
Infolists\\Components\\TextEntry::make('updated_at')
->label('Diupdate')
->dateTime('d M Y H:i:s'),
])->columns(2),
]);
}
public static function getRelations(): array
{
return [
RelationManagers\\ItemsRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => Pages\\ListOrders::route('/'),
'view' => Pages\\ViewOrder::route('/{record}'),
];
}
public static function getNavigationBadge(): ?string
{
return static::getModel()::where('status', 'pending')->count() ?: null;
}
public static function getNavigationBadgeColor(): ?string
{
return 'warning';
}
}
Buat View Page
php artisan make:filament-page ViewOrder --resource=OrderResource --type=ViewRecord
Buat Items Relation Manager
php artisan make:filament-relation-manager OrderResource items product_name
Edit app/Filament/Resources/OrderResource/RelationManagers/ItemsRelationManager.php:
<?php
namespace App\\Filament\\Resources\\OrderResource\\RelationManagers;
use Filament\\Resources\\RelationManagers\\RelationManager;
use Filament\\Tables;
use Filament\\Tables\\Table;
class ItemsRelationManager extends RelationManager
{
protected static string $relationship = 'items';
protected static ?string $title = 'Item Pesanan';
public function table(Table $table): Table
{
return $table
->columns([
Tables\\Columns\\TextColumn::make('product_name')
->label('Produk')
->searchable(),
Tables\\Columns\\TextColumn::make('quantity')
->label('Qty')
->alignCenter(),
Tables\\Columns\\TextColumn::make('price')
->label('Harga')
->money('IDR'),
Tables\\Columns\\TextColumn::make('subtotal')
->label('Subtotal')
->money('IDR')
->weight('bold'),
])
->paginated(false);
}
}
Sales Stats Widget
php artisan make:filament-widget SalesStatsWidget --stats-overview
Edit app/Filament/Widgets/SalesStatsWidget.php:
<?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 Carbon\\Carbon;
class SalesStatsWidget extends BaseWidget
{
protected static ?int $sort = 1;
protected function getStats(): array
{
// Today's stats
$todayOrders = Order::whereDate('created_at', today());
$todayPaid = (clone $todayOrders)->where('status', 'paid');
// This month stats
$monthOrders = Order::whereMonth('created_at', now()->month)
->whereYear('created_at', now()->year);
$monthPaid = (clone $monthOrders)->where('status', 'paid');
// Yesterday comparison
$yesterdayRevenue = Order::whereDate('paid_at', today()->subDay())
->where('status', 'paid')
->sum('total');
$todayRevenue = $todayPaid->sum('total');
$revenueChange = $yesterdayRevenue > 0
? round((($todayRevenue - $yesterdayRevenue) / $yesterdayRevenue) * 100, 1)
: ($todayRevenue > 0 ? 100 : 0);
return [
Stat::make('Penjualan Hari Ini', 'Rp ' . number_format($todayRevenue, 0, ',', '.'))
->description($todayPaid->count() . ' transaksi sukses')
->descriptionIcon('heroicon-m-banknotes')
->color('success')
->chart($this->getWeeklyRevenueChart()),
Stat::make('Penjualan Bulan Ini', 'Rp ' . number_format($monthPaid->sum('total'), 0, ',', '.'))
->description($monthPaid->count() . ' transaksi sukses')
->descriptionIcon('heroicon-m-calendar')
->color('primary'),
Stat::make('Pending Payment', Order::where('status', 'pending')->count())
->description('Menunggu pembayaran')
->descriptionIcon('heroicon-m-clock')
->color(Order::where('status', 'pending')->count() > 0 ? 'warning' : 'gray'),
Stat::make('Stok Rendah', Product::where('stock', '<=', 10)->where('is_active', true)->count())
->description('Produk perlu restock')
->descriptionIcon('heroicon-m-exclamation-triangle')
->color(Product::where('stock', '<=', 10)->count() > 0 ? 'danger' : 'success'),
];
}
private function getWeeklyRevenueChart(): array
{
$data = [];
for ($i = 6; $i >= 0; $i--) {
$date = today()->subDays($i);
$revenue = Order::whereDate('paid_at', $date)
->where('status', 'paid')
->sum('total');
$data[] = (int) $revenue;
}
return $data;
}
}
Recent Orders Widget
php artisan make:filament-widget RecentOrdersWidget
Edit app/Filament/Widgets/RecentOrdersWidget.php:
<?php
namespace App\\Filament\\Widgets;
use App\\Models\\Order;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Filament\\Widgets\\TableWidget as BaseWidget;
class RecentOrdersWidget extends BaseWidget
{
protected static ?int $sort = 2;
protected int | string | array $columnSpan = 'full';
protected static ?string $heading = 'Order Terbaru';
public function table(Table $table): Table
{
return $table
->query(
Order::query()->latest()->limit(5)
)
->columns([
Tables\\Columns\\TextColumn::make('order_number')
->label('No. Order')
->searchable(),
Tables\\Columns\\TextColumn::make('customer_name')
->label('Customer'),
Tables\\Columns\\TextColumn::make('total')
->label('Total')
->money('IDR'),
Tables\\Columns\\TextColumn::make('status')
->label('Status')
->badge()
->color(fn (string $state): string => match ($state) {
'pending' => 'warning',
'paid' => 'success',
'failed' => 'danger',
'expired' => 'gray',
'refunded' => 'info',
}),
Tables\\Columns\\TextColumn::make('created_at')
->label('Waktu')
->since(),
])
->actions([
Tables\\Actions\\Action::make('view')
->url(fn (Order $record) => route('filament.admin.resources.orders.view', $record))
->icon('heroicon-o-eye'),
])
->paginated(false);
}
}
Test Order Management
CHECKPOINT โ
โ๏ธ Order list dengan semua kolom
โ๏ธ Status badge dengan warna dan icon
โ๏ธ Filter by status working
โ๏ธ Filter by date range working
โ๏ธ View order detail dengan infolist
โ๏ธ Items relation manager menampilkan produk
โ๏ธ Navigation badge menampilkan pending count
โ๏ธ Stats widget menampilkan penjualan
โ๏ธ Recent orders widget di dashboard
โ๏ธ Mark expired action working
Bagian 10: Penutup
Recap - Apa yang Sudah Dibuat
โ
FITUR LENGKAP POS + MIDTRANS:
๐ฆ Setup & Konfigurasi
โโโ Laravel 12 + Filament 4
โโโ Midtrans PHP package
โโโ Config file untuk credentials
โโโ Service class (opsional)
๐ Database
โโโ Categories (4 kategori)
โโโ Products (21 produk)
โโโ Orders (dengan Midtrans fields)
โโโ Order Items (dengan snapshot)
โโโ Relationships lengkap
๐จ Filament Resources
โโโ CategoryResource (CRUD)
โโโ ProductResource (CRUD + stock badge)
โโโ OrderResource (View + filters + actions)
๐ POS Interface (Halaman Kasir)
โโโ Product grid dengan category filter
โโโ Click-to-add functionality
โโโ Session-based cart
โโโ Quantity increment/decrement
โโโ Stock validation
โโโ Auto calculate subtotal + PPN + total
โโโ Customer form
โโโ Responsive design
๐ณ Midtrans Integration
โโโ Snap Token generation
โโโ Snap popup di frontend
โโโ Multiple payment methods
โโโ Callback handlers (success/pending/error/close)
โโโ Redirect after payment
๐ Webhook Handler
โโโ Signature verification (SHA512)
โโโ Status mapping (capture, settlement, pending, etc.)
โโโ Auto update order status
โโโ Stock restoration on failure/expire
โโโ Comprehensive logging
๐ Dashboard & Reports
โโโ Sales stats widget (hari ini, bulan ini)
โโโ Weekly revenue chart
โโโ Pending payment counter
โโโ Low stock alert
โโโ Recent orders table
Testing di Sandbox
Test Cards:
CREDIT CARD - SUCCESS:
โโโ Number: 4811 1111 1111 1114
โโโ CVV: 123
โโโ Exp: 01/25 (any future)
โโโ OTP: 112233
CREDIT CARD - FAILURE:
โโโ Number: 4911 1111 1111 1113
โโโ (akan declined)
CREDIT CARD - CHALLENGE:
โโโ Number: 4511 1111 1111 1117
โโโ (fraud challenge)
E-Wallet & Bank Transfer:
- Di Sandbox, semua e-wallet dan bank transfer akan generate QR/VA simulasi
- Bisa langsung di-settle dari Midtrans Dashboard Sandbox
Simulate Payment di Dashboard:
- Login ke dashboard.sandbox.midtrans.com
- Pergi ke Transactions
- Cari order yang pending
- Klik Accept untuk simulate settlement
Checklist Go Production
BEFORE GO LIVE:
โ Environment & Credentials
โโโ Ganti ke Production keys di Midtrans
โโโ Update .env: MIDTRANS_IS_PRODUCTION=true
โโโ Update MIDTRANS_CLIENT_KEY (production)
โโโ Update MIDTRANS_SERVER_KEY (production)
โโโ Pastikan server key TIDAK exposed
โ Webhook Configuration
โโโ Update webhook URL ke domain production
โโโ Test webhook dengan real transaction
โโโ Setup monitoring untuk webhook failures
โโโ Handle duplicate webhooks (idempotent)
โ Security
โโโ Enable HTTPS (wajib untuk production)
โโโ Verify signature di setiap webhook
โโโ Rate limiting untuk endpoints
โโโ Input validation
โโโ SQL injection prevention (Eloquent OK)
โ Error Handling
โโโ Proper try-catch di checkout
โโโ User-friendly error messages
โโโ Logging untuk debugging
โโโ Alert untuk critical errors
โ Testing
โโโ Test semua payment methods
โโโ Test failure scenarios
โโโ Test webhook dengan berbagai status
โโโ Load testing (opsional)
โโโ User acceptance testing
โ Monitoring
โโโ Monitor pending orders
โโโ Alert untuk expired orders
โโโ Track conversion rate
โโโ Monitor Midtrans Dashboard
Tips & Best Practices
BEST PRACTICES:
1. Security
โโโ JANGAN pernah expose Server Key
โโโ Selalu verify webhook signature
โโโ Gunakan HTTPS di production
โโโ Sanitize semua user input
2. Reliability
โโโ Simpan snap_token untuk retry
โโโ Handle duplicate webhooks
โโโ Set expiry time untuk pending orders
โโโ Auto-expire old pending orders (cron)
3. User Experience
โโโ Show loading state saat checkout
โโโ Clear error messages
โโโ Retry payment option
โโโ Email notification (opsional)
4. Monitoring
โโโ Log semua webhook requests
โโโ Monitor failed payments
โโโ Track payment success rate
โโโ Alert untuk anomalies
5. Development
โโโ Gunakan Sandbox untuk testing
โโโ Test semua payment methods
โโโ Simulate berbagai scenarios
โโโ Dokumentasikan flow untuk tim
Cron Job untuk Auto-Expire (Opsional)
Tambahkan command untuk auto-expire pending orders:
// app/Console/Commands/ExpirePendingOrders.php
namespace App\\Console\\Commands;
use App\\Models\\Order;
use App\\Models\\Product;
use Illuminate\\Console\\Command;
class ExpirePendingOrders extends Command
{
protected $signature = 'orders:expire-pending';
protected $description = 'Expire pending orders older than 24 hours';
public function handle()
{
$expiredOrders = Order::where('status', 'pending')
->where('created_at', '<', now()->subHours(24))
->get();
foreach ($expiredOrders as $order) {
$order->update(['status' => 'expired']);
// Restore stock
foreach ($order->items as $item) {
Product::where('id', $item->product_id)
->increment('stock', $item->quantity);
}
$this->info("Expired: {$order->order_number}");
}
$this->info("Total expired: {$expiredOrders->count()} orders");
}
}
Schedule di routes/console.php:
use Illuminate\\Support\\Facades\\Schedule;
Schedule::command('orders:expire-pending')->hourly();
Rekomendasi Kelas Gratis BuildWithAngga
๐ KELAS GRATIS UNTUK BELAJAR LEBIH LANJUT:
1. Laravel Payment Gateway
โโโ Deep dive integrasi payment
โโโ Midtrans, Xendit, Stripe
โโโ buildwithangga.com/kelas/laravel-payment-gateway
2. Filament Admin Panel
โโโ Filament lebih lengkap
โโโ Custom pages & widgets
โโโ buildwithangga.com/kelas/filament-admin-panel
3. Laravel E-Commerce
โโโ Build full e-commerce app
โโโ Cart, checkout, payment
โโโ buildwithangga.com/kelas/laravel-ecommerce
4. Laravel API Development
โโโ REST API untuk mobile
โโโ Authentication & authorization
โโโ buildwithangga.com/kelas/laravel-api
5. Laravel Security Best Practices
โโโ Keamanan aplikasi
โโโ Payment security
โโโ buildwithangga.com/kelas/laravel-security
6. Laravel Livewire
โโโ Reactive components
โโโ Real-time features
โโโ buildwithangga.com/kelas/laravel-livewire
Penutup
Integrasi Midtrans dengan Filament memberikan solusi lengkap untuk aplikasi Point of Sales dengan pembayaran online. Dengan setup yang relatif simple, kamu bisa:
- Terima pembayaran dari 20+ metode (bank transfer, e-wallet, kartu kredit)
- Update status otomatis via webhook
- Track semua transaksi di satu dashboard
- Monitor penjualan dengan real-time stats
Project POS ini bisa dikembangkan lebih lanjut:
- Multi-outlet support
- Inventory management
- Customer loyalty program
- Reporting & analytics
- Mobile app dengan API
- Print struk thermal
Selamat coding! ๐
Resources:
- Dokumentasi Midtrans: docs.midtrans.com
- Midtrans Dashboard: dashboard.midtrans.com
- Filament Docs: filamentphp.com/docs
- Kelas Gratis: buildwithangga.com