Bikin Fitur Aman Projek Laravel Integrasi Midtrans Website Sewa Rumah

Pelajari cara mengintegrasikan payment gateway Midtrans di projek Laravel 12 untuk website sewa rumah. Tutorial lengkap mulai dari install Laravel, setup database dengan seeder, implementasi Service Repository Pattern, integrasi Midtrans Snap, testing dengan Ngrok, hingga handle callback dan notifikasi pembayaran. Dilengkapi best practices untuk transaksi yang aman dan reliable.


Bagian 1: Mengenal Midtrans dan Projek Sewa Rumah

Apa itu Midtrans?

Midtrans adalah payment gateway terpopuler di Indonesia yang menyediakan berbagai metode pembayaran dalam satu integrasi. Daripada integrasi manual ke setiap bank dan e-wallet, cukup integrasi sekali ke Midtrans dan semua payment method langsung tersedia.

Payment Methods yang Didukung

METODE PEMBAYARAN MIDTRANS:

๐Ÿ’ณ CREDIT/DEBIT CARD
โ”œโ”€โ”€ Visa
โ”œโ”€โ”€ Mastercard
โ”œโ”€โ”€ JCB
โ””โ”€โ”€ American Express

๐Ÿฆ BANK TRANSFER (Virtual Account)
โ”œโ”€โ”€ BCA Virtual Account
โ”œโ”€โ”€ BNI Virtual Account
โ”œโ”€โ”€ BRI Virtual Account
โ”œโ”€โ”€ Mandiri Bill Payment
โ”œโ”€โ”€ Permata Virtual Account
โ””โ”€โ”€ CIMB Niaga

๐Ÿ“ฑ E-WALLET
โ”œโ”€โ”€ GoPay
โ”œโ”€โ”€ ShopeePay
โ”œโ”€โ”€ DANA
โ”œโ”€โ”€ OVO
โ”œโ”€โ”€ LinkAja
โ””โ”€โ”€ QRIS (semua e-wallet)

๐Ÿช CONVENIENCE STORE
โ”œโ”€โ”€ Indomaret
โ””โ”€โ”€ Alfamart

๐Ÿ’ฐ PAYLATER
โ”œโ”€โ”€ Akulaku
โ”œโ”€โ”€ Kredivo
โ””โ”€โ”€ Indodana

๐Ÿข CARDLESS CREDIT
โ””โ”€โ”€ Akulaku

Dengan satu integrasi, customer kamu bisa bayar pakai metode apapun yang mereka prefer. Ini meningkatkan conversion rate karena tidak ada alasan "payment method tidak tersedia".

Kenapa Pilih Midtrans?

Ada beberapa payment gateway di Indonesia seperti Xendit, Doku, dan Ipaymu. Kenapa Midtrans jadi pilihan populer?

KEUNGGULAN MIDTRANS:

โ”œโ”€โ”€ ๐ŸŽฏ Satu Integrasi untuk Semua
โ”‚   โ””โ”€โ”€ Tidak perlu integrasi terpisah ke setiap bank/e-wallet
โ”‚
โ”œโ”€โ”€ ๐ŸŽจ Snap UI
โ”‚   โ”œโ”€โ”€ Popup payment yang sudah jadi dan responsive
โ”‚   โ”œโ”€โ”€ Tidak perlu design payment page sendiri
โ”‚   โ””โ”€โ”€ User experience yang sudah teruji
โ”‚
โ”œโ”€โ”€ ๐Ÿงช Sandbox Environment
โ”‚   โ”œโ”€โ”€ Testing tanpa uang asli
โ”‚   โ”œโ”€โ”€ Simulate berbagai skenario
โ”‚   โ””โ”€โ”€ Test cards tersedia
โ”‚
โ”œโ”€โ”€ ๐Ÿ”” Webhook Notification
โ”‚   โ”œโ”€โ”€ Real-time payment status update
โ”‚   โ”œโ”€โ”€ Server-to-server communication
โ”‚   โ””โ”€โ”€ Reliable dan secure
โ”‚
โ”œโ”€โ”€ ๐Ÿ“Š Dashboard Lengkap
โ”‚   โ”œโ”€โ”€ Monitor semua transaksi
โ”‚   โ”œโ”€โ”€ Analytics dan reporting
โ”‚   โ””โ”€โ”€ Refund management
โ”‚
โ”œโ”€โ”€ ๐Ÿ”’ Security & Compliance
โ”‚   โ”œโ”€โ”€ PCI DSS certified
โ”‚   โ”œโ”€โ”€ 3D Secure untuk credit card
โ”‚   โ””โ”€โ”€ Fraud detection system
โ”‚
โ”œโ”€โ”€ ๐Ÿ“š Dokumentasi Bahasa Indonesia
โ”‚   โ””โ”€โ”€ Lengkap dan mudah dipahami
โ”‚
โ””โ”€โ”€ ๐Ÿ’ฌ Support Responsive
    โ””โ”€โ”€ Tim support yang helpful

Midtrans Products

Midtrans menyediakan beberapa product untuk berbagai kebutuhan:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ Product         โ”‚ Description                                      โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ SNAP            โ”‚ Popup payment UI yang sudah jadi.                โ”‚
โ”‚                 โ”‚ Integrasi paling simple, cocok untuk             โ”‚
โ”‚                 โ”‚ kebanyakan use case. Ini yang akan kita pakai.   โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ CORE API        โ”‚ Full control atas payment flow dan UI.           โ”‚
โ”‚                 โ”‚ Lebih kompleks, untuk kebutuhan custom.          โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ MOBILE SDK      โ”‚ Native SDK untuk iOS dan Android.                โ”‚
โ”‚                 โ”‚ Untuk mobile app development.                    โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ CMS PLUGINS     โ”‚ Plugin siap pakai untuk WooCommerce,            โ”‚
โ”‚                 โ”‚ Magento, PrestaShop, OpenCart.                   โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ PAYMENT LINK    โ”‚ Generate link pembayaran tanpa integrasi.        โ”‚
โ”‚                 โ”‚ Cocok untuk invoice manual.                      โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Untuk tutorial ini, kita akan menggunakan Snap karena paling straightforward dan cocok untuk web application.

Midtrans Pricing

Midtrans menggunakan model pricing per-transaksi. Tidak ada monthly fee atau setup fee.

FEE STRUCTURE (per transaksi berhasil):

โ”œโ”€โ”€ Credit Card
โ”‚   โ””โ”€โ”€ 2.9% + Rp 2.000
โ”‚
โ”œโ”€โ”€ Bank Transfer (Virtual Account)
โ”‚   โ”œโ”€โ”€ BCA: Rp 4.000
โ”‚   โ”œโ”€โ”€ BNI: Rp 4.000
โ”‚   โ”œโ”€โ”€ BRI: Rp 4.000
โ”‚   โ”œโ”€โ”€ Mandiri: Rp 4.000
โ”‚   โ””โ”€โ”€ Permata: Rp 4.000
โ”‚
โ”œโ”€โ”€ E-Wallet
โ”‚   โ”œโ”€โ”€ GoPay: 2%
โ”‚   โ”œโ”€โ”€ ShopeePay: 1.5%
โ”‚   โ”œโ”€โ”€ DANA: 1.5%
โ”‚   โ””โ”€โ”€ OVO: 1.5%
โ”‚
โ”œโ”€โ”€ QRIS
โ”‚   โ””โ”€โ”€ 0.7%
โ”‚
โ”œโ”€โ”€ Convenience Store
โ”‚   โ”œโ”€โ”€ Indomaret: Rp 5.000
โ”‚   โ””โ”€โ”€ Alfamart: Rp 5.000
โ”‚
โ””โ”€โ”€ Paylater
    โ””โ”€โ”€ Varies by provider

Fee ini dipotong dari amount yang diterima merchant. Jadi kalau customer bayar Rp 1.000.000 via BCA VA, merchant terima Rp 996.000 (dipotong Rp 4.000).

Sandbox vs Production

Midtrans menyediakan dua environment:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ Aspect            โ”‚ SANDBOX             โ”‚ PRODUCTION                  โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ URL Dashboard     โ”‚ app.sandbox.        โ”‚ app.midtrans.com            โ”‚
โ”‚                   โ”‚ midtrans.com        โ”‚                             โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ API URL           โ”‚ app.sandbox.        โ”‚ app.midtrans.com            โ”‚
โ”‚                   โ”‚ midtrans.com/snap   โ”‚ /snap                       โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ Uang Asli         โ”‚ โŒ Tidak            โ”‚ โœ… Ya                       โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ Test Card         โ”‚ โœ… Available        โ”‚ โŒ Real card only           โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ API Keys          โ”‚ Sandbox keys        โ”‚ Production keys             โ”‚
โ”‚                   โ”‚ (SB-xxx)            โ”‚ (Mid-xxx)                   โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ Approval          โ”‚ Instant             โ”‚ Perlu approval (1-3 hari)   โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ Use Case          โ”‚ Development &       โ”‚ Live transactions           โ”‚
โ”‚                   โ”‚ Testing             โ”‚                             โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Selama development, kita akan menggunakan Sandbox. Setelah siap go-live, baru switch ke Production.

Projek yang Akan Dibangun: Website Sewa Rumah

Sekarang kita sudah paham tentang Midtrans. Mari lihat projek yang akan kita bangun: SewaRumah โ€” platform untuk menyewa rumah/properti seperti Airbnb versi sederhana.

SEWARUMAH.COM - FITUR UTAMA:

โ”œโ”€โ”€ ๐Ÿ  PROPERTY LISTING
โ”‚   โ”œโ”€โ”€ Daftar properti tersedia
โ”‚   โ”œโ”€โ”€ Detail properti (foto, amenities, lokasi)
โ”‚   โ”œโ”€โ”€ Harga per malam
โ”‚   โ””โ”€โ”€ Availability calendar
โ”‚
โ”œโ”€โ”€ ๐Ÿ‘ค USER MANAGEMENT
โ”‚   โ”œโ”€โ”€ Register/Login
โ”‚   โ”œโ”€โ”€ Profile management
โ”‚   โ””โ”€โ”€ Booking history
โ”‚
โ”œโ”€โ”€ ๐Ÿ“… BOOKING SYSTEM
โ”‚   โ”œโ”€โ”€ Pilih tanggal check-in dan check-out
โ”‚   โ”œโ”€โ”€ Jumlah tamu
โ”‚   โ”œโ”€โ”€ Perhitungan otomatis (nights ร— price)
โ”‚   โ””โ”€โ”€ Service fee calculation
โ”‚
โ”œโ”€โ”€ ๐Ÿ’ณ PAYMENT (MIDTRANS)
โ”‚   โ”œโ”€โ”€ Midtrans Snap popup
โ”‚   โ”œโ”€โ”€ Multiple payment methods
โ”‚   โ”œโ”€โ”€ Real-time status update
โ”‚   โ””โ”€โ”€ Payment receipt
โ”‚
โ”œโ”€โ”€ ๐Ÿ“ง NOTIFICATIONS
โ”‚   โ”œโ”€โ”€ Email konfirmasi booking
โ”‚   โ”œโ”€โ”€ Email konfirmasi pembayaran
โ”‚   โ””โ”€โ”€ Reminder sebelum check-in
โ”‚
โ””โ”€โ”€ ๐Ÿ“Š DASHBOARD
    โ”œโ”€โ”€ Guest: My bookings
    โ””โ”€โ”€ Owner: Property management

Payment Flow yang Akan Diimplementasi

Ini adalah flow lengkap dari user memilih properti sampai booking terkonfirmasi:

COMPLETE PAYMENT FLOW:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                                                                      โ”‚
โ”‚  STEP 1: BROWSE & SELECT                                            โ”‚
โ”‚  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€                                          โ”‚
โ”‚  User browse properties โ†’ Pilih properti โ†’ Lihat detail             โ”‚
โ”‚                                                                      โ”‚
โ”‚                           โ”‚                                          โ”‚
โ”‚                           โ–ผ                                          โ”‚
โ”‚                                                                      โ”‚
โ”‚  STEP 2: BOOKING FORM                                               โ”‚
โ”‚  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€                                               โ”‚
โ”‚  User isi form:                                                     โ”‚
โ”‚  โ”œโ”€โ”€ Tanggal check-in                                               โ”‚
โ”‚  โ”œโ”€โ”€ Tanggal check-out                                              โ”‚
โ”‚  โ”œโ”€โ”€ Jumlah tamu                                                    โ”‚
โ”‚  โ””โ”€โ”€ Notes (optional)                                               โ”‚
โ”‚                                                                      โ”‚
โ”‚                           โ”‚                                          โ”‚
โ”‚                           โ–ผ                                          โ”‚
โ”‚                                                                      โ”‚
โ”‚  STEP 3: PRICE CALCULATION                                          โ”‚
โ”‚  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€                                          โ”‚
โ”‚  System calculate:                                                  โ”‚
โ”‚  โ”œโ”€โ”€ Nights = check_out - check_in                                  โ”‚
โ”‚  โ”œโ”€โ”€ Subtotal = price_per_night ร— nights                            โ”‚
โ”‚  โ”œโ”€โ”€ Service Fee = subtotal ร— 5%                                    โ”‚
โ”‚  โ””โ”€โ”€ Total = subtotal + service_fee                                 โ”‚
โ”‚                                                                      โ”‚
โ”‚                           โ”‚                                          โ”‚
โ”‚                           โ–ผ                                          โ”‚
โ”‚                                                                      โ”‚
โ”‚  STEP 4: CREATE BOOKING & TRANSACTION                               โ”‚
โ”‚  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€                               โ”‚
โ”‚  Backend process:                                                   โ”‚
โ”‚  โ”œโ”€โ”€ Validate availability                                          โ”‚
โ”‚  โ”œโ”€โ”€ Create booking record (status: pending)                        โ”‚
โ”‚  โ”œโ”€โ”€ Generate order_id                                              โ”‚
โ”‚  โ”œโ”€โ”€ Request Snap token dari Midtrans                               โ”‚
โ”‚  โ””โ”€โ”€ Create transaction record                                      โ”‚
โ”‚                                                                      โ”‚
โ”‚                           โ”‚                                          โ”‚
โ”‚                           โ–ผ                                          โ”‚
โ”‚                                                                      โ”‚
โ”‚  STEP 5: PAYMENT PAGE                                               โ”‚
โ”‚  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€                                               โ”‚
โ”‚  Show payment page dengan:                                          โ”‚
โ”‚  โ”œโ”€โ”€ Booking summary                                                โ”‚
โ”‚  โ”œโ”€โ”€ Price breakdown                                                โ”‚
โ”‚  โ””โ”€โ”€ "Bayar Sekarang" button                                        โ”‚
โ”‚                                                                      โ”‚
โ”‚                           โ”‚                                          โ”‚
โ”‚                           โ–ผ                                          โ”‚
โ”‚                                                                      โ”‚
โ”‚  STEP 6: MIDTRANS SNAP POPUP                                        โ”‚
โ”‚  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€                                        โ”‚
โ”‚  User klik "Bayar Sekarang":                                        โ”‚
โ”‚  โ”œโ”€โ”€ Snap popup muncul                                              โ”‚
โ”‚  โ”œโ”€โ”€ User pilih payment method                                      โ”‚
โ”‚  โ”œโ”€โ”€ User complete payment (transfer/e-wallet/dll)                  โ”‚
โ”‚  โ””โ”€โ”€ Popup close                                                    โ”‚
โ”‚                                                                      โ”‚
โ”‚                           โ”‚                                          โ”‚
โ”‚                           โ–ผ                                          โ”‚
โ”‚                                                                      โ”‚
โ”‚  STEP 7: WEBHOOK NOTIFICATION                                       โ”‚
โ”‚  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€                                       โ”‚
โ”‚  Midtrans kirim notification ke server kita:                        โ”‚
โ”‚  โ”œโ”€โ”€ Verify signature                                               โ”‚
โ”‚  โ”œโ”€โ”€ Update transaction status                                      โ”‚
โ”‚  โ”œโ”€โ”€ Update booking status (pending โ†’ paid)                         โ”‚
โ”‚  โ””โ”€โ”€ Trigger email notification                                     โ”‚
โ”‚                                                                      โ”‚
โ”‚                           โ”‚                                          โ”‚
โ”‚                           โ–ผ                                          โ”‚
โ”‚                                                                      โ”‚
โ”‚  STEP 8: CONFIRMATION                                               โ”‚
โ”‚  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€                                               โ”‚
โ”‚  โ”œโ”€โ”€ Redirect ke success page                                       โ”‚
โ”‚  โ”œโ”€โ”€ Show booking confirmation                                      โ”‚
โ”‚  โ”œโ”€โ”€ Email konfirmasi terkirim                                      โ”‚
โ”‚  โ””โ”€โ”€ DONE! โœ…                                                       โ”‚
โ”‚                                                                      โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Tech Stack

Berikut teknologi yang akan kita gunakan:

TECH STACK:

โ”œโ”€โ”€ BACKEND
โ”‚   โ”œโ”€โ”€ Laravel 12 (latest)
โ”‚   โ”œโ”€โ”€ PHP 8.2+
โ”‚   โ””โ”€โ”€ MySQL 8.0
โ”‚
โ”œโ”€โ”€ PAYMENT
โ”‚   โ”œโ”€โ”€ Midtrans Snap
โ”‚   โ””โ”€โ”€ midtrans/midtrans-php package
โ”‚
โ”œโ”€โ”€ FRONTEND
โ”‚   โ”œโ”€โ”€ Blade templates
โ”‚   โ”œโ”€โ”€ Tailwind CSS
โ”‚   โ””โ”€โ”€ Alpine.js (interactivity)
โ”‚
โ”œโ”€โ”€ ARCHITECTURE
โ”‚   โ”œโ”€โ”€ Service Layer
โ”‚   โ”œโ”€โ”€ Repository Pattern
โ”‚   โ””โ”€โ”€ Clean separation of concerns
โ”‚
โ””โ”€โ”€ TOOLS
    โ”œโ”€โ”€ Ngrok (webhook testing)
    โ”œโ”€โ”€ Mailtrap (email testing)
    โ””โ”€โ”€ Laravel Debugbar

Arsitektur: Service Repository Pattern

Kita akan menggunakan Service Repository Pattern untuk code yang clean dan maintainable:

ARCHITECTURE LAYERS:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                                                                      โ”‚
โ”‚  HTTP REQUEST                                                        โ”‚
โ”‚       โ”‚                                                              โ”‚
โ”‚       โ–ผ                                                              โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”    โ”‚
โ”‚  โ”‚                      CONTROLLER                              โ”‚    โ”‚
โ”‚  โ”‚  - Handle HTTP request/response                              โ”‚    โ”‚
โ”‚  โ”‚  - Validate input                                            โ”‚    โ”‚
โ”‚  โ”‚  - Call service methods                                      โ”‚    โ”‚
โ”‚  โ”‚  - Return response/view                                      โ”‚    โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜    โ”‚
โ”‚       โ”‚                                                              โ”‚
โ”‚       โ–ผ                                                              โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”    โ”‚
โ”‚  โ”‚                      SERVICE LAYER                           โ”‚    โ”‚
โ”‚  โ”‚  - Business logic                                            โ”‚    โ”‚
โ”‚  โ”‚  - Orchestrate multiple repositories                         โ”‚    โ”‚
โ”‚  โ”‚  - Transaction management                                    โ”‚    โ”‚
โ”‚  โ”‚  - External API calls (Midtrans)                            โ”‚    โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜    โ”‚
โ”‚       โ”‚                                                              โ”‚
โ”‚       โ–ผ                                                              โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”    โ”‚
โ”‚  โ”‚                    REPOSITORY LAYER                          โ”‚    โ”‚
โ”‚  โ”‚  - Data access abstraction                                   โ”‚    โ”‚
โ”‚  โ”‚  - Query logic                                               โ”‚    โ”‚
โ”‚  โ”‚  - CRUD operations                                           โ”‚    โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜    โ”‚
โ”‚       โ”‚                                                              โ”‚
โ”‚       โ–ผ                                                              โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”    โ”‚
โ”‚  โ”‚                      MODEL (Eloquent)                        โ”‚    โ”‚
โ”‚  โ”‚  - Database representation                                   โ”‚    โ”‚
โ”‚  โ”‚  - Relationships                                             โ”‚    โ”‚
โ”‚  โ”‚  - Accessors/Mutators                                        โ”‚    โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜    โ”‚
โ”‚       โ”‚                                                              โ”‚
โ”‚       โ–ผ                                                              โ”‚
โ”‚  DATABASE                                                            โ”‚
โ”‚                                                                      โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Kenapa pakai pattern ini?

BENEFITS:

โ”œโ”€โ”€ Separation of Concerns
โ”‚   โ””โ”€โ”€ Setiap layer punya tanggung jawab jelas
โ”‚
โ”œโ”€โ”€ Testable Code
โ”‚   โ””โ”€โ”€ Bisa mock repository untuk unit test service
โ”‚
โ”œโ”€โ”€ Reusable Logic
โ”‚   โ””โ”€โ”€ Service bisa dipanggil dari controller, command, atau job
โ”‚
โ”œโ”€โ”€ Easy to Maintain
โ”‚   โ””โ”€โ”€ Perubahan di satu layer tidak affect layer lain
โ”‚
โ””โ”€โ”€ Scalable
    โ””โ”€โ”€ Mudah extend tanpa refactor besar-besaran

Apa yang Akan Dipelajari

Selama 9 bagian artikel ini, kamu akan belajar:

LEARNING ROADMAP:

โ”œโ”€โ”€ Bagian 1: Intro Midtrans & Project Overview โœ… (sekarang)
โ”‚
โ”œโ”€โ”€ Bagian 2: Install Laravel 12 & Setup Database
โ”‚   โ”œโ”€โ”€ Fresh Laravel installation
โ”‚   โ”œโ”€โ”€ Database configuration
โ”‚   โ””โ”€โ”€ Complete migration files
โ”‚
โ”œโ”€โ”€ Bagian 3: Seeder untuk Data Dummy
โ”‚   โ”œโ”€โ”€ User seeder (guest, owner, admin)
โ”‚   โ”œโ”€โ”€ Property seeder dengan data realistis
โ”‚   โ””โ”€โ”€ Sample bookings dan transactions
โ”‚
โ”œโ”€โ”€ Bagian 4: Repository Pattern Implementation
โ”‚   โ”œโ”€โ”€ Interface contracts
โ”‚   โ”œโ”€โ”€ Eloquent repositories
โ”‚   โ””โ”€โ”€ Service provider binding
โ”‚
โ”œโ”€โ”€ Bagian 5: Service Layer & Business Logic
โ”‚   โ”œโ”€โ”€ BookingService
โ”‚   โ”œโ”€โ”€ PaymentService
โ”‚   โ””โ”€โ”€ MidtransService
โ”‚
โ”œโ”€โ”€ Bagian 6: Integrasi Midtrans Snap
โ”‚   โ”œโ”€โ”€ Midtrans account setup
โ”‚   โ”œโ”€โ”€ Snap token generation
โ”‚   โ””โ”€โ”€ Frontend integration
โ”‚
โ”œโ”€โ”€ Bagian 7: Ngrok & Webhook Testing
โ”‚   โ”œโ”€โ”€ Setup Ngrok
โ”‚   โ”œโ”€โ”€ Configure notification URL
โ”‚   โ””โ”€โ”€ Test webhook locally
โ”‚
โ”œโ”€โ”€ Bagian 8: Handle Callback & Notification
โ”‚   โ”œโ”€โ”€ Webhook controller
โ”‚   โ”œโ”€โ”€ Status mapping
โ”‚   โ””โ”€โ”€ Email notifications
โ”‚
โ””โ”€โ”€ Bagian 9: Production Deployment & Best Practices
    โ”œโ”€โ”€ Switch ke production
    โ”œโ”€โ”€ Security checklist
    โ””โ”€โ”€ Monitoring & maintenance

Prerequisites

Sebelum mulai, pastikan kamu sudah punya:

REQUIREMENTS:

โ”œโ”€โ”€ โœ… PHP 8.2+ terinstall
โ”œโ”€โ”€ โœ… Composer terinstall
โ”œโ”€โ”€ โœ… MySQL/MariaDB running
โ”œโ”€โ”€ โœ… Code editor (VS Code, PHPStorm, dll)
โ”œโ”€โ”€ โœ… Terminal/command line familiarity
โ”œโ”€โ”€ โœ… Basic Laravel knowledge
โ”‚
โ”œโ”€โ”€ ๐Ÿ“ PERLU DAFTAR:
โ”‚   โ”œโ”€โ”€ Akun Midtrans (gratis) - midtrans.com
โ”‚   โ””โ”€โ”€ Akun Ngrok (gratis) - ngrok.com
โ”‚
โ””โ”€โ”€ ๐Ÿ’ก NICE TO HAVE:
    โ”œโ”€โ”€ Akun Mailtrap untuk email testing
    โ””โ”€โ”€ Postman/Insomnia untuk API testing

Daftar Akun Midtrans

Sebelum lanjut ke bagian berikutnya, daftar akun Midtrans dulu:

STEPS:

1. Buka dashboard.midtrans.com
2. Klik "Daftar" atau "Sign Up"
3. Isi form pendaftaran
4. Verify email
5. Login ke dashboard
6. Kamu akan masuk ke Sandbox environment by default

Setelah login, kamu bisa lihat:
โ”œโ”€โ”€ Dashboard overview
โ”œโ”€โ”€ Transactions list
โ”œโ”€โ”€ Settings โ†’ Access Keys (API keys)
โ””โ”€โ”€ Settings โ†’ Configuration (notification URL)

Simpan dulu API keys-nya, kita akan pakai di bagian selanjutnya.


Di bagian selanjutnya, kita akan mulai coding: install Laravel 12 fresh dan setup database schema lengkap untuk projek Sewa Rumah.

Bagian 2: Install Laravel 12 dan Setup Database

Sekarang kita mulai coding. Di bagian ini, kita akan install Laravel 12 fresh dan setup database schema lengkap untuk projek Sewa Rumah.

Install Laravel 12

Buka terminal dan jalankan command berikut:

# Menggunakan Laravel Installer (recommended)
laravel new sewa-rumah

# Atau menggunakan Composer
composer create-project laravel/laravel sewa-rumah

# Masuk ke directory project
cd sewa-rumah

Saat proses instalasi, pilih opsi berikut:

  • Starter kit: None (kita akan setup manual)
  • Testing framework: Pest atau PHPUnit
  • Database: MySQL
  • Run migrations: No (kita akan buat migration sendiri)

Konfigurasi Environment

Buka file .env dan update konfigurasi berikut:

APP_NAME="Sewa Rumah"
APP_ENV=local
APP_KEY=base64:xxxxx  # Sudah auto-generated
APP_DEBUG=true
APP_TIMEZONE=Asia/Jakarta
APP_URL=http://localhost:8000

# Database Configuration
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=sewa_rumah
DB_USERNAME=root
DB_PASSWORD=

# Session & Cache
SESSION_DRIVER=database
CACHE_STORE=database
QUEUE_CONNECTION=database

# Midtrans Configuration (akan diisi nanti)
MIDTRANS_SERVER_KEY=
MIDTRANS_CLIENT_KEY=
MIDTRANS_IS_PRODUCTION=false
MIDTRANS_IS_SANITIZED=true
MIDTRANS_IS_3DS=true
MIDTRANS_NOTIFICATION_URL=

# Mail Configuration (Mailtrap untuk testing)
MAIL_MAILER=smtp
MAIL_HOST=sandbox.smtp.mailtrap.io
MAIL_PORT=2525
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS="[email protected]"
MAIL_FROM_NAME="Sewa Rumah"

Buat Database

Buat database MySQL:

mysql -u root -p

CREATE DATABASE sewa_rumah CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
EXIT;

Konfigurasi Midtrans

Buat file konfigurasi untuk Midtrans:

php artisan make:config midtrans

Atau buat manual file config/midtrans.php:

<?php

return [
    /*
    |--------------------------------------------------------------------------
    | Midtrans Server Key
    |--------------------------------------------------------------------------
    |
    | Server key digunakan untuk server-to-server communication.
    | JANGAN expose key ini di frontend.
    |
    */
    'server_key' => env('MIDTRANS_SERVER_KEY', ''),

    /*
    |--------------------------------------------------------------------------
    | Midtrans Client Key
    |--------------------------------------------------------------------------
    |
    | Client key digunakan di frontend untuk Snap.js
    |
    */
    'client_key' => env('MIDTRANS_CLIENT_KEY', ''),

    /*
    |--------------------------------------------------------------------------
    | Production Mode
    |--------------------------------------------------------------------------
    |
    | Set true untuk production, false untuk sandbox.
    |
    */
    'is_production' => env('MIDTRANS_IS_PRODUCTION', false),

    /*
    |--------------------------------------------------------------------------
    | Sanitization
    |--------------------------------------------------------------------------
    |
    | Set true untuk enable sanitization pada request parameters.
    |
    */
    'is_sanitized' => env('MIDTRANS_IS_SANITIZED', true),

    /*
    |--------------------------------------------------------------------------
    | 3D Secure
    |--------------------------------------------------------------------------
    |
    | Set true untuk enable 3D Secure pada credit card transactions.
    |
    */
    'is_3ds' => env('MIDTRANS_IS_3DS', true),

    /*
    |--------------------------------------------------------------------------
    | Notification URL
    |--------------------------------------------------------------------------
    |
    | URL untuk menerima payment notification dari Midtrans.
    |
    */
    'notification_url' => env('MIDTRANS_NOTIFICATION_URL', ''),

    /*
    |--------------------------------------------------------------------------
    | Snap URL
    |--------------------------------------------------------------------------
    |
    | Base URL untuk Snap.js
    |
    */
    'snap_url' => env('MIDTRANS_IS_PRODUCTION', false)
        ? '<https://app.midtrans.com/snap/snap.js>'
        : '<https://app.sandbox.midtrans.com/snap/snap.js>',
];

Database Schema Design

Sebelum membuat migration, mari pahami dulu schema yang akan kita buat:

DATABASE SCHEMA:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                                                                      โ”‚
โ”‚  USERS                                                               โ”‚
โ”‚  โ”€โ”€โ”€โ”€โ”€โ”€                                                              โ”‚
โ”‚  Primary table untuk semua user (guest, owner, admin)               โ”‚
โ”‚  โ”œโ”€โ”€ id (PK)                                                        โ”‚
โ”‚  โ”œโ”€โ”€ name                                                           โ”‚
โ”‚  โ”œโ”€โ”€ email (unique)                                                 โ”‚
โ”‚  โ”œโ”€โ”€ phone                                                          โ”‚
โ”‚  โ”œโ”€โ”€ password                                                       โ”‚
โ”‚  โ”œโ”€โ”€ role (enum: guest, owner, admin)                              โ”‚
โ”‚  โ”œโ”€โ”€ avatar                                                         โ”‚
โ”‚  โ”œโ”€โ”€ email_verified_at                                              โ”‚
โ”‚  โ””โ”€โ”€ timestamps                                                     โ”‚
โ”‚                                                                      โ”‚
โ”‚  PROPERTIES                                                          โ”‚
โ”‚  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€                                                          โ”‚
โ”‚  Listing rumah/properti yang disewakan                              โ”‚
โ”‚  โ”œโ”€โ”€ id (PK)                                                        โ”‚
โ”‚  โ”œโ”€โ”€ owner_id (FK โ†’ users)                                         โ”‚
โ”‚  โ”œโ”€โ”€ name                                                           โ”‚
โ”‚  โ”œโ”€โ”€ slug (unique)                                                  โ”‚
โ”‚  โ”œโ”€โ”€ description                                                    โ”‚
โ”‚  โ”œโ”€โ”€ address                                                        โ”‚
โ”‚  โ”œโ”€โ”€ city                                                           โ”‚
โ”‚  โ”œโ”€โ”€ province                                                       โ”‚
โ”‚  โ”œโ”€โ”€ postal_code                                                    โ”‚
โ”‚  โ”œโ”€โ”€ latitude                                                       โ”‚
โ”‚  โ”œโ”€โ”€ longitude                                                      โ”‚
โ”‚  โ”œโ”€โ”€ price_per_night (decimal 12,2)                                โ”‚
โ”‚  โ”œโ”€โ”€ max_guests                                                     โ”‚
โ”‚  โ”œโ”€โ”€ bedrooms                                                       โ”‚
โ”‚  โ”œโ”€โ”€ bathrooms                                                      โ”‚
โ”‚  โ”œโ”€โ”€ amenities (JSON)                                               โ”‚
โ”‚  โ”œโ”€โ”€ images (JSON)                                                  โ”‚
โ”‚  โ”œโ”€โ”€ thumbnail                                                      โ”‚
โ”‚  โ”œโ”€โ”€ is_available (boolean)                                         โ”‚
โ”‚  โ””โ”€โ”€ timestamps                                                     โ”‚
โ”‚                                                                      โ”‚
โ”‚  BOOKINGS                                                            โ”‚
โ”‚  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€                                                            โ”‚
โ”‚  Record booking dari guest                                          โ”‚
โ”‚  โ”œโ”€โ”€ id (PK)                                                        โ”‚
โ”‚  โ”œโ”€โ”€ booking_code (unique, e.g., SEWA-ABC123)                      โ”‚
โ”‚  โ”œโ”€โ”€ user_id (FK โ†’ users, guest)                                   โ”‚
โ”‚  โ”œโ”€โ”€ property_id (FK โ†’ properties)                                 โ”‚
โ”‚  โ”œโ”€โ”€ check_in_date                                                  โ”‚
โ”‚  โ”œโ”€โ”€ check_out_date                                                 โ”‚
โ”‚  โ”œโ”€โ”€ guests (jumlah tamu)                                          โ”‚
โ”‚  โ”œโ”€โ”€ nights (calculated)                                            โ”‚
โ”‚  โ”œโ”€โ”€ price_per_night (snapshot harga saat booking)                 โ”‚
โ”‚  โ”œโ”€โ”€ subtotal                                                       โ”‚
โ”‚  โ”œโ”€โ”€ service_fee                                                    โ”‚
โ”‚  โ”œโ”€โ”€ total_price                                                    โ”‚
โ”‚  โ”œโ”€โ”€ status (enum: pending, paid, confirmed,                       โ”‚
โ”‚  โ”‚          completed, cancelled, refunded)                        โ”‚
โ”‚  โ”œโ”€โ”€ notes                                                          โ”‚
โ”‚  โ”œโ”€โ”€ special_requests                                               โ”‚
โ”‚  โ”œโ”€โ”€ paid_at                                                        โ”‚
โ”‚  โ”œโ”€โ”€ confirmed_at                                                   โ”‚
โ”‚  โ”œโ”€โ”€ cancelled_at                                                   โ”‚
โ”‚  โ”œโ”€โ”€ cancel_reason                                                  โ”‚
โ”‚  โ””โ”€โ”€ timestamps                                                     โ”‚
โ”‚                                                                      โ”‚
โ”‚  TRANSACTIONS                                                        โ”‚
โ”‚  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€                                                        โ”‚
โ”‚  Data transaksi dari Midtrans                                       โ”‚
โ”‚  โ”œโ”€โ”€ id (PK)                                                        โ”‚
โ”‚  โ”œโ”€โ”€ booking_id (FK โ†’ bookings)                                    โ”‚
โ”‚  โ”œโ”€โ”€ order_id (unique, Midtrans order_id)                          โ”‚
โ”‚  โ”œโ”€โ”€ transaction_id (Midtrans transaction_id)                      โ”‚
โ”‚  โ”œโ”€โ”€ payment_type (bank_transfer, gopay, credit_card, etc)         โ”‚
โ”‚  โ”œโ”€โ”€ gross_amount                                                   โ”‚
โ”‚  โ”œโ”€โ”€ transaction_status (pending, settlement, etc)                 โ”‚
โ”‚  โ”œโ”€โ”€ fraud_status                                                   โ”‚
โ”‚  โ”œโ”€โ”€ va_number (untuk bank transfer)                               โ”‚
โ”‚  โ”œโ”€โ”€ bank                                                           โ”‚
โ”‚  โ”œโ”€โ”€ transaction_time                                               โ”‚
โ”‚  โ”œโ”€โ”€ settlement_time                                                โ”‚
โ”‚  โ”œโ”€โ”€ expiry_time                                                    โ”‚
โ”‚  โ”œโ”€โ”€ snap_token                                                     โ”‚
โ”‚  โ”œโ”€โ”€ snap_redirect_url                                              โ”‚
โ”‚  โ”œโ”€โ”€ raw_response (JSON, full response dari Midtrans)              โ”‚
โ”‚  โ””โ”€โ”€ timestamps                                                     โ”‚
โ”‚                                                                      โ”‚
โ”‚  PAYMENT_LOGS                                                        โ”‚
โ”‚  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€                                                        โ”‚
โ”‚  Log semua webhook/notification dari Midtrans (audit trail)         โ”‚
โ”‚  โ”œโ”€โ”€ id (PK)                                                        โ”‚
โ”‚  โ”œโ”€โ”€ transaction_id (FK โ†’ transactions, nullable)                  โ”‚
โ”‚  โ”œโ”€โ”€ order_id                                                       โ”‚
โ”‚  โ”œโ”€โ”€ event_type (notification, callback, etc)                      โ”‚
โ”‚  โ”œโ”€โ”€ payload (JSON, raw payload dari Midtrans)                     โ”‚
โ”‚  โ”œโ”€โ”€ signature_verified (boolean)                                   โ”‚
โ”‚  โ”œโ”€โ”€ ip_address                                                     โ”‚
โ”‚  โ”œโ”€โ”€ user_agent                                                     โ”‚
โ”‚  โ””โ”€โ”€ timestamps                                                     โ”‚
โ”‚                                                                      โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Relationships Diagram

ENTITY RELATIONSHIPS:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”       โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”       โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  USERS   โ”‚       โ”‚ PROPERTIES โ”‚       โ”‚ BOOKINGS โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค       โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค       โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ id (PK)  โ”‚โ”€โ”€โ”    โ”‚ id (PK)    โ”‚โ”€โ”€โ”    โ”‚ id (PK)  โ”‚
โ”‚ name     โ”‚  โ”‚    โ”‚ owner_id   โ”‚โ†โ”€โ”˜    โ”‚ user_id  โ”‚โ†โ”€โ”
โ”‚ email    โ”‚  โ”‚    โ”‚ name       โ”‚  โ”‚    โ”‚ prop_id  โ”‚โ†โ”€โ”ผโ”€โ”
โ”‚ role     โ”‚  โ”‚    โ”‚ price      โ”‚  โ”‚    โ”‚ status   โ”‚  โ”‚ โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ”‚    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ”‚    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ”‚ โ”‚
              โ”‚                    โ”‚         โ”‚        โ”‚ โ”‚
              โ”‚    owner_id โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜         โ”‚        โ”‚ โ”‚
              โ”‚                              โ”‚        โ”‚ โ”‚
              โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ user_id โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚
                                             โ”‚          โ”‚
                       property_id โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                                             โ”‚
                                             โ–ผ
                                    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                                    โ”‚ TRANSACTIONS โ”‚
                                    โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
                                    โ”‚ id (PK)      โ”‚
                                    โ”‚ booking_id   โ”‚โ†โ”€โ”
                                    โ”‚ order_id     โ”‚  โ”‚
                                    โ”‚ status       โ”‚  โ”‚
                                    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ”‚
                                             โ”‚        โ”‚
                          booking_id โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                                             โ”‚
                                             โ–ผ
                                    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                                    โ”‚ PAYMENT_LOGS โ”‚
                                    โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
                                    โ”‚ id (PK)      โ”‚
                                    โ”‚ trans_id     โ”‚
                                    โ”‚ payload      โ”‚
                                    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Create Migrations

Sekarang buat migration files:

# Modify users table (sudah ada default migration)
php artisan make:migration add_fields_to_users_table --table=users

# Create new tables
php artisan make:migration create_properties_table
php artisan make:migration create_bookings_table
php artisan make:migration create_transactions_table
php artisan make:migration create_payment_logs_table

Migration: Add Fields to Users

<?php
// database/migrations/xxxx_add_fields_to_users_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::table('users', function (Blueprint $table) {
            $table->string('phone', 20)->nullable()->after('email');
            $table->enum('role', ['guest', 'owner', 'admin'])->default('guest')->after('phone');
            $table->string('avatar')->nullable()->after('role');
        });
    }

    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropColumn(['phone', 'role', 'avatar']);
        });
    }
};

Migration: Properties Table

<?php
// database/migrations/xxxx_create_properties_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('properties', function (Blueprint $table) {
            $table->id();
            $table->foreignId('owner_id')->constrained('users')->onDelete('cascade');
            $table->string('name');
            $table->string('slug')->unique();
            $table->text('description');
            $table->string('address');
            $table->string('city', 100);
            $table->string('province', 100);
            $table->string('postal_code', 10)->nullable();
            $table->decimal('latitude', 10, 8)->nullable();
            $table->decimal('longitude', 11, 8)->nullable();
            $table->decimal('price_per_night', 12, 2);
            $table->unsignedTinyInteger('max_guests')->default(2);
            $table->unsignedTinyInteger('bedrooms')->default(1);
            $table->unsignedTinyInteger('bathrooms')->default(1);
            $table->json('amenities')->nullable();
            $table->json('images')->nullable();
            $table->string('thumbnail')->nullable();
            $table->boolean('is_available')->default(true);
            $table->timestamps();

            // Indexes for common queries
            $table->index('city');
            $table->index('province');
            $table->index('price_per_night');
            $table->index('is_available');
            $table->index(['city', 'is_available']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('properties');
    }
};

Migration: Bookings Table

<?php
// database/migrations/xxxx_create_bookings_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('bookings', function (Blueprint $table) {
            $table->id();
            $table->string('booking_code', 20)->unique();
            $table->foreignId('user_id')->constrained()->onDelete('cascade');
            $table->foreignId('property_id')->constrained()->onDelete('cascade');
            $table->date('check_in_date');
            $table->date('check_out_date');
            $table->unsignedTinyInteger('guests')->default(1);
            $table->unsignedSmallInteger('nights');
            $table->decimal('price_per_night', 12, 2);
            $table->decimal('subtotal', 12, 2);
            $table->decimal('service_fee', 12, 2)->default(0);
            $table->decimal('total_price', 12, 2);
            $table->enum('status', [
                'pending',      // Menunggu pembayaran
                'paid',         // Sudah dibayar
                'confirmed',    // Dikonfirmasi owner
                'completed',    // Check-out selesai
                'cancelled',    // Dibatalkan
                'refunded'      // Sudah refund
            ])->default('pending');
            $table->text('notes')->nullable();
            $table->text('special_requests')->nullable();
            $table->timestamp('paid_at')->nullable();
            $table->timestamp('confirmed_at')->nullable();
            $table->timestamp('cancelled_at')->nullable();
            $table->text('cancel_reason')->nullable();
            $table->timestamps();

            // Indexes
            $table->index('status');
            $table->index('check_in_date');
            $table->index(['user_id', 'status']);
            $table->index(['property_id', 'status']);
            $table->index(['property_id', 'check_in_date', 'check_out_date']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('bookings');
    }
};

Migration: Transactions Table

<?php
// database/migrations/xxxx_create_transactions_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('transactions', function (Blueprint $table) {
            $table->id();
            $table->foreignId('booking_id')->constrained()->onDelete('cascade');
            $table->string('order_id', 50)->unique();
            $table->string('transaction_id', 100)->nullable();
            $table->string('payment_type', 50)->nullable();
            $table->decimal('gross_amount', 12, 2);
            $table->string('transaction_status', 30)->default('pending');
            $table->string('fraud_status', 30)->nullable();
            $table->string('va_number', 50)->nullable();
            $table->string('bank', 30)->nullable();
            $table->timestamp('transaction_time')->nullable();
            $table->timestamp('settlement_time')->nullable();
            $table->timestamp('expiry_time')->nullable();
            $table->text('snap_token')->nullable();
            $table->string('snap_redirect_url')->nullable();
            $table->json('raw_response')->nullable();
            $table->timestamps();

            // Indexes
            $table->index('transaction_status');
            $table->index('payment_type');
            $table->index('transaction_id');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('transactions');
    }
};

Migration: Payment Logs Table

<?php
// database/migrations/xxxx_create_payment_logs_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('payment_logs', function (Blueprint $table) {
            $table->id();
            $table->foreignId('transaction_id')->nullable()->constrained()->onDelete('set null');
            $table->string('order_id', 50)->nullable();
            $table->string('event_type', 50);
            $table->json('payload');
            $table->boolean('signature_verified')->default(false);
            $table->string('ip_address', 45)->nullable();
            $table->string('user_agent')->nullable();
            $table->timestamps();

            // Indexes
            $table->index('order_id');
            $table->index('event_type');
            $table->index(['order_id', 'event_type']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('payment_logs');
    }
};

Create Models

Generate model files:

php artisan make:model Property
php artisan make:model Booking
php artisan make:model Transaction
php artisan make:model PaymentLog

Model: User (Update)

<?php
// app/Models/User.php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
use Illuminate\\Foundation\\Auth\\User as Authenticatable;
use Illuminate\\Notifications\\Notifiable;

class User extends Authenticatable
{
    use HasFactory, Notifiable;

    protected $fillable = [
        'name',
        'email',
        'phone',
        'password',
        'role',
        'avatar',
    ];

    protected $hidden = [
        'password',
        'remember_token',
    ];

    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
        ];
    }

    // Relationships
    public function properties(): HasMany
    {
        return $this->hasMany(Property::class, 'owner_id');
    }

    public function bookings(): HasMany
    {
        return $this->hasMany(Booking::class);
    }

    // Helpers
    public function isOwner(): bool
    {
        return $this->role === 'owner';
    }

    public function isAdmin(): bool
    {
        return $this->role === 'admin';
    }

    public function isGuest(): bool
    {
        return $this->role === 'guest';
    }
}

Model: Property

<?php
// app/Models/Property.php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
use Illuminate\\Support\\Str;

class Property extends Model
{
    use HasFactory;

    protected $fillable = [
        'owner_id',
        'name',
        'slug',
        'description',
        'address',
        'city',
        'province',
        'postal_code',
        'latitude',
        'longitude',
        'price_per_night',
        'max_guests',
        'bedrooms',
        'bathrooms',
        'amenities',
        'images',
        'thumbnail',
        'is_available',
    ];

    protected function casts(): array
    {
        return [
            'amenities' => 'array',
            'images' => 'array',
            'price_per_night' => 'decimal:2',
            'latitude' => 'decimal:8',
            'longitude' => 'decimal:8',
            'is_available' => 'boolean',
        ];
    }

    // Auto-generate slug
    protected static function boot()
    {
        parent::boot();

        static::creating(function ($property) {
            if (empty($property->slug)) {
                $property->slug = Str::slug($property->name) . '-' . Str::random(6);
            }
        });
    }

    // Relationships
    public function owner(): BelongsTo
    {
        return $this->belongsTo(User::class, 'owner_id');
    }

    public function bookings(): HasMany
    {
        return $this->hasMany(Booking::class);
    }

    // Scopes
    public function scopeAvailable($query)
    {
        return $query->where('is_available', true);
    }

    public function scopeInCity($query, string $city)
    {
        return $query->where('city', $city);
    }

    public function scopePriceBetween($query, float $min, float $max)
    {
        return $query->whereBetween('price_per_night', [$min, $max]);
    }

    // Accessors
    public function getFormattedPriceAttribute(): string
    {
        return 'Rp ' . number_format($this->price_per_night, 0, ',', '.');
    }

    public function getFullAddressAttribute(): string
    {
        return "{$this->address}, {$this->city}, {$this->province}";
    }
}

Model: Booking

<?php
// app/Models/Booking.php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
use Illuminate\\Database\\Eloquent\\Relations\\HasOne;
use Illuminate\\Support\\Str;

class Booking extends Model
{
    use HasFactory;

    protected $fillable = [
        'booking_code',
        'user_id',
        'property_id',
        'check_in_date',
        'check_out_date',
        'guests',
        'nights',
        'price_per_night',
        'subtotal',
        'service_fee',
        'total_price',
        'status',
        'notes',
        'special_requests',
        'paid_at',
        'confirmed_at',
        'cancelled_at',
        'cancel_reason',
    ];

    protected function casts(): array
    {
        return [
            'check_in_date' => 'date',
            'check_out_date' => 'date',
            'price_per_night' => 'decimal:2',
            'subtotal' => 'decimal:2',
            'service_fee' => 'decimal:2',
            'total_price' => 'decimal:2',
            'paid_at' => 'datetime',
            'confirmed_at' => 'datetime',
            'cancelled_at' => 'datetime',
        ];
    }

    // Auto-generate booking code
    protected static function boot()
    {
        parent::boot();

        static::creating(function ($booking) {
            if (empty($booking->booking_code)) {
                $booking->booking_code = 'SEWA-' . strtoupper(Str::random(8));
            }
        });
    }

    // Relationships
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    public function property(): BelongsTo
    {
        return $this->belongsTo(Property::class);
    }

    public function transaction(): HasOne
    {
        return $this->hasOne(Transaction::class);
    }

    // Scopes
    public function scopePending($query)
    {
        return $query->where('status', 'pending');
    }

    public function scopePaid($query)
    {
        return $query->where('status', 'paid');
    }

    public function scopeConfirmed($query)
    {
        return $query->where('status', 'confirmed');
    }

    public function scopeActive($query)
    {
        return $query->whereIn('status', ['paid', 'confirmed']);
    }

    // Helpers
    public function isPending(): bool
    {
        return $this->status === 'pending';
    }

    public function isPaid(): bool
    {
        return $this->status === 'paid';
    }

    public function canBeCancelled(): bool
    {
        return in_array($this->status, ['pending', 'paid'])
            && $this->check_in_date->isFuture();
    }

    // Accessors
    public function getFormattedTotalAttribute(): string
    {
        return 'Rp ' . number_format($this->total_price, 0, ',', '.');
    }
}

Model: Transaction

<?php
// app/Models/Transaction.php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;

class Transaction extends Model
{
    use HasFactory;

    protected $fillable = [
        'booking_id',
        'order_id',
        'transaction_id',
        'payment_type',
        'gross_amount',
        'transaction_status',
        'fraud_status',
        'va_number',
        'bank',
        'transaction_time',
        'settlement_time',
        'expiry_time',
        'snap_token',
        'snap_redirect_url',
        'raw_response',
    ];

    protected function casts(): array
    {
        return [
            'gross_amount' => 'decimal:2',
            'transaction_time' => 'datetime',
            'settlement_time' => 'datetime',
            'expiry_time' => 'datetime',
            'raw_response' => 'array',
        ];
    }

    // Relationships
    public function booking(): BelongsTo
    {
        return $this->belongsTo(Booking::class);
    }

    public function paymentLogs(): HasMany
    {
        return $this->hasMany(PaymentLog::class);
    }

    // Helpers
    public function isSettled(): bool
    {
        return in_array($this->transaction_status, ['capture', 'settlement']);
    }

    public function isPending(): bool
    {
        return $this->transaction_status === 'pending';
    }

    public function isFailed(): bool
    {
        return in_array($this->transaction_status, ['deny', 'cancel', 'expire']);
    }

    // Accessor for payment type label
    public function getPaymentTypeLabelAttribute(): string
    {
        return match($this->payment_type) {
            'credit_card' => 'Credit Card',
            'bank_transfer' => 'Bank Transfer',
            'echannel' => 'Mandiri Bill',
            'gopay' => 'GoPay',
            'shopeepay' => 'ShopeePay',
            'qris' => 'QRIS',
            'cstore' => 'Convenience Store',
            default => ucfirst(str_replace('_', ' ', $this->payment_type ?? 'Unknown')),
        };
    }
}

Model: PaymentLog

<?php
// app/Models/PaymentLog.php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;

class PaymentLog extends Model
{
    use HasFactory;

    protected $fillable = [
        'transaction_id',
        'order_id',
        'event_type',
        'payload',
        'signature_verified',
        'ip_address',
        'user_agent',
    ];

    protected function casts(): array
    {
        return [
            'payload' => 'array',
            'signature_verified' => 'boolean',
        ];
    }

    // Relationships
    public function transaction(): BelongsTo
    {
        return $this->belongsTo(Transaction::class);
    }
}

Run Migrations

Sekarang jalankan semua migrations:

php artisan migrate

Output yang diharapkan:

INFO  Running migrations.

2024_01_01_000000_create_users_table .................................. 15ms DONE
2024_01_01_000001_create_cache_table .................................. 8ms DONE
2024_01_01_000002_create_jobs_table ................................... 12ms DONE
xxxx_xx_xx_xxxxxx_add_fields_to_users_table ........................... 5ms DONE
xxxx_xx_xx_xxxxxx_create_properties_table ............................. 18ms DONE
xxxx_xx_xx_xxxxxx_create_bookings_table ............................... 15ms DONE
xxxx_xx_xx_xxxxxx_create_transactions_table ........................... 12ms DONE
xxxx_xx_xx_xxxxxx_create_payment_logs_table ........................... 10ms DONE

Verify Database

Cek struktur database sudah benar:

php artisan db:show

Atau via MySQL:

USE sewa_rumah;
SHOW TABLES;
DESCRIBE properties;
DESCRIBE bookings;
DESCRIBE transactions;

Test Jalankan Laravel

php artisan serve

Buka http://localhost:8000 โ€” Laravel welcome page harusnya muncul.


Database schema sudah siap. Di bagian selanjutnya, kita akan membuat Seeder untuk mengisi data dummy yang realistis โ€” properti di berbagai kota Indonesia, users dengan berbagai role, dan sample bookings.

Bagian 3: Seeder untuk Data Dummy

Sekarang kita akan membuat data dummy yang realistis untuk testing. Data yang bagus membuat development dan testing jadi lebih mudah karena kita bisa melihat aplikasi dengan data yang mirip production.

Create Seeder Files

Generate seeder files:

php artisan make:seeder UserSeeder
php artisan make:seeder PropertySeeder
php artisan make:seeder BookingSeeder

UserSeeder

Kita akan membuat beberapa tipe user: 1 Admin, 5 Property Owners, dan 10 Guests.

<?php
// database/seeders/UserSeeder.php

namespace Database\\Seeders;

use App\\Models\\User;
use Illuminate\\Database\\Seeder;
use Illuminate\\Support\\Facades\\Hash;

class UserSeeder extends Seeder
{
    public function run(): void
    {
        // Admin User
        User::create([
            'name' => 'Administrator',
            'email' => '[email protected]',
            'phone' => '081234567890',
            'password' => Hash::make('password'),
            'role' => 'admin',
            'email_verified_at' => now(),
        ]);

        // Property Owners
        $owners = [
            ['name' => 'Budi Santoso', 'email' => '[email protected]', 'phone' => '081234567891'],
            ['name' => 'Siti Rahayu', 'email' => '[email protected]', 'phone' => '081234567892'],
            ['name' => 'Agus Wijaya', 'email' => '[email protected]', 'phone' => '081234567893'],
            ['name' => 'Dewi Lestari', 'email' => '[email protected]', 'phone' => '081234567894'],
            ['name' => 'Eko Prasetyo', 'email' => '[email protected]', 'phone' => '081234567895'],
        ];

        foreach ($owners as $owner) {
            User::create([
                'name' => $owner['name'],
                'email' => $owner['email'],
                'phone' => $owner['phone'],
                'password' => Hash::make('password'),
                'role' => 'owner',
                'email_verified_at' => now(),
            ]);
        }

        // Guest Users
        $guests = [
            ['name' => 'Ahmad Fauzi', 'email' => '[email protected]', 'phone' => '082345678901'],
            ['name' => 'Rina Marlina', 'email' => '[email protected]', 'phone' => '082345678902'],
            ['name' => 'Doni Kusuma', 'email' => '[email protected]', 'phone' => '082345678903'],
            ['name' => 'Putri Handayani', 'email' => '[email protected]', 'phone' => '082345678904'],
            ['name' => 'Bambang Suryadi', 'email' => '[email protected]', 'phone' => '082345678905'],
            ['name' => 'Nadia Safitri', 'email' => '[email protected]', 'phone' => '082345678906'],
            ['name' => 'Rizky Ramadhan', 'email' => '[email protected]', 'phone' => '082345678907'],
            ['name' => 'Anisa Putri', 'email' => '[email protected]', 'phone' => '082345678908'],
            ['name' => 'Hendra Gunawan', 'email' => '[email protected]', 'phone' => '082345678909'],
            ['name' => 'Maya Sari', 'email' => '[email protected]', 'phone' => '082345678910'],
        ];

        foreach ($guests as $guest) {
            User::create([
                'name' => $guest['name'],
                'email' => $guest['email'],
                'phone' => $guest['phone'],
                'password' => Hash::make('password'),
                'role' => 'guest',
                'email_verified_at' => now(),
            ]);
        }

        $this->command->info('โœ… Created 1 admin, 5 owners, and 10 guests');
    }
}

PropertySeeder

Sekarang buat properties dengan data realistis dari berbagai kota di Indonesia:

<?php
// database/seeders/PropertySeeder.php

namespace Database\\Seeders;

use App\\Models\\Property;
use App\\Models\\User;
use Illuminate\\Database\\Seeder;
use Illuminate\\Support\\Str;

class PropertySeeder extends Seeder
{
    private array $allAmenities = [
        'wifi', 'ac', 'parking', 'pool', 'kitchen', 'tv', 'washer',
        'dryer', 'hot_water', 'security', 'garden', 'balcony',
        'sea_view', 'mountain_view', 'city_view', 'breakfast',
        'gym', 'spa', 'bbq', 'workspace',
    ];

    public function run(): void
    {
        $owners = User::where('role', 'owner')->get();

        if ($owners->isEmpty()) {
            $this->command->error('No owners found. Run UserSeeder first.');
            return;
        }

        $properties = $this->getPropertiesData();

        foreach ($properties as $propertyData) {
            $owner = $owners->random();
            $amenities = collect($this->allAmenities)->random(rand(5, 10))->values()->toArray();
            $images = $this->generatePlaceholderImages(rand(4, 8));

            Property::create([
                'owner_id' => $owner->id,
                'name' => $propertyData['name'],
                'slug' => Str::slug($propertyData['name']) . '-' . Str::random(6),
                'description' => $propertyData['description'],
                'address' => $propertyData['address'],
                'city' => $propertyData['city'],
                'province' => $propertyData['province'],
                'postal_code' => $propertyData['postal_code'],
                'latitude' => $propertyData['latitude'],
                'longitude' => $propertyData['longitude'],
                'price_per_night' => $propertyData['price'],
                'max_guests' => $propertyData['max_guests'],
                'bedrooms' => $propertyData['bedrooms'],
                'bathrooms' => $propertyData['bathrooms'],
                'amenities' => $amenities,
                'images' => $images,
                'thumbnail' => $images[0] ?? null,
                'is_available' => true,
            ]);
        }

        $this->command->info('โœ… Created ' . count($properties) . ' properties');
    }

    private function generatePlaceholderImages(int $count): array
    {
        $images = [];
        for ($i = 1; $i <= $count; $i++) {
            $images[] = "<https://picsum.photos/seed/>" . Str::random(10) . "/800/600";
        }
        return $images;
    }

    private function getPropertiesData(): array
    {
        return [
            // BALI
            [
                'name' => 'Villa Sunset Seminyak',
                'description' => 'Villa mewah dengan pemandangan sunset spektakuler di Seminyak. Dilengkapi dengan kolam renang pribadi, taman tropis yang asri, dan akses mudah ke pantai. Cocok untuk liburan keluarga atau honeymoon.',
                'address' => 'Jl. Kayu Aya No. 123',
                'city' => 'Badung',
                'province' => 'Bali',
                'postal_code' => '80361',
                'latitude' => -8.6897,
                'longitude' => 115.1628,
                'price' => 2500000,
                'max_guests' => 6,
                'bedrooms' => 3,
                'bathrooms' => 2,
            ],
            [
                'name' => 'Ubud Rice Terrace Villa',
                'description' => 'Nikmati ketenangan Ubud dengan pemandangan sawah terasering yang menakjubkan. Villa ini menawarkan pengalaman autentik Bali dengan kenyamanan modern.',
                'address' => 'Jl. Raya Tegallalang No. 45',
                'city' => 'Gianyar',
                'province' => 'Bali',
                'postal_code' => '80561',
                'latitude' => -8.4312,
                'longitude' => 115.2792,
                'price' => 1800000,
                'max_guests' => 4,
                'bedrooms' => 2,
                'bathrooms' => 2,
            ],
            [
                'name' => 'Beachfront Canggu Home',
                'description' => 'Rumah modern di tepi pantai Canggu yang populer. Akses langsung ke pantai, dekat dengan cafe dan restoran trendy.',
                'address' => 'Jl. Pantai Batu Bolong No. 88',
                'city' => 'Badung',
                'province' => 'Bali',
                'postal_code' => '80351',
                'latitude' => -8.6560,
                'longitude' => 115.1320,
                'price' => 3200000,
                'max_guests' => 8,
                'bedrooms' => 4,
                'bathrooms' => 3,
            ],
            [
                'name' => 'Nusa Dua Luxury Suite',
                'description' => 'Suite mewah di kawasan elite Nusa Dua. Fasilitas bintang 5 dengan keamanan 24 jam.',
                'address' => 'Kawasan BTDC Lot N-5',
                'city' => 'Badung',
                'province' => 'Bali',
                'postal_code' => '80363',
                'latitude' => -8.8055,
                'longitude' => 115.2331,
                'price' => 4500000,
                'max_guests' => 4,
                'bedrooms' => 2,
                'bathrooms' => 2,
            ],
            // JAKARTA
            [
                'name' => 'SCBD Premium Apartment',
                'description' => 'Apartemen premium di jantung bisnis Jakarta. Pemandangan city skyline yang memukau. Fully furnished dengan desain interior modern minimalis.',
                'address' => 'Jl. Jend. Sudirman Kav. 52-53',
                'city' => 'Jakarta Selatan',
                'province' => 'DKI Jakarta',
                'postal_code' => '12190',
                'latitude' => -6.2246,
                'longitude' => 106.8097,
                'price' => 1500000,
                'max_guests' => 3,
                'bedrooms' => 1,
                'bathrooms' => 1,
            ],
            [
                'name' => 'Menteng Heritage House',
                'description' => 'Rumah heritage klasik di kawasan Menteng yang prestigious. Arsitektur kolonial Belanda yang terawat dengan baik.',
                'address' => 'Jl. Menteng Dalam No. 17',
                'city' => 'Jakarta Pusat',
                'province' => 'DKI Jakarta',
                'postal_code' => '10310',
                'latitude' => -6.1944,
                'longitude' => 106.8419,
                'price' => 2800000,
                'max_guests' => 6,
                'bedrooms' => 3,
                'bathrooms' => 2,
            ],
            [
                'name' => 'PIK Waterfront Villa',
                'description' => 'Villa modern di Pantai Indah Kapuk dengan akses ke waterfront. Dekat dengan berbagai kuliner dan entertainment.',
                'address' => 'Jl. Marina Indah No. 8',
                'city' => 'Jakarta Utara',
                'province' => 'DKI Jakarta',
                'postal_code' => '14470',
                'latitude' => -6.1097,
                'longitude' => 106.7417,
                'price' => 3500000,
                'max_guests' => 8,
                'bedrooms' => 4,
                'bathrooms' => 3,
            ],
            // BANDUNG
            [
                'name' => 'Dago Highland Villa',
                'description' => 'Villa dengan pemandangan kota Bandung dari ketinggian Dago. Udara sejuk pegunungan, perfect untuk escape dari panasnya kota.',
                'address' => 'Jl. Dago Pakar No. 25',
                'city' => 'Bandung',
                'province' => 'Jawa Barat',
                'postal_code' => '40135',
                'latitude' => -6.8550,
                'longitude' => 107.6180,
                'price' => 1200000,
                'max_guests' => 6,
                'bedrooms' => 3,
                'bathrooms' => 2,
            ],
            [
                'name' => 'Lembang Mountain Retreat',
                'description' => 'Retreat nyaman di pegunungan Lembang. Suasana asri dengan udara segar. Dekat dengan Tangkuban Perahu dan Floating Market.',
                'address' => 'Jl. Grand Hotel No. 33',
                'city' => 'Bandung Barat',
                'province' => 'Jawa Barat',
                'postal_code' => '40391',
                'latitude' => -6.8126,
                'longitude' => 107.6172,
                'price' => 950000,
                'max_guests' => 8,
                'bedrooms' => 4,
                'bathrooms' => 2,
            ],
            [
                'name' => 'Ciumbuleuit Cozy Home',
                'description' => 'Rumah nyaman di kawasan Ciumbuleuit yang sejuk. Suasana homey dengan taman yang asri.',
                'address' => 'Jl. Ciumbuleuit No. 150',
                'city' => 'Bandung',
                'province' => 'Jawa Barat',
                'postal_code' => '40142',
                'latitude' => -6.8700,
                'longitude' => 107.6050,
                'price' => 850000,
                'max_guests' => 5,
                'bedrooms' => 2,
                'bathrooms' => 1,
            ],
            // YOGYAKARTA
            [
                'name' => 'Jogja Heritage Guesthouse',
                'description' => 'Guesthouse dengan arsitektur Jawa klasik di pusat kota Jogja. Walking distance ke Malioboro dan Keraton.',
                'address' => 'Jl. Prawirotaman No. 30',
                'city' => 'Yogyakarta',
                'province' => 'DI Yogyakarta',
                'postal_code' => '55153',
                'latitude' => -7.8150,
                'longitude' => 110.3656,
                'price' => 650000,
                'max_guests' => 4,
                'bedrooms' => 2,
                'bathrooms' => 1,
            ],
            [
                'name' => 'Kaliurang Mountain House',
                'description' => 'Rumah di lereng Gunung Merapi dengan view yang spectacular. Udara sejuk dan suasana tenang.',
                'address' => 'Jl. Kaliurang Km. 23',
                'city' => 'Sleman',
                'province' => 'DI Yogyakarta',
                'postal_code' => '55581',
                'latitude' => -7.6050,
                'longitude' => 110.4250,
                'price' => 750000,
                'max_guests' => 6,
                'bedrooms' => 3,
                'bathrooms' => 2,
            ],
            // SURABAYA
            [
                'name' => 'Pakuwon City Modern Home',
                'description' => 'Rumah modern di kawasan premium Pakuwon City. Fasilitas lengkap dalam satu kawasan.',
                'address' => 'Jl. Pakuwon City Ruko AA-15',
                'city' => 'Surabaya',
                'province' => 'Jawa Timur',
                'postal_code' => '60112',
                'latitude' => -7.2850,
                'longitude' => 112.7820,
                'price' => 1100000,
                'max_guests' => 5,
                'bedrooms' => 2,
                'bathrooms' => 2,
            ],
            [
                'name' => 'Citraland Lake View Villa',
                'description' => 'Villa dengan pemandangan danau di Citraland. Kawasan hijau dan asri.',
                'address' => 'Jl. Bukit Golf No. 7',
                'city' => 'Surabaya',
                'province' => 'Jawa Timur',
                'postal_code' => '60217',
                'latitude' => -7.2910,
                'longitude' => 112.6540,
                'price' => 1350000,
                'max_guests' => 6,
                'bedrooms' => 3,
                'bathrooms' => 2,
            ],
            // MALANG
            [
                'name' => 'Batu Villa & Resort',
                'description' => 'Villa di kota wisata Batu dengan pemandangan pegunungan. Dekat dengan Jatim Park dan Museum Angkut.',
                'address' => 'Jl. Raya Selecta No. 12',
                'city' => 'Batu',
                'province' => 'Jawa Timur',
                'postal_code' => '65312',
                'latitude' => -7.8670,
                'longitude' => 112.5280,
                'price' => 980000,
                'max_guests' => 8,
                'bedrooms' => 4,
                'bathrooms' => 2,
            ],
            // LOMBOK
            [
                'name' => 'Senggigi Beach Villa',
                'description' => 'Villa tepi pantai di Senggigi dengan sunset view yang memukau. Perfect untuk honeymoon atau romantic getaway.',
                'address' => 'Jl. Raya Senggigi No. 55',
                'city' => 'Lombok Barat',
                'province' => 'Nusa Tenggara Barat',
                'postal_code' => '83355',
                'latitude' => -8.4840,
                'longitude' => 116.0470,
                'price' => 1650000,
                'max_guests' => 4,
                'bedrooms' => 2,
                'bathrooms' => 2,
            ],
            [
                'name' => 'Kuta Lombok Surf House',
                'description' => 'Rumah untuk surfer di Kuta Lombok yang terkenal dengan pantai-pantainya. Vibe relaxed dan friendly.',
                'address' => 'Jl. Pantai Kuta No. 18',
                'city' => 'Lombok Tengah',
                'province' => 'Nusa Tenggara Barat',
                'postal_code' => '83573',
                'latitude' => -8.9010,
                'longitude' => 116.2880,
                'price' => 750000,
                'max_guests' => 4,
                'bedrooms' => 2,
                'bathrooms' => 1,
            ],
            // MEDAN
            [
                'name' => 'Lake Toba View House',
                'description' => 'Rumah dengan pemandangan Danau Toba yang iconic. Udara sejuk pegunungan Sumatera.',
                'address' => 'Jl. Pembangunan No. 22',
                'city' => 'Parapat',
                'province' => 'Sumatera Utara',
                'postal_code' => '21174',
                'latitude' => 2.6620,
                'longitude' => 98.9380,
                'price' => 850000,
                'max_guests' => 6,
                'bedrooms' => 3,
                'bathrooms' => 2,
            ],
            // MAKASSAR
            [
                'name' => 'Losari Beach Apartment',
                'description' => 'Apartemen modern dengan view Pantai Losari yang terkenal. Sunset spektakuler setiap hari.',
                'address' => 'Jl. Penghibur No. 50',
                'city' => 'Makassar',
                'province' => 'Sulawesi Selatan',
                'postal_code' => '90111',
                'latitude' => -5.1420,
                'longitude' => 119.4070,
                'price' => 950000,
                'max_guests' => 3,
                'bedrooms' => 1,
                'bathrooms' => 1,
            ],
        ];
    }
}

BookingSeeder

Sekarang buat sample bookings dengan berbagai status:

<?php
// database/seeders/BookingSeeder.php

namespace Database\\Seeders;

use App\\Models\\Booking;
use App\\Models\\Property;
use App\\Models\\Transaction;
use App\\Models\\User;
use Carbon\\Carbon;
use Illuminate\\Database\\Seeder;
use Illuminate\\Support\\Str;

class BookingSeeder extends Seeder
{
    public function run(): void
    {
        $guests = User::where('role', 'guest')->get();
        $properties = Property::all();

        if ($guests->isEmpty() || $properties->isEmpty()) {
            $this->command->error('No guests or properties found.');
            return;
        }

        $bookingsCreated = 0;

        // 1. Completed bookings (past) - 5
        for ($i = 0; $i < 5; $i++) {
            $this->createBookingWithTransaction($guests->random(), $properties->random(), 'completed');
            $bookingsCreated++;
        }

        // 2. Confirmed bookings (upcoming) - 3
        for ($i = 0; $i < 3; $i++) {
            $this->createBookingWithTransaction($guests->random(), $properties->random(), 'confirmed');
            $bookingsCreated++;
        }

        // 3. Paid bookings (waiting confirmation) - 2
        for ($i = 0; $i < 2; $i++) {
            $this->createBookingWithTransaction($guests->random(), $properties->random(), 'paid');
            $bookingsCreated++;
        }

        // 4. Pending bookings (waiting payment) - 3
        for ($i = 0; $i < 3; $i++) {
            $this->createBookingWithTransaction($guests->random(), $properties->random(), 'pending');
            $bookingsCreated++;
        }

        // 5. Cancelled bookings - 2
        for ($i = 0; $i < 2; $i++) {
            $this->createBookingWithTransaction($guests->random(), $properties->random(), 'cancelled');
            $bookingsCreated++;
        }

        $this->command->info("โœ… Created {$bookingsCreated} bookings with transactions");
    }

    private function createBookingWithTransaction(User $guest, Property $property, string $status): void
    {
        // Set dates based on status
        $checkIn = match($status) {
            'completed' => Carbon::now()->subDays(rand(30, 60)),
            'confirmed', 'paid' => Carbon::now()->addDays(rand(7, 30)),
            'pending' => Carbon::now()->addDays(rand(3, 14)),
            'cancelled' => Carbon::now()->addDays(rand(5, 20)),
        };

        $nights = rand(2, 5);
        $checkOut = $checkIn->copy()->addDays($nights);

        // Calculate pricing
        $pricePerNight = $property->price_per_night;
        $subtotal = $pricePerNight * $nights;
        $serviceFee = $subtotal * 0.05;
        $totalPrice = $subtotal + $serviceFee;

        // Create booking
        $booking = Booking::create([
            'booking_code' => 'SEWA-' . strtoupper(Str::random(8)),
            'user_id' => $guest->id,
            'property_id' => $property->id,
            'check_in_date' => $checkIn->toDateString(),
            'check_out_date' => $checkOut->toDateString(),
            'guests' => rand(1, min(4, $property->max_guests)),
            'nights' => $nights,
            'price_per_night' => $pricePerNight,
            'subtotal' => $subtotal,
            'service_fee' => $serviceFee,
            'total_price' => $totalPrice,
            'status' => $status,
            'notes' => rand(0, 1) ? 'Akan datang dengan keluarga' : null,
            'paid_at' => in_array($status, ['paid', 'confirmed', 'completed']) ? now()->subDays(rand(1, 7)) : null,
            'confirmed_at' => in_array($status, ['confirmed', 'completed']) ? now()->subDays(rand(1, 5)) : null,
            'cancelled_at' => $status === 'cancelled' ? now()->subDays(rand(1, 3)) : null,
            'cancel_reason' => $status === 'cancelled' ? 'Perubahan rencana perjalanan' : null,
        ]);

        // Create transaction (except for pending)
        if ($status !== 'pending') {
            $this->createTransaction($booking, $status);
        }
    }

    private function createTransaction(Booking $booking, string $status): void
    {
        $paymentTypes = ['bank_transfer', 'gopay', 'shopeepay', 'credit_card', 'qris'];
        $banks = ['bca', 'bni', 'bri', 'mandiri'];
        $paymentType = $paymentTypes[array_rand($paymentTypes)];
        $isPaid = in_array($status, ['paid', 'confirmed', 'completed']);

        Transaction::create([
            'booking_id' => $booking->id,
            'order_id' => 'ORDER-' . $booking->id . '-' . time() . rand(100, 999),
            'transaction_id' => $isPaid ? 'TXN-' . Str::random(20) : null,
            'payment_type' => $paymentType,
            'gross_amount' => $booking->total_price,
            'transaction_status' => $isPaid ? 'settlement' : ($status === 'cancelled' ? 'cancel' : 'pending'),
            'fraud_status' => $paymentType === 'credit_card' ? 'accept' : null,
            'va_number' => $paymentType === 'bank_transfer' ? rand(10000, 99999) . rand(1000000000, 9999999999) : null,
            'bank' => $paymentType === 'bank_transfer' ? $banks[array_rand($banks)] : null,
            'transaction_time' => $booking->paid_at ?? $booking->created_at,
            'settlement_time' => $isPaid ? $booking->paid_at : null,
            'snap_token' => 'SNAP-' . Str::random(32),
            'snap_redirect_url' => '<https://app.sandbox.midtrans.com/snap/v2/vtweb/>' . Str::random(32),
        ]);
    }
}

Update DatabaseSeeder

<?php
// database/seeders/DatabaseSeeder.php

namespace Database\\Seeders;

use Illuminate\\Database\\Seeder;

class DatabaseSeeder extends Seeder
{
    public function run(): void
    {
        $this->call([
            UserSeeder::class,
            PropertySeeder::class,
            BookingSeeder::class,
        ]);
    }
}

Run Seeders

Jalankan semua seeders:

# Fresh migrate dengan seed
php artisan migrate:fresh --seed

Output yang diharapkan:

INFO  Seeding database.

Database\\Seeders\\UserSeeder ...................................... RUNNING
โœ… Created 1 admin, 5 owners, and 10 guests
Database\\Seeders\\UserSeeder .................................. 152.45 ms DONE

Database\\Seeders\\PropertySeeder .................................. RUNNING
โœ… Created 20 properties
Database\\Seeders\\PropertySeeder ............................... 89.32 ms DONE

Database\\Seeders\\BookingSeeder ................................... RUNNING
โœ… Created 15 bookings with transactions
Database\\Seeders\\BookingSeeder ................................ 67.18 ms DONE

Verify Seeded Data

Cek data sudah masuk dengan benar:

php artisan tinker

// Cek jumlah data
User::count();          // 16 (1 admin + 5 owners + 10 guests)
Property::count();      // 20
Booking::count();       // 15
Transaction::count();   // 12 (pending bookings tidak punya transaction)

// Cek user by role
User::where('role', 'admin')->count();   // 1
User::where('role', 'owner')->count();   // 5
User::where('role', 'guest')->count();   // 10

// Cek booking by status
Booking::where('status', 'completed')->count();   // 5
Booking::where('status', 'confirmed')->count();   // 3
Booking::where('status', 'paid')->count();        // 2
Booking::where('status', 'pending')->count();     // 3
Booking::where('status', 'cancelled')->count();   // 2

// Lihat sample property
Property::with('owner')->first();

// Lihat sample booking dengan relasi
Booking::with(['user', 'property', 'transaction'])->first();

Login Credentials untuk Testing

ADMIN:
Email: [email protected]
Password: password

OWNER:
Email: [email protected]
Password: password

GUEST:
Email: [email protected]
Password: password

Sample Data Summary

DATA YANG DIBUAT:

USERS (16 total):
โ”œโ”€โ”€ 1 Admin
โ”œโ”€โ”€ 5 Property Owners
โ””โ”€โ”€ 10 Guests

PROPERTIES (20 total):
โ”œโ”€โ”€ 4 di Bali (Rp 1.8jt - 4.5jt/malam)
โ”œโ”€โ”€ 3 di Jakarta (Rp 1.5jt - 3.5jt/malam)
โ”œโ”€โ”€ 3 di Bandung (Rp 850rb - 1.2jt/malam)
โ”œโ”€โ”€ 2 di Yogyakarta (Rp 650rb - 750rb/malam)
โ”œโ”€โ”€ 2 di Surabaya (Rp 1.1jt - 1.35jt/malam)
โ”œโ”€โ”€ 1 di Malang (Rp 980rb/malam)
โ”œโ”€โ”€ 2 di Lombok (Rp 750rb - 1.65jt/malam)
โ”œโ”€โ”€ 1 di Medan/Toba (Rp 850rb/malam)
โ””โ”€โ”€ 1 di Makassar (Rp 950rb/malam)

BOOKINGS (15 total):
โ”œโ”€โ”€ 5 completed (sudah check-out)
โ”œโ”€โ”€ 3 confirmed (upcoming)
โ”œโ”€โ”€ 2 paid (menunggu konfirmasi)
โ”œโ”€โ”€ 3 pending (menunggu bayar)
โ””โ”€โ”€ 2 cancelled


Data dummy sudah siap dan realistis. Di bagian selanjutnya, kita akan mengimplementasikan Repository Pattern untuk mengorganisir data access layer dengan lebih clean dan maintainable.

Bagian 4: Repository Pattern Implementation

Sekarang kita akan mengimplementasikan Repository Pattern untuk memisahkan logic data access dari business logic. Pattern ini membuat code lebih clean, testable, dan maintainable.

Apa itu Repository Pattern?

Repository Pattern adalah design pattern yang memisahkan logic untuk mengakses data dari business logic aplikasi. Bayangkan Repository sebagai "perantara" antara aplikasi dan database.

TANPA REPOSITORY:
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                                                                      โ”‚
โ”‚  Controller                                                          โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”     โ”‚
โ”‚  โ”‚  public function index()                                    โ”‚     โ”‚
โ”‚  โ”‚  {                                                          โ”‚     โ”‚
โ”‚  โ”‚      // Query logic langsung di controller                  โ”‚     โ”‚
โ”‚  โ”‚      $properties = Property::where('is_available', true)    โ”‚     โ”‚
โ”‚  โ”‚          ->where('city', $request->city)                    โ”‚     โ”‚
โ”‚  โ”‚          ->whereBetween('price_per_night', [$min, $max])   โ”‚     โ”‚
โ”‚  โ”‚          ->with('owner')                                    โ”‚     โ”‚
โ”‚  โ”‚          ->orderBy('price_per_night')                       โ”‚     โ”‚
โ”‚  โ”‚          ->paginate(12);                                    โ”‚     โ”‚
โ”‚  โ”‚      return view('properties.index', compact('properties'));โ”‚     โ”‚
โ”‚  โ”‚  }                                                          โ”‚     โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜     โ”‚
โ”‚                                                                      โ”‚
โ”‚  Problem:                                                            โ”‚
โ”‚  โ”œโ”€โ”€ Controller jadi gemuk (fat controller)                         โ”‚
โ”‚  โ”œโ”€โ”€ Query logic tersebar di banyak tempat                          โ”‚
โ”‚  โ”œโ”€โ”€ Sulit di-test (harus hit database)                             โ”‚
โ”‚  โ””โ”€โ”€ Sulit di-maintain kalau query berubah                          โ”‚
โ”‚                                                                      โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

DENGAN REPOSITORY:
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                                                                      โ”‚
โ”‚  Controller                                                          โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”     โ”‚
โ”‚  โ”‚  public function index()                                    โ”‚     โ”‚
โ”‚  โ”‚  {                                                          โ”‚     โ”‚
โ”‚  โ”‚      $properties = $this->propertyRepo->search($filters);   โ”‚     โ”‚
โ”‚  โ”‚      return view('properties.index', compact('properties'));โ”‚     โ”‚
โ”‚  โ”‚  }                                                          โ”‚     โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜     โ”‚
โ”‚                           โ”‚                                          โ”‚
โ”‚                           โ–ผ                                          โ”‚
โ”‚  Repository                                                          โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”     โ”‚
โ”‚  โ”‚  public function search(array $filters)                     โ”‚     โ”‚
โ”‚  โ”‚  {                                                          โ”‚     โ”‚
โ”‚  โ”‚      // Semua query logic di sini                           โ”‚     โ”‚
โ”‚  โ”‚      return Property::where('is_available', true)           โ”‚     โ”‚
โ”‚  โ”‚          ->when($filters['city'], ...)                      โ”‚     โ”‚
โ”‚  โ”‚          ->paginate(12);                                    โ”‚     โ”‚
โ”‚  โ”‚  }                                                          โ”‚     โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜     โ”‚
โ”‚                                                                      โ”‚
โ”‚  Benefits:                                                           โ”‚
โ”‚  โ”œโ”€โ”€ Controller jadi tipis (thin controller)                        โ”‚
โ”‚  โ”œโ”€โ”€ Query logic terpusat di satu tempat                            โ”‚
โ”‚  โ”œโ”€โ”€ Mudah di-test (bisa mock repository)                           โ”‚
โ”‚  โ””โ”€โ”€ Mudah di-maintain                                              โ”‚
โ”‚                                                                      โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Directory Structure

Buat struktur folder untuk repositories:

mkdir -p app/Repositories/Contracts
mkdir -p app/Repositories/Eloquent

Struktur yang akan kita buat:

app/
โ”œโ”€โ”€ Repositories/
โ”‚   โ”œโ”€โ”€ Contracts/                    # Interfaces
โ”‚   โ”‚   โ”œโ”€โ”€ BaseRepositoryInterface.php
โ”‚   โ”‚   โ”œโ”€โ”€ PropertyRepositoryInterface.php
โ”‚   โ”‚   โ”œโ”€โ”€ BookingRepositoryInterface.php
โ”‚   โ”‚   โ””โ”€โ”€ TransactionRepositoryInterface.php
โ”‚   โ””โ”€โ”€ Eloquent/                     # Implementations
โ”‚       โ”œโ”€โ”€ BaseRepository.php
โ”‚       โ”œโ”€โ”€ PropertyRepository.php
โ”‚       โ”œโ”€โ”€ BookingRepository.php
โ”‚       โ””โ”€โ”€ TransactionRepository.php
โ””โ”€โ”€ Providers/
    โ””โ”€โ”€ RepositoryServiceProvider.php

Base Repository Interface

Interface dasar yang berisi method-method umum:

<?php
// app/Repositories/Contracts/BaseRepositoryInterface.php

namespace App\\Repositories\\Contracts;

use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Collection;
use Illuminate\\Pagination\\LengthAwarePaginator;

interface BaseRepositoryInterface
{
    /**
     * Get all records
     */
    public function all(array $columns = ['*']): Collection;

    /**
     * Get paginated records
     */
    public function paginate(int $perPage = 15, array $columns = ['*']): LengthAwarePaginator;

    /**
     * Find record by ID
     */
    public function find(int $id, array $columns = ['*']): ?Model;

    /**
     * Find record by ID or throw exception
     */
    public function findOrFail(int $id, array $columns = ['*']): Model;

    /**
     * Find records by field
     */
    public function findBy(string $field, mixed $value, array $columns = ['*']): Collection;

    /**
     * Find first record by field
     */
    public function findFirstBy(string $field, mixed $value, array $columns = ['*']): ?Model;

    /**
     * Create new record
     */
    public function create(array $data): Model;

    /**
     * Update existing record
     */
    public function update(int $id, array $data): bool;

    /**
     * Delete record
     */
    public function delete(int $id): bool;

    /**
     * Get records with relations
     */
    public function with(array $relations): self;

    /**
     * Count records
     */
    public function count(): int;
}

Base Repository Implementation

Implementasi dasar yang bisa di-extend oleh repository lain:

<?php
// app/Repositories/Eloquent/BaseRepository.php

namespace App\\Repositories\\Eloquent;

use App\\Repositories\\Contracts\\BaseRepositoryInterface;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Collection;
use Illuminate\\Database\\Eloquent\\Builder;
use Illuminate\\Pagination\\LengthAwarePaginator;

abstract class BaseRepository implements BaseRepositoryInterface
{
    protected Model $model;
    protected Builder $query;

    public function __construct(Model $model)
    {
        $this->model = $model;
        $this->query = $model->newQuery();
    }

    protected function newQuery(): Builder
    {
        return $this->model->newQuery();
    }

    public function all(array $columns = ['*']): Collection
    {
        return $this->newQuery()->get($columns);
    }

    public function paginate(int $perPage = 15, array $columns = ['*']): LengthAwarePaginator
    {
        return $this->newQuery()->paginate($perPage, $columns);
    }

    public function find(int $id, array $columns = ['*']): ?Model
    {
        return $this->newQuery()->find($id, $columns);
    }

    public function findOrFail(int $id, array $columns = ['*']): Model
    {
        return $this->newQuery()->findOrFail($id, $columns);
    }

    public function findBy(string $field, mixed $value, array $columns = ['*']): Collection
    {
        return $this->newQuery()->where($field, $value)->get($columns);
    }

    public function findFirstBy(string $field, mixed $value, array $columns = ['*']): ?Model
    {
        return $this->newQuery()->where($field, $value)->first($columns);
    }

    public function create(array $data): Model
    {
        return $this->newQuery()->create($data);
    }

    public function update(int $id, array $data): bool
    {
        $record = $this->findOrFail($id);
        return $record->update($data);
    }

    public function delete(int $id): bool
    {
        $record = $this->findOrFail($id);
        return $record->delete();
    }

    public function with(array $relations): self
    {
        $this->query = $this->newQuery()->with($relations);
        return $this;
    }

    public function count(): int
    {
        return $this->newQuery()->count();
    }
}

Property Repository Interface

Interface untuk Property dengan method-method spesifik:

<?php
// app/Repositories/Contracts/PropertyRepositoryInterface.php

namespace App\\Repositories\\Contracts;

use App\\Models\\Property;
use Illuminate\\Database\\Eloquent\\Collection;
use Illuminate\\Pagination\\LengthAwarePaginator;

interface PropertyRepositoryInterface extends BaseRepositoryInterface
{
    /**
     * Get all available properties
     */
    public function getAvailable(): Collection;

    /**
     * Find property by slug
     */
    public function findBySlug(string $slug): ?Property;

    /**
     * Get properties by city
     */
    public function getByCity(string $city): Collection;

    /**
     * Get properties by owner
     */
    public function getByOwner(int $ownerId): Collection;

    /**
     * Search properties with filters
     */
    public function search(array $filters, int $perPage = 12): LengthAwarePaginator;

    /**
     * Check property availability for date range
     */
    public function isAvailableForDates(int $propertyId, string $checkIn, string $checkOut): bool;

    /**
     * Get featured properties
     */
    public function getFeatured(int $limit = 6): Collection;

    /**
     * Get properties with price range
     */
    public function getByPriceRange(float $min, float $max): Collection;

    /**
     * Get unique cities that have properties
     */
    public function getAvailableCities(): array;
}

Property Repository Implementation

<?php
// app/Repositories/Eloquent/PropertyRepository.php

namespace App\\Repositories\\Eloquent;

use App\\Models\\Property;
use App\\Models\\Booking;
use App\\Repositories\\Contracts\\PropertyRepositoryInterface;
use Illuminate\\Database\\Eloquent\\Collection;
use Illuminate\\Pagination\\LengthAwarePaginator;

class PropertyRepository extends BaseRepository implements PropertyRepositoryInterface
{
    public function __construct(Property $model)
    {
        parent::__construct($model);
    }

    public function getAvailable(): Collection
    {
        return $this->newQuery()
            ->where('is_available', true)
            ->with('owner')
            ->orderBy('created_at', 'desc')
            ->get();
    }

    public function findBySlug(string $slug): ?Property
    {
        return $this->newQuery()
            ->where('slug', $slug)
            ->with(['owner', 'bookings'])
            ->first();
    }

    public function getByCity(string $city): Collection
    {
        return $this->newQuery()
            ->where('city', $city)
            ->where('is_available', true)
            ->with('owner')
            ->get();
    }

    public function getByOwner(int $ownerId): Collection
    {
        return $this->newQuery()
            ->where('owner_id', $ownerId)
            ->with('bookings')
            ->orderBy('created_at', 'desc')
            ->get();
    }

    public function search(array $filters, int $perPage = 12): LengthAwarePaginator
    {
        $query = $this->newQuery()
            ->where('is_available', true)
            ->with('owner');

        // Filter by city
        if (!empty($filters['city'])) {
            $query->where('city', $filters['city']);
        }

        // Filter by province
        if (!empty($filters['province'])) {
            $query->where('province', $filters['province']);
        }

        // Filter by price range
        if (!empty($filters['min_price'])) {
            $query->where('price_per_night', '>=', $filters['min_price']);
        }

        if (!empty($filters['max_price'])) {
            $query->where('price_per_night', '<=', $filters['max_price']);
        }

        // Filter by guests
        if (!empty($filters['guests'])) {
            $query->where('max_guests', '>=', $filters['guests']);
        }

        // Filter by bedrooms
        if (!empty($filters['bedrooms'])) {
            $query->where('bedrooms', '>=', $filters['bedrooms']);
        }

        // Filter by amenities
        if (!empty($filters['amenities']) && is_array($filters['amenities'])) {
            foreach ($filters['amenities'] as $amenity) {
                $query->whereJsonContains('amenities', $amenity);
            }
        }

        // Search by keyword (name or description)
        if (!empty($filters['keyword'])) {
            $keyword = $filters['keyword'];
            $query->where(function ($q) use ($keyword) {
                $q->where('name', 'like', "%{$keyword}%")
                  ->orWhere('description', 'like', "%{$keyword}%")
                  ->orWhere('address', 'like', "%{$keyword}%");
            });
        }

        // Check availability for dates
        if (!empty($filters['check_in']) && !empty($filters['check_out'])) {
            $checkIn = $filters['check_in'];
            $checkOut = $filters['check_out'];

            $bookedPropertyIds = Booking::whereIn('status', ['paid', 'confirmed'])
                ->where(function ($q) use ($checkIn, $checkOut) {
                    $q->whereBetween('check_in_date', [$checkIn, $checkOut])
                      ->orWhereBetween('check_out_date', [$checkIn, $checkOut])
                      ->orWhere(function ($q2) use ($checkIn, $checkOut) {
                          $q2->where('check_in_date', '<=', $checkIn)
                             ->where('check_out_date', '>=', $checkOut);
                      });
                })
                ->pluck('property_id');

            $query->whereNotIn('id', $bookedPropertyIds);
        }

        // Sorting
        $sortBy = $filters['sort_by'] ?? 'created_at';
        $sortOrder = $filters['sort_order'] ?? 'desc';

        $allowedSorts = ['price_per_night', 'created_at', 'name', 'bedrooms'];
        if (in_array($sortBy, $allowedSorts)) {
            $query->orderBy($sortBy, $sortOrder);
        }

        return $query->paginate($perPage);
    }

    public function isAvailableForDates(int $propertyId, string $checkIn, string $checkOut): bool
    {
        $conflictingBookings = Booking::where('property_id', $propertyId)
            ->whereIn('status', ['paid', 'confirmed'])
            ->where(function ($query) use ($checkIn, $checkOut) {
                $query->whereBetween('check_in_date', [$checkIn, $checkOut])
                      ->orWhereBetween('check_out_date', [$checkIn, $checkOut])
                      ->orWhere(function ($q) use ($checkIn, $checkOut) {
                          $q->where('check_in_date', '<=', $checkIn)
                            ->where('check_out_date', '>=', $checkOut);
                      });
            })
            ->exists();

        return !$conflictingBookings;
    }

    public function getFeatured(int $limit = 6): Collection
    {
        return $this->newQuery()
            ->where('is_available', true)
            ->with('owner')
            ->inRandomOrder()
            ->limit($limit)
            ->get();
    }

    public function getByPriceRange(float $min, float $max): Collection
    {
        return $this->newQuery()
            ->where('is_available', true)
            ->whereBetween('price_per_night', [$min, $max])
            ->with('owner')
            ->orderBy('price_per_night')
            ->get();
    }

    public function getAvailableCities(): array
    {
        return $this->newQuery()
            ->where('is_available', true)
            ->distinct()
            ->pluck('city')
            ->sort()
            ->values()
            ->toArray();
    }
}

Booking Repository Interface

<?php
// app/Repositories/Contracts/BookingRepositoryInterface.php

namespace App\\Repositories\\Contracts;

use App\\Models\\Booking;
use Illuminate\\Database\\Eloquent\\Collection;
use Illuminate\\Pagination\\LengthAwarePaginator;

interface BookingRepositoryInterface extends BaseRepositoryInterface
{
    /**
     * Find booking by code
     */
    public function findByCode(string $code): ?Booking;

    /**
     * Get bookings by user
     */
    public function getByUser(int $userId, int $perPage = 10): LengthAwarePaginator;

    /**
     * Get bookings by property
     */
    public function getByProperty(int $propertyId): Collection;

    /**
     * Get bookings by status
     */
    public function getByStatus(string $status): Collection;

    /**
     * Get pending bookings that need payment
     */
    public function getPendingPayments(): Collection;

    /**
     * Get upcoming bookings for user
     */
    public function getUpcoming(int $userId): Collection;

    /**
     * Get booked dates for a property
     */
    public function getBookedDates(int $propertyId): array;

    /**
     * Update booking status
     */
    public function updateStatus(int $id, string $status, array $additionalData = []): bool;

    /**
     * Create booking with calculated prices
     */
    public function createWithPricing(array $data): Booking;

    /**
     * Get bookings for owner's properties
     */
    public function getByOwner(int $ownerId, int $perPage = 10): LengthAwarePaginator;

    /**
     * Generate unique booking code
     */
    public function generateBookingCode(): string;
}

Booking Repository Implementation

<?php
// app/Repositories/Eloquent/BookingRepository.php

namespace App\\Repositories\\Eloquent;

use App\\Models\\Booking;
use App\\Models\\Property;
use App\\Repositories\\Contracts\\BookingRepositoryInterface;
use Carbon\\Carbon;
use Illuminate\\Database\\Eloquent\\Collection;
use Illuminate\\Pagination\\LengthAwarePaginator;
use Illuminate\\Support\\Str;

class BookingRepository extends BaseRepository implements BookingRepositoryInterface
{
    public function __construct(Booking $model)
    {
        parent::__construct($model);
    }

    public function findByCode(string $code): ?Booking
    {
        return $this->newQuery()
            ->where('booking_code', $code)
            ->with(['user', 'property', 'transaction'])
            ->first();
    }

    public function getByUser(int $userId, int $perPage = 10): LengthAwarePaginator
    {
        return $this->newQuery()
            ->where('user_id', $userId)
            ->with(['property', 'transaction'])
            ->orderBy('created_at', 'desc')
            ->paginate($perPage);
    }

    public function getByProperty(int $propertyId): Collection
    {
        return $this->newQuery()
            ->where('property_id', $propertyId)
            ->with(['user', 'transaction'])
            ->orderBy('check_in_date', 'desc')
            ->get();
    }

    public function getByStatus(string $status): Collection
    {
        return $this->newQuery()
            ->where('status', $status)
            ->with(['user', 'property', 'transaction'])
            ->orderBy('created_at', 'desc')
            ->get();
    }

    public function getPendingPayments(): Collection
    {
        return $this->newQuery()
            ->where('status', 'pending')
            ->where('created_at', '>=', now()->subHours(24))
            ->with(['user', 'property'])
            ->get();
    }

    public function getUpcoming(int $userId): Collection
    {
        return $this->newQuery()
            ->where('user_id', $userId)
            ->whereIn('status', ['paid', 'confirmed'])
            ->where('check_in_date', '>=', now()->toDateString())
            ->with(['property'])
            ->orderBy('check_in_date')
            ->get();
    }

    public function getBookedDates(int $propertyId): array
    {
        $bookings = $this->newQuery()
            ->where('property_id', $propertyId)
            ->whereIn('status', ['paid', 'confirmed'])
            ->where('check_out_date', '>=', now()->toDateString())
            ->get(['check_in_date', 'check_out_date']);

        $bookedDates = [];
        foreach ($bookings as $booking) {
            $period = Carbon::parse($booking->check_in_date)
                ->daysUntil($booking->check_out_date);

            foreach ($period as $date) {
                $bookedDates[] = $date->format('Y-m-d');
            }
        }

        return array_unique($bookedDates);
    }

    public function updateStatus(int $id, string $status, array $additionalData = []): bool
    {
        $booking = $this->findOrFail($id);

        $data = array_merge(['status' => $status], $additionalData);

        // Add timestamp based on status
        switch ($status) {
            case 'paid':
                $data['paid_at'] = $data['paid_at'] ?? now();
                break;
            case 'confirmed':
                $data['confirmed_at'] = $data['confirmed_at'] ?? now();
                break;
            case 'cancelled':
                $data['cancelled_at'] = $data['cancelled_at'] ?? now();
                break;
        }

        return $booking->update($data);
    }

    public function createWithPricing(array $data): Booking
    {
        $property = Property::findOrFail($data['property_id']);

        $checkIn = Carbon::parse($data['check_in_date']);
        $checkOut = Carbon::parse($data['check_out_date']);
        $nights = $checkIn->diffInDays($checkOut);

        $pricePerNight = $property->price_per_night;
        $subtotal = $pricePerNight * $nights;
        $serviceFee = $subtotal * 0.05; // 5% service fee
        $totalPrice = $subtotal + $serviceFee;

        return $this->create([
            'booking_code' => $this->generateBookingCode(),
            'user_id' => $data['user_id'],
            'property_id' => $data['property_id'],
            'check_in_date' => $data['check_in_date'],
            'check_out_date' => $data['check_out_date'],
            'guests' => $data['guests'] ?? 1,
            'nights' => $nights,
            'price_per_night' => $pricePerNight,
            'subtotal' => $subtotal,
            'service_fee' => $serviceFee,
            'total_price' => $totalPrice,
            'status' => 'pending',
            'notes' => $data['notes'] ?? null,
            'special_requests' => $data['special_requests'] ?? null,
        ]);
    }

    public function getByOwner(int $ownerId, int $perPage = 10): LengthAwarePaginator
    {
        return $this->newQuery()
            ->whereHas('property', function ($query) use ($ownerId) {
                $query->where('owner_id', $ownerId);
            })
            ->with(['user', 'property', 'transaction'])
            ->orderBy('created_at', 'desc')
            ->paginate($perPage);
    }

    public function generateBookingCode(): string
    {
        do {
            $code = 'SEWA-' . strtoupper(Str::random(8));
        } while ($this->newQuery()->where('booking_code', $code)->exists());

        return $code;
    }
}

Transaction Repository Interface

<?php
// app/Repositories/Contracts/TransactionRepositoryInterface.php

namespace App\\Repositories\\Contracts;

use App\\Models\\Transaction;
use Illuminate\\Database\\Eloquent\\Collection;

interface TransactionRepositoryInterface extends BaseRepositoryInterface
{
    /**
     * Find transaction by order_id
     */
    public function findByOrderId(string $orderId): ?Transaction;

    /**
     * Find transaction by Midtrans transaction_id
     */
    public function findByTransactionId(string $transactionId): ?Transaction;

    /**
     * Get transaction by booking
     */
    public function getByBooking(int $bookingId): ?Transaction;

    /**
     * Create transaction with snap token
     */
    public function createWithSnapToken(array $data): Transaction;

    /**
     * Update transaction from Midtrans notification
     */
    public function updateFromMidtrans(string $orderId, array $midtransData): bool;

    /**
     * Get pending transactions
     */
    public function getPending(): Collection;

    /**
     * Get transactions by status
     */
    public function getByStatus(string $status): Collection;

    /**
     * Generate unique order ID
     */
    public function generateOrderId(int $bookingId): string;
}

Transaction Repository Implementation

<?php
// app/Repositories/Eloquent/TransactionRepository.php

namespace App\\Repositories\\Eloquent;

use App\\Models\\Transaction;
use App\\Repositories\\Contracts\\TransactionRepositoryInterface;
use Illuminate\\Database\\Eloquent\\Collection;

class TransactionRepository extends BaseRepository implements TransactionRepositoryInterface
{
    public function __construct(Transaction $model)
    {
        parent::__construct($model);
    }

    public function findByOrderId(string $orderId): ?Transaction
    {
        return $this->newQuery()
            ->where('order_id', $orderId)
            ->with(['booking.user', 'booking.property'])
            ->first();
    }

    public function findByTransactionId(string $transactionId): ?Transaction
    {
        return $this->newQuery()
            ->where('transaction_id', $transactionId)
            ->with(['booking'])
            ->first();
    }

    public function getByBooking(int $bookingId): ?Transaction
    {
        return $this->newQuery()
            ->where('booking_id', $bookingId)
            ->first();
    }

    public function createWithSnapToken(array $data): Transaction
    {
        return $this->create([
            'booking_id' => $data['booking_id'],
            'order_id' => $data['order_id'],
            'gross_amount' => $data['gross_amount'],
            'transaction_status' => 'pending',
            'snap_token' => $data['snap_token'],
            'snap_redirect_url' => $data['snap_redirect_url'] ?? null,
        ]);
    }

    public function updateFromMidtrans(string $orderId, array $midtransData): bool
    {
        $transaction = $this->findByOrderId($orderId);

        if (!$transaction) {
            return false;
        }

        return $transaction->update([
            'transaction_id' => $midtransData['transaction_id'] ?? $transaction->transaction_id,
            'payment_type' => $midtransData['payment_type'] ?? $transaction->payment_type,
            'transaction_status' => $midtransData['transaction_status'],
            'fraud_status' => $midtransData['fraud_status'] ?? null,
            'va_number' => $this->extractVaNumber($midtransData),
            'bank' => $midtransData['bank'] ?? $this->extractBank($midtransData),
            'transaction_time' => $midtransData['transaction_time'] ?? null,
            'settlement_time' => $midtransData['settlement_time'] ?? null,
            'raw_response' => $midtransData,
        ]);
    }

    public function getPending(): Collection
    {
        return $this->newQuery()
            ->where('transaction_status', 'pending')
            ->with(['booking.user', 'booking.property'])
            ->orderBy('created_at', 'desc')
            ->get();
    }

    public function getByStatus(string $status): Collection
    {
        return $this->newQuery()
            ->where('transaction_status', $status)
            ->with(['booking'])
            ->orderBy('created_at', 'desc')
            ->get();
    }

    public function generateOrderId(int $bookingId): string
    {
        return 'SEWA-' . $bookingId . '-' . time();
    }

    /**
     * Extract VA number from various payment types
     */
    private function extractVaNumber(array $data): ?string
    {
        // Bank Transfer
        if (isset($data['va_numbers'][0]['va_number'])) {
            return $data['va_numbers'][0]['va_number'];
        }

        // Permata
        if (isset($data['permata_va_number'])) {
            return $data['permata_va_number'];
        }

        // BCA
        if (isset($data['bca_va_number'])) {
            return $data['bca_va_number'];
        }

        return null;
    }

    /**
     * Extract bank name from various payment types
     */
    private function extractBank(array $data): ?string
    {
        if (isset($data['va_numbers'][0]['bank'])) {
            return $data['va_numbers'][0]['bank'];
        }

        if (isset($data['permata_va_number'])) {
            return 'permata';
        }

        if (isset($data['bca_va_number'])) {
            return 'bca';
        }

        return null;
    }
}

Repository Service Provider

Buat service provider untuk binding interfaces ke implementations:

php artisan make:provider RepositoryServiceProvider

<?php
// app/Providers/RepositoryServiceProvider.php

namespace App\\Providers;

use Illuminate\\Support\\ServiceProvider;

// Contracts
use App\\Repositories\\Contracts\\PropertyRepositoryInterface;
use App\\Repositories\\Contracts\\BookingRepositoryInterface;
use App\\Repositories\\Contracts\\TransactionRepositoryInterface;

// Implementations
use App\\Repositories\\Eloquent\\PropertyRepository;
use App\\Repositories\\Eloquent\\BookingRepository;
use App\\Repositories\\Eloquent\\TransactionRepository;

class RepositoryServiceProvider extends ServiceProvider
{
    /**
     * Register services.
     */
    public function register(): void
    {
        $this->app->bind(
            PropertyRepositoryInterface::class,
            PropertyRepository::class
        );

        $this->app->bind(
            BookingRepositoryInterface::class,
            BookingRepository::class
        );

        $this->app->bind(
            TransactionRepositoryInterface::class,
            TransactionRepository::class
        );
    }

    /**
     * Bootstrap services.
     */
    public function boot(): void
    {
        //
    }
}

Register Service Provider

Untuk Laravel 11+, tambahkan di bootstrap/providers.php:

<?php
// bootstrap/providers.php

return [
    App\\Providers\\AppServiceProvider::class,
    App\\Providers\\RepositoryServiceProvider::class,  // Tambahkan ini
];

Usage Example

Sekarang repository bisa digunakan di controller dengan dependency injection:

<?php
// Contoh penggunaan di Controller

namespace App\\Http\\Controllers;

use App\\Repositories\\Contracts\\PropertyRepositoryInterface;
use App\\Repositories\\Contracts\\BookingRepositoryInterface;
use Illuminate\\Http\\Request;

class PropertyController extends Controller
{
    public function __construct(
        protected PropertyRepositoryInterface $propertyRepo
    ) {}

    public function index(Request $request)
    {
        $properties = $this->propertyRepo->search([
            'city' => $request->city,
            'min_price' => $request->min_price,
            'max_price' => $request->max_price,
            'guests' => $request->guests,
            'check_in' => $request->check_in,
            'check_out' => $request->check_out,
        ]);

        return view('properties.index', compact('properties'));
    }

    public function show(string $slug)
    {
        $property = $this->propertyRepo->findBySlug($slug);

        if (!$property) {
            abort(404);
        }

        $bookedDates = app(BookingRepositoryInterface::class)
            ->getBookedDates($property->id);

        return view('properties.show', compact('property', 'bookedDates'));
    }
}


Repository Pattern sudah terimplementasi. Di bagian selanjutnya, kita akan membuat Service Layer untuk menangani business logic seperti pembuatan booking dan integrasi dengan Midtrans.

Bagian 5: Service Layer & Business Logic

Sekarang kita akan membuat Service Layer yang berisi business logic aplikasi. Service Layer berada di antara Controller dan Repository, menangani logic kompleks seperti kalkulasi harga, validasi bisnis, dan koordinasi dengan external API (Midtrans).

Perbedaan Repository vs Service

REPOSITORY vs SERVICE:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                                                                      โ”‚
โ”‚  REPOSITORY                                                          โ”‚
โ”‚  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€                                                          โ”‚
โ”‚  โ”œโ”€โ”€ Data access (CRUD)                                             โ”‚
โ”‚  โ”œโ”€โ”€ Query database                                                 โ”‚
โ”‚  โ”œโ”€โ”€ Simple data manipulation                                       โ”‚
โ”‚  โ””โ”€โ”€ Tidak tahu tentang business rules                              โ”‚
โ”‚                                                                      โ”‚
โ”‚  Contoh:                                                             โ”‚
โ”‚  - findBySlug($slug)                                                โ”‚
โ”‚  - create($data)                                                    โ”‚
โ”‚  - getByStatus($status)                                             โ”‚
โ”‚                                                                      โ”‚
โ”‚  SERVICE                                                             โ”‚
โ”‚  โ”€โ”€โ”€โ”€โ”€โ”€โ”€                                                             โ”‚
โ”‚  โ”œโ”€โ”€ Business logic                                                 โ”‚
โ”‚  โ”œโ”€โ”€ Validasi business rules                                        โ”‚
โ”‚  โ”œโ”€โ”€ Koordinasi multiple repositories                               โ”‚
โ”‚  โ”œโ”€โ”€ External API integration                                       โ”‚
โ”‚  โ””โ”€โ”€ Transaction management                                         โ”‚
โ”‚                                                                      โ”‚
โ”‚  Contoh:                                                             โ”‚
โ”‚  - createBooking() โ†’ validate โ†’ check availability โ†’ create โ†’       โ”‚
โ”‚                      generate payment โ†’ send notification           โ”‚
โ”‚  - processPayment() โ†’ verify โ†’ update status โ†’ notify              โ”‚
โ”‚                                                                      โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Service Structure

Buat folder untuk services:

mkdir -p app/Services

Struktur:

app/
โ””โ”€โ”€ Services/
    โ”œโ”€โ”€ PropertyService.php
    โ”œโ”€โ”€ BookingService.php
    โ”œโ”€โ”€ PaymentService.php
    โ””โ”€โ”€ MidtransService.php

Install Midtrans Package

Sebelum membuat services, install package Midtrans:

composer require midtrans/midtrans-php

MidtransService

Service untuk komunikasi langsung dengan Midtrans API:

<?php
// app/Services/MidtransService.php

namespace App\\Services;

use App\\Models\\Booking;
use Illuminate\\Support\\Facades\\Log;
use Midtrans\\Config;
use Midtrans\\Snap;
use Midtrans\\Transaction;

class MidtransService
{
    public function __construct()
    {
        $this->initializeConfig();
    }

    /**
     * Initialize Midtrans configuration
     */
    private function initializeConfig(): void
    {
        Config::$serverKey = config('midtrans.server_key');
        Config::$isProduction = config('midtrans.is_production');
        Config::$isSanitized = config('midtrans.is_sanitized');
        Config::$is3ds = config('midtrans.is_3ds');
    }

    /**
     * Create Snap token for payment
     */
    public function createSnapToken(Booking $booking, string $orderId): array
    {
        $booking->load(['user', 'property']);

        $params = [
            'transaction_details' => [
                'order_id' => $orderId,
                'gross_amount' => (int) $booking->total_price,
            ],
            'customer_details' => [
                'first_name' => $booking->user->name,
                'email' => $booking->user->email,
                'phone' => $booking->user->phone ?? '',
            ],
            'item_details' => $this->buildItemDetails($booking),
            'callbacks' => [
                'finish' => route('bookings.finish'),
            ],
            'expiry' => [
                'unit' => 'hours',
                'duration' => 24,
            ],
        ];

        try {
            $snapToken = Snap::getSnapToken($params);

            Log::info('Snap token created', [
                'order_id' => $orderId,
                'booking_id' => $booking->id,
            ]);

            return [
                'success' => true,
                'token' => $snapToken,
                'redirect_url' => $this->getSnapRedirectUrl($params),
            ];
        } catch (\\Exception $e) {
            Log::error('Failed to create snap token', [
                'order_id' => $orderId,
                'error' => $e->getMessage(),
            ]);

            return [
                'success' => false,
                'error' => $e->getMessage(),
            ];
        }
    }

    /**
     * Build item details for Midtrans
     */
    private function buildItemDetails(Booking $booking): array
    {
        $items = [];

        // Property rental
        $items[] = [
            'id' => 'PROPERTY-' . $booking->property_id,
            'price' => (int) $booking->price_per_night,
            'quantity' => $booking->nights,
            'name' => $this->truncate($booking->property->name . ' (' . $booking->nights . ' malam)', 50),
        ];

        // Service fee
        if ($booking->service_fee > 0) {
            $items[] = [
                'id' => 'SERVICE-FEE',
                'price' => (int) $booking->service_fee,
                'quantity' => 1,
                'name' => 'Biaya Layanan',
            ];
        }

        return $items;
    }

    /**
     * Get Snap redirect URL
     */
    private function getSnapRedirectUrl(array $params): ?string
    {
        try {
            return Snap::createTransaction($params)->redirect_url;
        } catch (\\Exception $e) {
            return null;
        }
    }

    /**
     * Verify notification signature
     */
    public function verifySignature(array $notification): bool
    {
        $orderId = $notification['order_id'] ?? '';
        $statusCode = $notification['status_code'] ?? '';
        $grossAmount = $notification['gross_amount'] ?? '';
        $serverKey = config('midtrans.server_key');

        $expectedSignature = hash('sha512', $orderId . $statusCode . $grossAmount . $serverKey);
        $receivedSignature = $notification['signature_key'] ?? '';

        return hash_equals($expectedSignature, $receivedSignature);
    }

    /**
     * Get transaction status from Midtrans
     */
    public function getTransactionStatus(string $orderId): ?array
    {
        try {
            $status = Transaction::status($orderId);
            return json_decode(json_encode($status), true);
        } catch (\\Exception $e) {
            Log::error('Failed to get transaction status', [
                'order_id' => $orderId,
                'error' => $e->getMessage(),
            ]);
            return null;
        }
    }

    /**
     * Cancel transaction
     */
    public function cancelTransaction(string $orderId): bool
    {
        try {
            Transaction::cancel($orderId);
            return true;
        } catch (\\Exception $e) {
            Log::error('Failed to cancel transaction', [
                'order_id' => $orderId,
                'error' => $e->getMessage(),
            ]);
            return false;
        }
    }

    /**
     * Truncate string for Midtrans (max 50 chars)
     */
    private function truncate(string $string, int $length): string
    {
        return strlen($string) > $length
            ? substr($string, 0, $length - 3) . '...'
            : $string;
    }

    /**
     * Map Midtrans status to app booking status
     */
    public function mapToBookingStatus(string $transactionStatus, ?string $fraudStatus = null): string
    {
        // Handle fraud status for credit card
        if ($transactionStatus === 'capture') {
            return $fraudStatus === 'accept' ? 'paid' : 'pending';
        }

        return match ($transactionStatus) {
            'settlement' => 'paid',
            'pending' => 'pending',
            'deny', 'cancel', 'expire' => 'cancelled',
            'refund', 'partial_refund' => 'refunded',
            default => 'pending',
        };
    }
}

PropertyService

Service untuk business logic terkait property:

<?php
// app/Services/PropertyService.php

namespace App\\Services;

use App\\Models\\Property;
use App\\Repositories\\Contracts\\PropertyRepositoryInterface;
use App\\Repositories\\Contracts\\BookingRepositoryInterface;
use Carbon\\Carbon;
use Illuminate\\Pagination\\LengthAwarePaginator;

class PropertyService
{
    public function __construct(
        protected PropertyRepositoryInterface $propertyRepo,
        protected BookingRepositoryInterface $bookingRepo
    ) {}

    /**
     * Search properties with filters
     */
    public function search(array $filters, int $perPage = 12): LengthAwarePaginator
    {
        return $this->propertyRepo->search($filters, $perPage);
    }

    /**
     * Get property details with availability info
     */
    public function getPropertyDetails(string $slug): ?array
    {
        $property = $this->propertyRepo->findBySlug($slug);

        if (!$property) {
            return null;
        }

        $bookedDates = $this->bookingRepo->getBookedDates($property->id);

        return [
            'property' => $property,
            'booked_dates' => $bookedDates,
            'amenities_list' => $this->formatAmenities($property->amenities ?? []),
        ];
    }

    /**
     * Check availability for specific dates
     */
    public function checkAvailability(int $propertyId, string $checkIn, string $checkOut): array
    {
        $property = $this->propertyRepo->find($propertyId);

        if (!$property) {
            return [
                'available' => false,
                'reason' => 'Property tidak ditemukan',
            ];
        }

        if (!$property->is_available) {
            return [
                'available' => false,
                'reason' => 'Property sedang tidak tersedia',
            ];
        }

        $isAvailable = $this->propertyRepo->isAvailableForDates($propertyId, $checkIn, $checkOut);

        if (!$isAvailable) {
            return [
                'available' => false,
                'reason' => 'Property sudah dibooking untuk tanggal tersebut',
            ];
        }

        return [
            'available' => true,
            'property' => $property,
        ];
    }

    /**
     * Calculate price for booking
     */
    public function calculatePrice(int $propertyId, string $checkIn, string $checkOut): ?array
    {
        $property = $this->propertyRepo->find($propertyId);

        if (!$property) {
            return null;
        }

        $checkInDate = Carbon::parse($checkIn);
        $checkOutDate = Carbon::parse($checkOut);
        $nights = $checkInDate->diffInDays($checkOutDate);

        if ($nights < 1) {
            return null;
        }

        $pricePerNight = $property->price_per_night;
        $subtotal = $pricePerNight * $nights;
        $serviceFeePercent = 5; // 5%
        $serviceFee = $subtotal * ($serviceFeePercent / 100);
        $totalPrice = $subtotal + $serviceFee;

        return [
            'property' => $property,
            'check_in' => $checkIn,
            'check_out' => $checkOut,
            'nights' => $nights,
            'price_per_night' => $pricePerNight,
            'subtotal' => $subtotal,
            'service_fee_percent' => $serviceFeePercent,
            'service_fee' => $serviceFee,
            'total_price' => $totalPrice,
            'formatted' => [
                'price_per_night' => $this->formatPrice($pricePerNight),
                'subtotal' => $this->formatPrice($subtotal),
                'service_fee' => $this->formatPrice($serviceFee),
                'total_price' => $this->formatPrice($totalPrice),
            ],
        ];
    }

    /**
     * Get featured properties for homepage
     */
    public function getFeaturedProperties(int $limit = 6): array
    {
        $properties = $this->propertyRepo->getFeatured($limit);

        return $properties->map(function ($property) {
            return [
                'id' => $property->id,
                'name' => $property->name,
                'slug' => $property->slug,
                'city' => $property->city,
                'price' => $property->price_per_night,
                'formatted_price' => $this->formatPrice($property->price_per_night),
                'thumbnail' => $property->thumbnail,
                'bedrooms' => $property->bedrooms,
                'bathrooms' => $property->bathrooms,
                'max_guests' => $property->max_guests,
            ];
        })->toArray();
    }

    /**
     * Format amenities to readable list
     */
    private function formatAmenities(array $amenities): array
    {
        $labels = [
            'wifi' => ['label' => 'WiFi Gratis', 'icon' => 'wifi'],
            'ac' => ['label' => 'AC', 'icon' => 'snowflake'],
            'parking' => ['label' => 'Parkir Gratis', 'icon' => 'car'],
            'pool' => ['label' => 'Kolam Renang', 'icon' => 'swimming-pool'],
            'kitchen' => ['label' => 'Dapur', 'icon' => 'utensils'],
            'tv' => ['label' => 'TV', 'icon' => 'tv'],
            'washer' => ['label' => 'Mesin Cuci', 'icon' => 'tshirt'],
            'hot_water' => ['label' => 'Air Panas', 'icon' => 'hot-tub'],
            'security' => ['label' => 'Keamanan 24 Jam', 'icon' => 'shield-alt'],
            'garden' => ['label' => 'Taman', 'icon' => 'leaf'],
            'balcony' => ['label' => 'Balkon', 'icon' => 'door-open'],
            'sea_view' => ['label' => 'Pemandangan Laut', 'icon' => 'water'],
            'mountain_view' => ['label' => 'Pemandangan Gunung', 'icon' => 'mountain'],
            'workspace' => ['label' => 'Area Kerja', 'icon' => 'laptop'],
        ];

        return collect($amenities)
            ->map(fn($amenity) => $labels[$amenity] ?? ['label' => ucfirst($amenity), 'icon' => 'check'])
            ->toArray();
    }

    /**
     * Format price to Indonesian Rupiah
     */
    private function formatPrice(float $price): string
    {
        return 'Rp ' . number_format($price, 0, ',', '.');
    }
}

BookingService

Service utama untuk menangani business logic booking:

<?php
// app/Services/BookingService.php

namespace App\\Services;

use App\\Models\\Booking;
use App\\Repositories\\Contracts\\BookingRepositoryInterface;
use App\\Repositories\\Contracts\\TransactionRepositoryInterface;
use Carbon\\Carbon;
use Illuminate\\Support\\Facades\\DB;
use Illuminate\\Support\\Facades\\Log;

class BookingService
{
    public function __construct(
        protected BookingRepositoryInterface $bookingRepo,
        protected TransactionRepositoryInterface $transactionRepo,
        protected PropertyService $propertyService,
        protected PaymentService $paymentService
    ) {}

    /**
     * Create new booking with payment
     */
    public function createBooking(array $data): array
    {
        // 1. Validate dates
        $validation = $this->validateBookingDates($data['check_in_date'], $data['check_out_date']);
        if (!$validation['valid']) {
            return [
                'success' => false,
                'error' => $validation['message'],
            ];
        }

        // 2. Check availability
        $availability = $this->propertyService->checkAvailability(
            $data['property_id'],
            $data['check_in_date'],
            $data['check_out_date']
        );

        if (!$availability['available']) {
            return [
                'success' => false,
                'error' => $availability['reason'],
            ];
        }

        // 3. Create booking and payment in transaction
        try {
            return DB::transaction(function () use ($data) {
                // Create booking
                $booking = $this->bookingRepo->createWithPricing($data);

                Log::info('Booking created', [
                    'booking_id' => $booking->id,
                    'booking_code' => $booking->booking_code,
                ]);

                // Create payment (Snap token)
                $payment = $this->paymentService->createPayment($booking);

                if (!$payment['success']) {
                    throw new \\Exception($payment['error'] ?? 'Failed to create payment');
                }

                return [
                    'success' => true,
                    'booking' => $booking->fresh(['property', 'transaction']),
                    'snap_token' => $payment['snap_token'],
                    'redirect_url' => $payment['redirect_url'],
                ];
            });
        } catch (\\Exception $e) {
            Log::error('Failed to create booking', [
                'error' => $e->getMessage(),
                'data' => $data,
            ]);

            return [
                'success' => false,
                'error' => 'Gagal membuat booking. Silakan coba lagi.',
            ];
        }
    }

    /**
     * Validate booking dates
     */
    private function validateBookingDates(string $checkIn, string $checkOut): array
    {
        $checkInDate = Carbon::parse($checkIn);
        $checkOutDate = Carbon::parse($checkOut);
        $today = Carbon::today();

        // Check-in must be today or future
        if ($checkInDate->lt($today)) {
            return [
                'valid' => false,
                'message' => 'Tanggal check-in tidak boleh di masa lalu',
            ];
        }

        // Check-out must be after check-in
        if ($checkOutDate->lte($checkInDate)) {
            return [
                'valid' => false,
                'message' => 'Tanggal check-out harus setelah tanggal check-in',
            ];
        }

        // Maximum stay (e.g., 30 days)
        $nights = $checkInDate->diffInDays($checkOutDate);
        if ($nights > 30) {
            return [
                'valid' => false,
                'message' => 'Maksimal durasi menginap adalah 30 malam',
            ];
        }

        // Minimum advance booking (e.g., booking harus minimal hari ini)
        // Bisa ditambahkan logic lain sesuai kebutuhan

        return ['valid' => true];
    }

    /**
     * Get booking details
     */
    public function getBookingDetails(string $bookingCode): ?Booking
    {
        return $this->bookingRepo->findByCode($bookingCode);
    }

    /**
     * Get user's bookings
     */
    public function getUserBookings(int $userId, int $perPage = 10)
    {
        return $this->bookingRepo->getByUser($userId, $perPage);
    }

    /**
     * Get upcoming bookings for user
     */
    public function getUpcomingBookings(int $userId)
    {
        return $this->bookingRepo->getUpcoming($userId);
    }

    /**
     * Cancel booking
     */
    public function cancelBooking(Booking $booking, ?string $reason = null): array
    {
        // Check if booking can be cancelled
        if (!$booking->canBeCancelled()) {
            return [
                'success' => false,
                'error' => 'Booking tidak dapat dibatalkan',
            ];
        }

        // Check cancellation deadline (e.g., H-1)
        if ($booking->check_in_date->diffInDays(now()) < 1) {
            return [
                'success' => false,
                'error' => 'Pembatalan harus dilakukan minimal H-1 sebelum check-in',
            ];
        }

        try {
            DB::transaction(function () use ($booking, $reason) {
                // Update booking status
                $this->bookingRepo->updateStatus($booking->id, 'cancelled', [
                    'cancel_reason' => $reason,
                ]);

                // Cancel transaction in Midtrans if exists
                if ($booking->transaction && $booking->transaction->order_id) {
                    $this->paymentService->cancelPayment($booking->transaction->order_id);
                }

                Log::info('Booking cancelled', [
                    'booking_id' => $booking->id,
                    'booking_code' => $booking->booking_code,
                    'reason' => $reason,
                ]);
            });

            return [
                'success' => true,
                'message' => 'Booking berhasil dibatalkan',
            ];
        } catch (\\Exception $e) {
            Log::error('Failed to cancel booking', [
                'booking_id' => $booking->id,
                'error' => $e->getMessage(),
            ]);

            return [
                'success' => false,
                'error' => 'Gagal membatalkan booking',
            ];
        }
    }

    /**
     * Confirm booking (by owner)
     */
    public function confirmBooking(Booking $booking): array
    {
        if ($booking->status !== 'paid') {
            return [
                'success' => false,
                'error' => 'Hanya booking yang sudah dibayar yang dapat dikonfirmasi',
            ];
        }

        try {
            $this->bookingRepo->updateStatus($booking->id, 'confirmed');

            Log::info('Booking confirmed', [
                'booking_id' => $booking->id,
                'booking_code' => $booking->booking_code,
            ]);

            // TODO: Send confirmation email to guest

            return [
                'success' => true,
                'message' => 'Booking berhasil dikonfirmasi',
            ];
        } catch (\\Exception $e) {
            return [
                'success' => false,
                'error' => 'Gagal mengkonfirmasi booking',
            ];
        }
    }

    /**
     * Complete booking (after checkout)
     */
    public function completeBooking(Booking $booking): array
    {
        if ($booking->status !== 'confirmed') {
            return [
                'success' => false,
                'error' => 'Hanya booking yang sudah dikonfirmasi yang dapat diselesaikan',
            ];
        }

        try {
            $this->bookingRepo->updateStatus($booking->id, 'completed');

            return [
                'success' => true,
                'message' => 'Booking selesai',
            ];
        } catch (\\Exception $e) {
            return [
                'success' => false,
                'error' => 'Gagal menyelesaikan booking',
            ];
        }
    }

    /**
     * Get booking price breakdown
     */
    public function getPriceBreakdown(Booking $booking): array
    {
        return [
            'property_name' => $booking->property->name,
            'check_in' => $booking->check_in_date->format('d M Y'),
            'check_out' => $booking->check_out_date->format('d M Y'),
            'nights' => $booking->nights,
            'guests' => $booking->guests,
            'price_per_night' => [
                'value' => $booking->price_per_night,
                'formatted' => 'Rp ' . number_format($booking->price_per_night, 0, ',', '.'),
            ],
            'subtotal' => [
                'value' => $booking->subtotal,
                'formatted' => 'Rp ' . number_format($booking->subtotal, 0, ',', '.'),
            ],
            'service_fee' => [
                'value' => $booking->service_fee,
                'formatted' => 'Rp ' . number_format($booking->service_fee, 0, ',', '.'),
            ],
            'total' => [
                'value' => $booking->total_price,
                'formatted' => 'Rp ' . number_format($booking->total_price, 0, ',', '.'),
            ],
        ];
    }
}

PaymentService

Service untuk menangani payment dan integrasi dengan Midtrans:

<?php
// app/Services/PaymentService.php

namespace App\\Services;

use App\\Models\\Booking;
use App\\Models\\PaymentLog;
use App\\Repositories\\Contracts\\BookingRepositoryInterface;
use App\\Repositories\\Contracts\\TransactionRepositoryInterface;
use Illuminate\\Support\\Facades\\DB;
use Illuminate\\Support\\Facades\\Log;

class PaymentService
{
    public function __construct(
        protected TransactionRepositoryInterface $transactionRepo,
        protected BookingRepositoryInterface $bookingRepo,
        protected MidtransService $midtransService
    ) {}

    /**
     * Create payment for booking
     */
    public function createPayment(Booking $booking): array
    {
        $orderId = $this->transactionRepo->generateOrderId($booking->id);

        // Get Snap token from Midtrans
        $snapResult = $this->midtransService->createSnapToken($booking, $orderId);

        if (!$snapResult['success']) {
            return [
                'success' => false,
                'error' => $snapResult['error'],
            ];
        }

        // Create transaction record
        $transaction = $this->transactionRepo->createWithSnapToken([
            'booking_id' => $booking->id,
            'order_id' => $orderId,
            'gross_amount' => $booking->total_price,
            'snap_token' => $snapResult['token'],
            'snap_redirect_url' => $snapResult['redirect_url'],
        ]);

        return [
            'success' => true,
            'transaction' => $transaction,
            'snap_token' => $snapResult['token'],
            'redirect_url' => $snapResult['redirect_url'],
        ];
    }

    /**
     * Handle notification from Midtrans
     */
    public function handleNotification(array $notification, string $ipAddress = null): array
    {
        $orderId = $notification['order_id'] ?? null;

        // Log incoming notification
        $this->logPaymentEvent($orderId, 'notification', $notification, $ipAddress);

        // Verify signature
        if (!$this->midtransService->verifySignature($notification)) {
            Log::warning('Invalid signature for notification', ['order_id' => $orderId]);

            return [
                'success' => false,
                'error' => 'Invalid signature',
            ];
        }

        // Find transaction
        $transaction = $this->transactionRepo->findByOrderId($orderId);

        if (!$transaction) {
            Log::warning('Transaction not found', ['order_id' => $orderId]);

            return [
                'success' => false,
                'error' => 'Transaction not found',
            ];
        }

        // Process notification
        try {
            DB::transaction(function () use ($transaction, $notification) {
                $transactionStatus = $notification['transaction_status'];
                $fraudStatus = $notification['fraud_status'] ?? null;

                // Update transaction
                $this->transactionRepo->updateFromMidtrans($transaction->order_id, $notification);

                // Map to booking status
                $bookingStatus = $this->midtransService->mapToBookingStatus($transactionStatus, $fraudStatus);

                // Update booking if status changed
                $booking = $transaction->booking;
                $additionalData = [];

                if ($bookingStatus === 'paid' && $booking->status === 'pending') {
                    $additionalData['paid_at'] = now();
                }

                if ($booking->status !== $bookingStatus) {
                    $this->bookingRepo->updateStatus($booking->id, $bookingStatus, $additionalData);

                    Log::info('Booking status updated from payment', [
                        'booking_id' => $booking->id,
                        'old_status' => $booking->status,
                        'new_status' => $bookingStatus,
                        'transaction_status' => $transactionStatus,
                    ]);
                }

                // TODO: Send email notification based on status
                // if ($bookingStatus === 'paid') {
                //     SendBookingPaidEmail::dispatch($booking);
                // }
            });

            return [
                'success' => true,
                'message' => 'Notification processed',
            ];
        } catch (\\Exception $e) {
            Log::error('Failed to process notification', [
                'order_id' => $orderId,
                'error' => $e->getMessage(),
            ]);

            return [
                'success' => false,
                'error' => 'Failed to process notification',
            ];
        }
    }

    /**
     * Check payment status
     */
    public function checkPaymentStatus(string $orderId): ?array
    {
        $status = $this->midtransService->getTransactionStatus($orderId);

        if ($status) {
            // Update local transaction
            $this->transactionRepo->updateFromMidtrans($orderId, $status);
        }

        return $status;
    }

    /**
     * Cancel payment
     */
    public function cancelPayment(string $orderId): bool
    {
        return $this->midtransService->cancelTransaction($orderId);
    }

    /**
     * Log payment event
     */
    private function logPaymentEvent(
        ?string $orderId,
        string $eventType,
        array $payload,
        ?string $ipAddress = null,
        bool $signatureVerified = false
    ): void {
        $transaction = $orderId ? $this->transactionRepo->findByOrderId($orderId) : null;

        PaymentLog::create([
            'transaction_id' => $transaction?->id,
            'order_id' => $orderId,
            'event_type' => $eventType,
            'payload' => $payload,
            'signature_verified' => $signatureVerified,
            'ip_address' => $ipAddress,
            'user_agent' => request()->userAgent(),
        ]);
    }

    /**
     * Get payment details for display
     */
    public function getPaymentDetails(Booking $booking): ?array
    {
        $transaction = $booking->transaction;

        if (!$transaction) {
            return null;
        }

        return [
            'order_id' => $transaction->order_id,
            'status' => $transaction->transaction_status,
            'payment_type' => $transaction->payment_type_label,
            'amount' => [
                'value' => $transaction->gross_amount,
                'formatted' => 'Rp ' . number_format($transaction->gross_amount, 0, ',', '.'),
            ],
            'va_number' => $transaction->va_number,
            'bank' => $transaction->bank ? strtoupper($transaction->bank) : null,
            'expiry_time' => $transaction->expiry_time,
            'snap_token' => $transaction->snap_token,
            'transaction_time' => $transaction->transaction_time?->format('d M Y H:i'),
            'settlement_time' => $transaction->settlement_time?->format('d M Y H:i'),
        ];
    }
}

Register Services

Services di Laravel 11+ otomatis di-resolve melalui constructor injection. Tapi untuk singleton, tambahkan di AppServiceProvider:

<?php
// app/Providers/AppServiceProvider.php

namespace App\\Providers;

use App\\Services\\MidtransService;
use Illuminate\\Support\\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // Register MidtransService as singleton
        $this->app->singleton(MidtransService::class, function ($app) {
            return new MidtransService();
        });
    }

    public function boot(): void
    {
        //
    }
}

Usage di Controller

Contoh penggunaan services di controller:

<?php

namespace App\\Http\\Controllers;

use App\\Services\\BookingService;
use App\\Services\\PropertyService;
use Illuminate\\Http\\Request;

class BookingController extends Controller
{
    public function __construct(
        protected BookingService $bookingService,
        protected PropertyService $propertyService
    ) {}

    public function store(Request $request)
    {
        $validated = $request->validate([
            'property_id' => 'required|exists:properties,id',
            'check_in_date' => 'required|date|after_or_equal:today',
            'check_out_date' => 'required|date|after:check_in_date',
            'guests' => 'required|integer|min:1',
        ]);

        $validated['user_id'] = auth()->id();

        $result = $this->bookingService->createBooking($validated);

        if (!$result['success']) {
            return back()->withErrors(['error' => $result['error']]);
        }

        return view('bookings.payment', [
            'booking' => $result['booking'],
            'snap_token' => $result['snap_token'],
        ]);
    }
}


Service Layer sudah siap dengan complete business logic. Di bagian selanjutnya, kita akan mengintegrasikan Midtrans Snap di frontend dan membuat payment page yang functional.

Bagian 6: Integrasi Midtrans Snap

Sekarang kita akan mengintegrasikan Midtrans Snap di frontend. Snap adalah popup payment dari Midtrans yang sudah siap pakai.

Setup Akun Midtrans

  1. Daftar di Midtrans: Buka dashboard.midtrans.com dan daftar
  2. Masuk ke Sandbox: Pastikan environment adalah "Sandbox"
  3. Dapatkan API Keys: Settings โ†’ Access Keys โ†’ Copy Server Key dan Client Key

Update .env

MIDTRANS_SERVER_KEY=SB-Mid-server-xxxxxxxxxxxxx
MIDTRANS_CLIENT_KEY=SB-Mid-client-xxxxxxxxxxxxx
MIDTRANS_IS_PRODUCTION=false

BookingController

<?php
// app/Http/Controllers/BookingController.php

namespace App\\Http\\Controllers;

use App\\Http\\Requests\\CreateBookingRequest;
use App\\Models\\Booking;
use App\\Models\\Property;
use App\\Services\\BookingService;
use App\\Services\\PaymentService;
use App\\Services\\PropertyService;
use Illuminate\\Http\\Request;

class BookingController extends Controller
{
    public function __construct(
        protected BookingService $bookingService,
        protected PropertyService $propertyService,
        protected PaymentService $paymentService
    ) {}

    public function create(Property $property, Request $request)
    {
        $request->validate([
            'check_in' => 'required|date|after_or_equal:today',
            'check_out' => 'required|date|after:check_in',
            'guests' => 'required|integer|min:1|max:' . $property->max_guests,
        ]);

        $pricing = $this->propertyService->calculatePrice(
            $property->id,
            $request->check_in,
            $request->check_out
        );

        return view('bookings.create', [
            'property' => $property,
            'pricing' => $pricing,
            'guests' => $request->guests,
        ]);
    }

    public function store(CreateBookingRequest $request)
    {
        $result = $this->bookingService->createBooking([
            'user_id' => auth()->id(),
            'property_id' => $request->property_id,
            'check_in_date' => $request->check_in_date,
            'check_out_date' => $request->check_out_date,
            'guests' => $request->guests,
            'notes' => $request->notes,
        ]);

        if (!$result['success']) {
            return back()->withErrors(['error' => $result['error']]);
        }

        return redirect()->route('bookings.payment', $result['booking']);
    }

    public function payment(Booking $booking)
    {
        if ($booking->user_id !== auth()->id()) {
            abort(403);
        }

        if ($booking->status !== 'pending') {
            return redirect()->route('bookings.show', $booking);
        }

        $booking->load(['property', 'transaction']);
        $snapToken = $booking->transaction?->snap_token;

        return view('bookings.payment', [
            'booking' => $booking,
            'snap_token' => $snapToken,
            'client_key' => config('midtrans.client_key'),
            'snap_url' => config('midtrans.snap_url'),
            'price_breakdown' => $this->bookingService->getPriceBreakdown($booking),
        ]);
    }

    public function finish(Request $request)
    {
        $orderId = $request->order_id;
        $transactionStatus = $request->transaction_status;

        $transaction = app(\\App\\Repositories\\Contracts\\TransactionRepositoryInterface::class)
            ->findByOrderId($orderId);

        if (!$transaction) {
            return redirect()->route('home')->with('error', 'Transaksi tidak ditemukan');
        }

        $booking = $transaction->booking;

        return match ($transactionStatus) {
            'capture', 'settlement' => redirect()->route('bookings.success', $booking),
            'pending' => redirect()->route('bookings.pending', $booking),
            default => redirect()->route('bookings.show', $booking),
        };
    }

    public function success(Booking $booking)
    {
        if ($booking->user_id !== auth()->id()) {
            abort(403);
        }

        return view('bookings.success', [
            'booking' => $booking->load(['property', 'transaction']),
            'price_breakdown' => $this->bookingService->getPriceBreakdown($booking),
        ]);
    }

    public function pending(Booking $booking)
    {
        if ($booking->user_id !== auth()->id()) {
            abort(403);
        }

        return view('bookings.pending', [
            'booking' => $booking->load(['property', 'transaction']),
            'payment_details' => $this->paymentService->getPaymentDetails($booking),
        ]);
    }

    public function checkStatus(Booking $booking)
    {
        if ($booking->user_id !== auth()->id()) {
            return response()->json(['error' => 'Unauthorized'], 403);
        }

        $transaction = $booking->transaction;
        $status = $this->paymentService->checkPaymentStatus($transaction->order_id);

        return response()->json([
            'transaction_status' => $status['transaction_status'] ?? $transaction->transaction_status,
            'booking_status' => $booking->fresh()->status,
        ]);
    }
}

Routes

// routes/web.php

use App\\Http\\Controllers\\BookingController;
use App\\Http\\Controllers\\WebhookController;

Route::middleware(['auth'])->group(function () {
    Route::get('/properties/{property}/book', [BookingController::class, 'create'])->name('bookings.create');
    Route::post('/bookings', [BookingController::class, 'store'])->name('bookings.store');
    Route::get('/bookings/{booking}/payment', [BookingController::class, 'payment'])->name('bookings.payment');
    Route::get('/bookings/{booking}/success', [BookingController::class, 'success'])->name('bookings.success');
    Route::get('/bookings/{booking}/pending', [BookingController::class, 'pending'])->name('bookings.pending');
    Route::get('/bookings/finish', [BookingController::class, 'finish'])->name('bookings.finish');
    Route::get('/bookings/{booking}/check-status', [BookingController::class, 'checkStatus'])->name('bookings.check-status');
});

// Webhook tanpa auth dan CSRF
Route::post('/midtrans/notification', [WebhookController::class, 'handle'])
    ->name('midtrans.notification')
    ->withoutMiddleware(['web']);

Payment View dengan Snap

{{-- resources/views/bookings/payment.blade.php --}}

@extends('layouts.app')

@section('content')
<div class="max-w-4xl mx-auto py-8 px-4">
    <div class="grid grid-cols-1 lg:grid-cols-2 gap-8">

        {{-- Booking Summary --}}
        <div class="bg-white rounded-lg shadow-md p-6">
            <h2 class="text-xl font-semibold mb-4">Ringkasan Booking</h2>

            <div class="flex gap-4 mb-6">
                <img src="{{ $booking->property->thumbnail }}"
                     alt="{{ $booking->property->name }}"
                     class="w-24 h-24 rounded-lg object-cover">
                <div>
                    <h3 class="font-semibold">{{ $booking->property->name }}</h3>
                    <p class="text-gray-600 text-sm">{{ $booking->property->city }}</p>
                </div>
            </div>

            <div class="space-y-3 text-sm">
                <div class="flex justify-between">
                    <span class="text-gray-600">Kode Booking</span>
                    <span class="font-mono font-semibold">{{ $booking->booking_code }}</span>
                </div>
                <div class="flex justify-between">
                    <span class="text-gray-600">Check-in</span>
                    <span>{{ $price_breakdown['check_in'] }}</span>
                </div>
                <div class="flex justify-between">
                    <span class="text-gray-600">Check-out</span>
                    <span>{{ $price_breakdown['check_out'] }}</span>
                </div>
                <div class="flex justify-between">
                    <span class="text-gray-600">Durasi</span>
                    <span>{{ $price_breakdown['nights'] }} malam</span>
                </div>
            </div>

            <div class="border-t mt-4 pt-4 space-y-2 text-sm">
                <div class="flex justify-between">
                    <span>{{ $price_breakdown['price_per_night']['formatted'] }} ร— {{ $price_breakdown['nights'] }} malam</span>
                    <span>{{ $price_breakdown['subtotal']['formatted'] }}</span>
                </div>
                <div class="flex justify-between">
                    <span>Biaya layanan</span>
                    <span>{{ $price_breakdown['service_fee']['formatted'] }}</span>
                </div>
                <div class="flex justify-between font-semibold text-base border-t pt-2">
                    <span>Total</span>
                    <span>{{ $price_breakdown['total']['formatted'] }}</span>
                </div>
            </div>
        </div>

        {{-- Payment Section --}}
        <div class="bg-white rounded-lg shadow-md p-6">
            <h2 class="text-xl font-semibold mb-4">Pembayaran</h2>

            <p class="text-gray-600 mb-6">
                Klik tombol di bawah untuk memilih metode pembayaran.
            </p>

            <button id="pay-button"
                    class="w-full bg-blue-600 text-white py-3 px-4 rounded-lg font-semibold hover:bg-blue-700">
                Bayar Sekarang
            </button>

            <div class="mt-6">
                <p class="text-sm text-gray-500 mb-3">Metode pembayaran tersedia:</p>
                <div class="flex flex-wrap gap-2">
                    <span class="px-2 py-1 bg-gray-100 rounded text-xs">Bank Transfer</span>
                    <span class="px-2 py-1 bg-gray-100 rounded text-xs">GoPay</span>
                    <span class="px-2 py-1 bg-gray-100 rounded text-xs">ShopeePay</span>
                    <span class="px-2 py-1 bg-gray-100 rounded text-xs">QRIS</span>
                    <span class="px-2 py-1 bg-gray-100 rounded text-xs">Credit Card</span>
                </div>
            </div>
        </div>

    </div>
</div>
@endsection

@section('scripts')
<script src="{{ $snap_url }}" data-client-key="{{ $client_key }}"></script>

<script>
document.getElementById('pay-button').addEventListener('click', function() {
    this.disabled = true;
    this.textContent = 'Memproses...';

    snap.pay('{{ $snap_token }}', {
        onSuccess: function(result) {
            window.location.href = '{{ route("bookings.success", $booking) }}';
        },
        onPending: function(result) {
            window.location.href = '{{ route("bookings.pending", $booking) }}';
        },
        onError: function(result) {
            alert('Pembayaran gagal. Silakan coba lagi.');
            document.getElementById('pay-button').disabled = false;
            document.getElementById('pay-button').textContent = 'Bayar Sekarang';
        },
        onClose: function() {
            document.getElementById('pay-button').disabled = false;
            document.getElementById('pay-button').textContent = 'Bayar Sekarang';
        }
    });
});
</script>
@endsection

Success View

{{-- resources/views/bookings/success.blade.php --}}

@extends('layouts.app')

@section('content')
<div class="max-w-2xl mx-auto py-12 px-4 text-center">

    <div class="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-6">
        <svg class="w-10 h-10 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
        </svg>
    </div>

    <h1 class="text-2xl font-bold mb-2">Pembayaran Berhasil!</h1>
    <p class="text-gray-600 mb-8">Booking Anda telah dikonfirmasi.</p>

    <div class="bg-white rounded-lg shadow-md p-6 text-left mb-8">
        <div class="flex justify-between items-start mb-4">
            <div>
                <p class="text-sm text-gray-500">Kode Booking</p>
                <p class="text-xl font-mono font-bold">{{ $booking->booking_code }}</p>
            </div>
            <span class="px-3 py-1 bg-green-100 text-green-800 text-sm rounded-full">
                {{ ucfirst($booking->status) }}
            </span>
        </div>

        <div class="border-t pt-4">
            <h3 class="font-semibold mb-2">{{ $booking->property->name }}</h3>
            <div class="grid grid-cols-2 gap-4 text-sm">
                <div>
                    <p class="text-gray-500">Check-in</p>
                    <p class="font-medium">{{ $price_breakdown['check_in'] }}</p>
                </div>
                <div>
                    <p class="text-gray-500">Check-out</p>
                    <p class="font-medium">{{ $price_breakdown['check_out'] }}</p>
                </div>
                <div>
                    <p class="text-gray-500">Total Dibayar</p>
                    <p class="font-medium">{{ $price_breakdown['total']['formatted'] }}</p>
                </div>
            </div>
        </div>
    </div>

    <div class="flex gap-4 justify-center">
        <a href="{{ route('bookings.show', $booking) }}" class="px-6 py-2 bg-blue-600 text-white rounded-lg">
            Lihat Detail
        </a>
        <a href="{{ route('home') }}" class="px-6 py-2 border rounded-lg">
            Kembali
        </a>
    </div>

</div>
@endsection

Pending View

{{-- resources/views/bookings/pending.blade.php --}}

@extends('layouts.app')

@section('content')
<div class="max-w-2xl mx-auto py-12 px-4">

    <div class="text-center mb-8">
        <div class="w-20 h-20 bg-yellow-100 rounded-full flex items-center justify-center mx-auto mb-6">
            <svg class="w-10 h-10 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
            </svg>
        </div>

        <h1 class="text-2xl font-bold mb-2">Menunggu Pembayaran</h1>
        <p class="text-gray-600">Selesaikan pembayaran sesuai instruksi</p>
    </div>

    @if($payment_details && $payment_details['va_number'])
    <div class="bg-white rounded-lg shadow-md p-6 mb-6">
        <h3 class="font-semibold mb-4">Instruksi Pembayaran</h3>

        <div class="bg-gray-50 rounded-lg p-4 mb-4">
            <p class="text-sm text-gray-500 mb-1">Nomor Virtual Account ({{ $payment_details['bank'] }})</p>
            <p class="text-2xl font-mono font-bold">{{ $payment_details['va_number'] }}</p>
        </div>

        <div class="grid grid-cols-2 gap-4 text-sm">
            <div>
                <p class="text-gray-500">Total Pembayaran</p>
                <p class="font-semibold text-lg">{{ $payment_details['amount']['formatted'] }}</p>
            </div>
            <div>
                <p class="text-gray-500">Metode</p>
                <p class="font-medium">{{ $payment_details['payment_type'] }}</p>
            </div>
        </div>
    </div>
    @endif

    <div class="text-center">
        <button onclick="checkPaymentStatus()" id="check-status-btn"
                class="px-6 py-2 bg-blue-600 text-white rounded-lg">
            Cek Status Pembayaran
        </button>
    </div>

</div>
@endsection

@section('scripts')
<script>
function checkPaymentStatus() {
    const btn = document.getElementById('check-status-btn');
    btn.disabled = true;
    btn.textContent = 'Memeriksa...';

    fetch('{{ route("bookings.check-status", $booking) }}')
        .then(response => response.json())
        .then(data => {
            if (data.booking_status === 'paid' || data.transaction_status === 'settlement') {
                window.location.href = '{{ route("bookings.success", $booking) }}';
            } else {
                btn.disabled = false;
                btn.textContent = 'Cek Status Pembayaran';
                alert('Pembayaran belum diterima.');
            }
        });
}

// Auto check every 30 seconds
setInterval(checkPaymentStatus, 30000);
</script>
@endsection

Test Cards (Sandbox)

CREDIT CARD SUCCESS:
Card Number: 4811 1111 1111 1114
Exp Date:    12/25
CVV:         123
OTP:         112233

CREDIT CARD FAILURE:
Card Number: 4911 1111 1111 1113

Testing Flow

1. Buat booking โ†’ Pilih property โ†’ Isi tanggal
2. Halaman payment โ†’ Klik "Bayar Sekarang"
3. Snap popup โ†’ Pilih payment method
4. Complete payment โ†’ Redirect success/pending
5. Verify di database dan Midtrans Dashboard


Midtrans Snap sudah terintegrasi. Di bagian selanjutnya, kita akan setup Ngrok untuk testing webhook di local development.

Bagian 7: Ngrok dan Webhook Testing

Salah satu tantangan saat development payment gateway adalah testing webhook. Midtrans perlu mengirim notification ke server kita ketika ada update status pembayaran, tapi localhost tidak bisa diakses dari internet. Di sinilah Ngrok berperan.

Apa itu Webhook?

Webhook adalah mekanisme server-to-server communication dimana Midtrans akan mengirim HTTP POST request ke URL yang kita tentukan setiap kali ada perubahan status transaksi.

WEBHOOK FLOW:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                                                                      โ”‚
โ”‚  1. User complete payment di Snap                                   โ”‚
โ”‚                     โ”‚                                                โ”‚
โ”‚                     โ–ผ                                                โ”‚
โ”‚  2. Midtrans process payment                                        โ”‚
โ”‚                     โ”‚                                                โ”‚
โ”‚                     โ–ผ                                                โ”‚
โ”‚  3. Midtrans kirim HTTP POST ke Notification URL                    โ”‚
โ”‚     POST <https://your-domain.com/midtrans/notification>              โ”‚
โ”‚     {                                                                โ”‚
โ”‚       "transaction_status": "settlement",                           โ”‚
โ”‚       "order_id": "SEWA-123-1234567890",                            โ”‚
โ”‚       "gross_amount": "1500000.00",                                 โ”‚
โ”‚       "payment_type": "bank_transfer",                              โ”‚
โ”‚       ...                                                           โ”‚
โ”‚     }                                                                โ”‚
โ”‚                     โ”‚                                                โ”‚
โ”‚                     โ–ผ                                                โ”‚
โ”‚  4. Server kita terima notification                                 โ”‚
โ”‚     - Verify signature                                              โ”‚
โ”‚     - Update transaction status                                     โ”‚
โ”‚     - Update booking status                                         โ”‚
โ”‚     - Send email notification                                       โ”‚
โ”‚                     โ”‚                                                โ”‚
โ”‚                     โ–ผ                                                โ”‚
โ”‚  5. Return HTTP 200 OK ke Midtrans                                  โ”‚
โ”‚                                                                      โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Masalah: Localhost Tidak Accessible

PROBLEM:

Internet                          Your Computer
โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€                         โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

Midtrans Server โ”€โ”€โ”€โ”€ X โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ localhost:8000

Midtrans tidak bisa akses localhost karena:
โ”œโ”€โ”€ localhost hanya accessible dari komputer sendiri
โ”œโ”€โ”€ Tidak punya public IP
โ””โ”€โ”€ Firewall/NAT blocking incoming connections

Solusi: Ngrok

Ngrok membuat tunnel dari internet ke localhost kamu. Dengan Ngrok, Midtrans bisa mengirim webhook ke localhost.

DENGAN NGROK:

Internet                          Your Computer
โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€                         โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

Midtrans Server                   localhost:8000
      โ”‚                                 โ–ฒ
      โ”‚                                 โ”‚
      โ–ผ                                 โ”‚
<https://abc123.ngrok.io> โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

      (Ngrok tunnel)

Flow:
1. Midtrans POST ke <https://abc123.ngrok.io/midtrans/notification>
2. Ngrok forward ke localhost:8000/midtrans/notification
3. Laravel handle request
4. Response balik via tunnel

Install Ngrok

Step 1: Daftar Akun Ngrok

  1. Buka ngrok.com
  2. Klik "Sign up" (gratis)
  3. Verify email
  4. Login ke dashboard

Step 2: Download dan Install

# MacOS (dengan Homebrew)
brew install ngrok

# Linux
curl -s <https://ngrok-agent.s3.amazonaws.com/ngrok.asc> | \\
  sudo tee /etc/apt/trusted.gpg.d/ngrok.asc >/dev/null && \\
  echo "deb <https://ngrok-agent.s3.amazonaws.com> buster main" | \\
  sudo tee /etc/apt/sources.list.d/ngrok.list && \\
  sudo apt update && sudo apt install ngrok

# Windows (dengan Chocolatey)
choco install ngrok

# Atau download manual dari ngrok.com/download

Step 3: Setup Auth Token

Dapatkan auth token dari dashboard.ngrok.com/get-started/your-authtoken

ngrok config add-authtoken YOUR_AUTH_TOKEN

Jalankan Ngrok

Terminal 1: Jalankan Laravel

cd sewa-rumah
php artisan serve
# Server running on <http://127.0.0.1:8000>

Terminal 2: Jalankan Ngrok

ngrok http 8000

Output:

ngrok

Session Status                online
Account                       [email protected] (Plan: Free)
Version                       3.x.x
Region                        Asia Pacific (ap)
Latency                       45ms
Web Interface                 <http://127.0.0.1:4040>
Forwarding                    <https://abc123.ngrok-free.app> -> <http://localhost:8000>

Connections                   ttl     opn     rt1     rt5     p50     p90
                              0       0       0.00    0.00    0.00    0.00

Catat URL forwarding: https://abc123.ngrok-free.app

Update Midtrans Notification URL

Di Midtrans Dashboard:

  1. Login ke dashboard.midtrans.com
  2. Pastikan di environment Sandbox
  3. Pergi ke Settings โ†’ Configuration
  4. Update Payment Notification URL: <https://abc123.ngrok-free.app/midtrans/notification>
  5. Update Finish Redirect URL: <https://abc123.ngrok-free.app/bookings/finish>
  6. Klik Save
MIDTRANS CONFIGURATION:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                                                                      โ”‚
โ”‚  Payment Notification URL:                                          โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”    โ”‚
โ”‚  โ”‚ <https://abc123.ngrok-free.app/midtrans/notification>         โ”‚    โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜    โ”‚
โ”‚                                                                      โ”‚
โ”‚  Finish Redirect URL:                                               โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”    โ”‚
โ”‚  โ”‚ <https://abc123.ngrok-free.app/bookings/finish>               โ”‚    โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜    โ”‚
โ”‚                                                                      โ”‚
โ”‚  Unfinish Redirect URL:                                             โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”    โ”‚
โ”‚  โ”‚ <https://abc123.ngrok-free.app/bookings/finish>               โ”‚    โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜    โ”‚
โ”‚                                                                      โ”‚
โ”‚  Error Redirect URL:                                                โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”    โ”‚
โ”‚  โ”‚ <https://abc123.ngrok-free.app/bookings/finish>               โ”‚    โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜    โ”‚
โ”‚                                                                      โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Buat WebhookController

<?php
// app/Http/Controllers/WebhookController.php

namespace App\\Http\\Controllers;

use App\\Services\\PaymentService;
use Illuminate\\Http\\Request;
use Illuminate\\Support\\Facades\\Log;

class WebhookController extends Controller
{
    public function __construct(
        protected PaymentService $paymentService
    ) {}

    /**
     * Handle Midtrans notification webhook
     */
    public function handle(Request $request)
    {
        // Log incoming request
        Log::channel('webhook')->info('Midtrans webhook received', [
            'payload' => $request->all(),
            'ip' => $request->ip(),
            'user_agent' => $request->userAgent(),
        ]);

        try {
            $notification = $request->all();

            // Validate required fields
            if (empty($notification['order_id'])) {
                Log::warning('Webhook missing order_id');
                return response()->json(['status' => 'error', 'message' => 'Missing order_id'], 400);
            }

            // Process notification
            $result = $this->paymentService->handleNotification(
                $notification,
                $request->ip()
            );

            if ($result['success']) {
                Log::info('Webhook processed successfully', [
                    'order_id' => $notification['order_id'],
                ]);
                return response()->json(['status' => 'ok']);
            }

            Log::warning('Webhook processing failed', [
                'order_id' => $notification['order_id'],
                'error' => $result['error'] ?? 'Unknown error',
            ]);

            return response()->json([
                'status' => 'error',
                'message' => $result['error'] ?? 'Processing failed'
            ], 500);

        } catch (\\Exception $e) {
            Log::error('Webhook exception', [
                'error' => $e->getMessage(),
                'trace' => $e->getTraceAsString(),
                'payload' => $request->all(),
            ]);

            return response()->json([
                'status' => 'error',
                'message' => 'Internal server error'
            ], 500);
        }
    }
}

Setup Webhook Log Channel

Buat dedicated log channel untuk webhook:

<?php
// config/logging.php

'channels' => [
    // ... existing channels

    'webhook' => [
        'driver' => 'daily',
        'path' => storage_path('logs/webhook.log'),
        'level' => 'debug',
        'days' => 30,
    ],
],

Exclude Webhook dari CSRF

Webhook dari Midtrans tidak akan punya CSRF token. Exclude route ini:

Laravel 11+:

<?php
// bootstrap/app.php

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        api: __DIR__.'/../routes/api.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware) {
        $middleware->validateCsrfTokens(except: [
            'midtrans/*',  // Exclude semua route midtrans
        ]);
    })
    ->withExceptions(function (Exceptions $exceptions) {
        //
    })->create();

Laravel 10 dan sebelumnya:

<?php
// app/Http/Middleware/VerifyCsrfToken.php

namespace App\\Http\\Middleware;

use Illuminate\\Foundation\\Http\\Middleware\\VerifyCsrfToken as Middleware;

class VerifyCsrfToken extends Middleware
{
    protected $except = [
        'midtrans/*',
    ];
}

Ngrok Web Interface

Ngrok menyediakan web interface untuk monitoring di http://127.0.0.1:4040

NGROK INSPECTOR:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  ngrok Web Interface - <http://127.0.0.1:4040>                        โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚                                                                      โ”‚
โ”‚  Requests:                                                          โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”‚
โ”‚  โ”‚ POST /midtrans/notification    200 OK    45ms                 โ”‚  โ”‚
โ”‚  โ”‚ POST /midtrans/notification    200 OK    38ms                 โ”‚  โ”‚
โ”‚  โ”‚ GET  /bookings/123/payment     200 OK    120ms                โ”‚  โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ”‚
โ”‚                                                                      โ”‚
โ”‚  Click request to see details:                                      โ”‚
โ”‚  โ”œโ”€โ”€ Request headers                                                โ”‚
โ”‚  โ”œโ”€โ”€ Request body (JSON payload)                                    โ”‚
โ”‚  โ”œโ”€โ”€ Response headers                                               โ”‚
โ”‚  โ”œโ”€โ”€ Response body                                                  โ”‚
โ”‚  โ””โ”€โ”€ Timing information                                             โ”‚
โ”‚                                                                      โ”‚
โ”‚  Features:                                                          โ”‚
โ”‚  โ”œโ”€โ”€ ๐Ÿ”„ Replay - Replay any request                                โ”‚
โ”‚  โ”œโ”€โ”€ ๐Ÿ” Inspect - View full request/response                       โ”‚
โ”‚  โ””โ”€โ”€ ๐Ÿ“Š Stats - Connection statistics                              โ”‚
โ”‚                                                                      โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Fitur yang berguna:

  • Inspect: Lihat detail setiap request (headers, body, response)
  • Replay: Kirim ulang request untuk testing
  • Filter: Filter request berdasarkan path atau status

Testing Webhook

Method 1: Test via Midtrans Dashboard

  1. Buat booking dan payment di aplikasi
  2. Di Midtrans Dashboard โ†’ Transactions โ†’ Pilih transaksi
  3. Klik "Resend Notification"

Method 2: Simulate Payment di Sandbox

Untuk Bank Transfer/VA:

  1. Buat booking dan pilih Bank Transfer
  2. Di Midtrans Dashboard โ†’ Sandbox โ†’ Simulator
  3. Masukkan VA number dan amount
  4. Klik "Pay"
  5. Webhook akan terkirim

Method 3: Manual Testing dengan cURL

# Simulate settlement notification
curl -X POST <https://abc123.ngrok-free.app/midtrans/notification> \\
  -H "Content-Type: application/json" \\
  -d '{
    "transaction_time": "2024-01-15 10:30:00",
    "transaction_status": "settlement",
    "transaction_id": "test-transaction-123",
    "status_code": "200",
    "signature_key": "YOUR_CALCULATED_SIGNATURE",
    "payment_type": "bank_transfer",
    "order_id": "SEWA-1-1234567890",
    "gross_amount": "1500000.00",
    "fraud_status": "accept",
    "currency": "IDR"
  }'

Method 4: Tinker untuk Generate Signature

php artisan tinker

// Generate valid signature untuk testing
$orderId = 'SEWA-1-1234567890';
$statusCode = '200';
$grossAmount = '1500000.00';
$serverKey = config('midtrans.server_key');

$signature = hash('sha512', $orderId . $statusCode . $grossAmount . $serverKey);
echo $signature;

Webhook Response Best Practices

Midtrans expect response dalam waktu singkat. Jika terlalu lama, Midtrans akan retry.

// โŒ BAD: Heavy processing di webhook
public function handle(Request $request)
{
    $notification = $request->all();

    // Update database
    $this->updateTransaction($notification);

    // Send email (slow!)
    Mail::send(...);

    // Generate PDF (slow!)
    $this->generateReceipt();

    // Update external system (slow!)
    $this->notifyExternalSystem();

    return response()->json(['status' => 'ok']);
}

// โœ… GOOD: Lightweight webhook, queue heavy tasks
public function handle(Request $request)
{
    $notification = $request->all();

    // Quick validation and DB update only
    $result = $this->paymentService->handleNotification($notification);

    if ($result['success']) {
        // Queue heavy tasks
        ProcessPaymentNotification::dispatch($notification);
    }

    return response()->json(['status' => 'ok']);
}

Debugging Webhook Issues

Issue 1: 404 Not Found

Penyebab:
โ”œโ”€โ”€ Route tidak terdaftar
โ”œโ”€โ”€ Typo di URL
โ””โ”€โ”€ Wrong HTTP method

Solusi:
php artisan route:list | grep midtrans

Issue 2: 419 CSRF Token Mismatch

Penyebab:
โ””โ”€โ”€ Route tidak di-exclude dari CSRF

Solusi:
Tambahkan 'midtrans/*' ke CSRF exceptions

Issue 3: 500 Internal Server Error

Penyebab:
โ”œโ”€โ”€ Error di code
โ”œโ”€โ”€ Database connection issue
โ””โ”€โ”€ Service not found

Solusi:
โ”œโ”€โ”€ Check storage/logs/laravel.log
โ”œโ”€โ”€ Check storage/logs/webhook.log
โ””โ”€โ”€ Check Ngrok inspector untuk response body

Issue 4: Invalid Signature

Penyebab:
โ”œโ”€โ”€ Server key tidak match
โ”œโ”€โ”€ Order ID berbeda
โ””โ”€โ”€ Gross amount berbeda (perhatikan format)

Solusi:
Log dan compare signature di code

Ngrok Tips

Tip 1: Custom Domain (Paid)

# Dengan plan berbayar, bisa pakai custom domain
ngrok http 8000 --domain=sewa-rumah.ngrok.io

Tip 2: Inspect Mode

# Jalankan dengan inspect enabled
ngrok http 8000 --inspect

Tip 3: Region Selection

# Pilih region terdekat untuk latency lebih rendah
ngrok http 8000 --region=ap  # Asia Pacific

Tip 4: Save Config

# ~/.ngrok2/ngrok.yml
authtoken: YOUR_TOKEN
region: ap
tunnels:
  sewa-rumah:
    addr: 8000
    proto: http

# Jalankan dengan config
ngrok start sewa-rumah

Checklist Testing Webhook

WEBHOOK TESTING CHECKLIST:

โ–ก Ngrok running dan URL tercatat
โ–ก Notification URL updated di Midtrans Dashboard
โ–ก Route terdaftar (php artisan route:list)
โ–ก CSRF exception ditambahkan
โ–ก WebhookController created
โ–ก Webhook log channel configured

โ–ก Test Case 1: Credit Card Success
  โ”œโ”€โ”€ Buat booking
  โ”œโ”€โ”€ Bayar dengan test card 4811...
  โ”œโ”€โ”€ Check webhook received di Ngrok inspector
  โ”œโ”€โ”€ Check booking status updated
  โ””โ”€โ”€ Check webhook.log

โ–ก Test Case 2: Bank Transfer Pending
  โ”œโ”€โ”€ Buat booking
  โ”œโ”€โ”€ Pilih Bank Transfer
  โ”œโ”€โ”€ Check webhook "pending" received
  โ”œโ”€โ”€ Simulate payment di Sandbox
  โ”œโ”€โ”€ Check webhook "settlement" received
  โ””โ”€โ”€ Check booking status updated to "paid"

โ–ก Test Case 3: Payment Expired
  โ”œโ”€โ”€ Buat booking
  โ”œโ”€โ”€ Tunggu expire (atau simulate)
  โ”œโ”€โ”€ Check webhook "expire" received
  โ””โ”€โ”€ Check booking status updated to "cancelled"

โ–ก Test Case 4: Invalid Signature
  โ”œโ”€โ”€ Send manual request dengan wrong signature
  โ”œโ”€โ”€ Check rejected dengan 403
  โ””โ”€โ”€ Check logged as warning


Ngrok sudah berjalan dan webhook bisa di-test di local. Di bagian selanjutnya, kita akan mengimplementasikan handler lengkap untuk berbagai callback dan notification status dari Midtrans.

Bagian 8: Handle Callback dan Notification

Di bagian ini kita akan mengimplementasikan handler lengkap untuk berbagai jenis callback dan notification dari Midtrans. Kita juga akan membuat email notification dan handle edge cases seperti duplicate notification.

Tiga Jenis Callback Midtrans

CALLBACK TYPES:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                                                                      โ”‚
โ”‚  1. HTTP NOTIFICATION (Webhook)                                     โ”‚
โ”‚  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€                                    โ”‚
โ”‚  โ”œโ”€โ”€ Server-to-server POST request                                  โ”‚
โ”‚  โ”œโ”€โ”€ Paling RELIABLE - harus jadi sumber kebenaran                 โ”‚
โ”‚  โ”œโ”€โ”€ Retry mechanism jika gagal                                     โ”‚
โ”‚  โ””โ”€โ”€ WAJIB diimplementasi                                          โ”‚
โ”‚                                                                      โ”‚
โ”‚  2. FINISH REDIRECT                                                 โ”‚
โ”‚  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€                                                 โ”‚
โ”‚  โ”œโ”€โ”€ User di-redirect setelah payment selesai                      โ”‚
โ”‚  โ”œโ”€โ”€ Bisa di-manipulasi user (query params)                        โ”‚
โ”‚  โ”œโ”€โ”€ JANGAN andalkan untuk update status                           โ”‚
โ”‚  โ””โ”€โ”€ Gunakan HANYA untuk UX (redirect ke halaman yang tepat)       โ”‚
โ”‚                                                                      โ”‚
โ”‚  3. SNAP JS CALLBACK                                                โ”‚
โ”‚  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€                                                โ”‚
โ”‚  โ”œโ”€โ”€ Client-side callback (onSuccess, onPending, onError)          โ”‚
โ”‚  โ”œโ”€โ”€ Bisa di-bypass user (close browser, dll)                      โ”‚
โ”‚  โ”œโ”€โ”€ JANGAN andalkan untuk update status                           โ”‚
โ”‚  โ””โ”€โ”€ Gunakan HANYA untuk UX feedback                               โ”‚
โ”‚                                                                      โ”‚
โ”‚  KESIMPULAN:                                                        โ”‚
โ”‚  โ”œโ”€โ”€ HTTP Notification = Sumber kebenaran (update database)        โ”‚
โ”‚  โ”œโ”€โ”€ Finish Redirect = UX only (redirect user)                     โ”‚
โ”‚  โ””โ”€โ”€ Snap JS Callback = UX only (immediate feedback)               โ”‚
โ”‚                                                                      โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Transaction Status Mapping

Midtrans mengirim berbagai status. Berikut mapping ke status booking kita:

<?php
// app/Services/NotificationHandler.php

namespace App\\Services;

class NotificationHandler
{
    /**
     * Map Midtrans transaction status to booking status
     */
    private const STATUS_MAP = [
        // Payment successful
        'capture' => 'paid',      // Credit card captured
        'settlement' => 'paid',   // Payment settled/completed

        // Payment pending
        'pending' => 'pending',   // Waiting for payment

        // Payment failed/cancelled
        'deny' => 'cancelled',    // Payment denied
        'cancel' => 'cancelled',  // Payment cancelled
        'expire' => 'cancelled',  // Payment expired

        // Refund
        'refund' => 'refunded',
        'partial_refund' => 'refunded',
    ];

    /**
     * Map transaction status to booking status
     */
    public function mapStatus(string $transactionStatus, ?string $fraudStatus = null): string
    {
        // Special handling for credit card capture with fraud detection
        if ($transactionStatus === 'capture') {
            // Only accept if fraud status is 'accept'
            if ($fraudStatus === 'accept') {
                return 'paid';
            }
            // Challenge or deny means we should wait or reject
            return $fraudStatus === 'challenge' ? 'pending' : 'cancelled';
        }

        return self::STATUS_MAP[$transactionStatus] ?? 'pending';
    }

    /**
     * Check if status indicates successful payment
     */
    public function isSuccessful(string $transactionStatus, ?string $fraudStatus = null): bool
    {
        if ($transactionStatus === 'capture') {
            return $fraudStatus === 'accept';
        }

        return in_array($transactionStatus, ['settlement']);
    }

    /**
     * Check if status indicates failed payment
     */
    public function isFailed(string $transactionStatus): bool
    {
        return in_array($transactionStatus, ['deny', 'cancel', 'expire']);
    }

    /**
     * Check if status indicates pending payment
     */
    public function isPending(string $transactionStatus): bool
    {
        return $transactionStatus === 'pending';
    }
}

Handle Idempotency

Midtrans mungkin mengirim notification yang sama lebih dari sekali. Kita harus handle ini untuk menghindari duplicate processing.

<?php
// app/Services/PaymentService.php (update handleNotification method)

namespace App\\Services;

use App\\Models\\Booking;
use App\\Models\\PaymentLog;
use App\\Models\\Transaction;
use App\\Jobs\\SendBookingPaidEmail;
use App\\Jobs\\SendPaymentExpiredEmail;
use App\\Repositories\\Contracts\\BookingRepositoryInterface;
use App\\Repositories\\Contracts\\TransactionRepositoryInterface;
use Illuminate\\Support\\Facades\\DB;
use Illuminate\\Support\\Facades\\Log;

class PaymentService
{
    // ... constructor dan methods lainnya

    /**
     * Handle notification from Midtrans with idempotency check
     */
    public function handleNotification(array $notification, ?string $ipAddress = null): array
    {
        $orderId = $notification['order_id'] ?? null;
        $transactionStatus = $notification['transaction_status'] ?? null;
        $transactionId = $notification['transaction_id'] ?? null;

        if (!$orderId || !$transactionStatus) {
            return ['success' => false, 'error' => 'Missing required fields'];
        }

        // Check for duplicate notification (idempotency)
        if ($this->isDuplicateNotification($orderId, $transactionStatus, $transactionId)) {
            Log::info('Duplicate notification ignored', [
                'order_id' => $orderId,
                'status' => $transactionStatus,
            ]);
            return ['success' => true, 'message' => 'Duplicate notification'];
        }

        // Log the notification first
        $this->logNotification($orderId, 'webhook', $notification, $ipAddress);

        // Verify signature
        if (!$this->midtransService->verifySignature($notification)) {
            Log::warning('Invalid signature', ['order_id' => $orderId]);
            return ['success' => false, 'error' => 'Invalid signature'];
        }

        // Find transaction
        $transaction = $this->transactionRepo->findByOrderId($orderId);
        if (!$transaction) {
            Log::warning('Transaction not found', ['order_id' => $orderId]);
            return ['success' => false, 'error' => 'Transaction not found'];
        }

        // Process the notification
        return $this->processNotification($transaction, $notification);
    }

    /**
     * Check if this notification has already been processed
     */
    private function isDuplicateNotification(string $orderId, string $status, ?string $transactionId): bool
    {
        return PaymentLog::where('order_id', $orderId)
            ->where('event_type', 'webhook')
            ->where('signature_verified', true)
            ->whereJsonContains('payload->transaction_status', $status)
            ->when($transactionId, function ($query) use ($transactionId) {
                $query->whereJsonContains('payload->transaction_id', $transactionId);
            })
            ->exists();
    }

    /**
     * Process the notification and update records
     */
    private function processNotification(Transaction $transaction, array $notification): array
    {
        $transactionStatus = $notification['transaction_status'];
        $fraudStatus = $notification['fraud_status'] ?? null;

        try {
            DB::transaction(function () use ($transaction, $notification, $transactionStatus, $fraudStatus) {
                // Update transaction
                $this->transactionRepo->updateFromMidtrans($transaction->order_id, $notification);

                // Get booking
                $booking = $transaction->booking;
                $oldStatus = $booking->status;

                // Map to booking status
                $handler = new NotificationHandler();
                $newBookingStatus = $handler->mapStatus($transactionStatus, $fraudStatus);

                // Only update if status actually changes
                if ($this->shouldUpdateBookingStatus($oldStatus, $newBookingStatus)) {
                    $additionalData = $this->getAdditionalStatusData($newBookingStatus);
                    $this->bookingRepo->updateStatus($booking->id, $newBookingStatus, $additionalData);

                    // Dispatch appropriate notification
                    $this->dispatchStatusNotification($booking->fresh(), $oldStatus, $newBookingStatus);

                    Log::info('Booking status updated', [
                        'booking_id' => $booking->id,
                        'old_status' => $oldStatus,
                        'new_status' => $newBookingStatus,
                    ]);
                }

                // Mark notification as processed
                $this->markNotificationVerified($transaction->order_id, $notification);
            });

            return ['success' => true];

        } catch (\\Exception $e) {
            Log::error('Failed to process notification', [
                'order_id' => $transaction->order_id,
                'error' => $e->getMessage(),
            ]);

            return ['success' => false, 'error' => 'Processing failed'];
        }
    }

    /**
     * Determine if booking status should be updated
     */
    private function shouldUpdateBookingStatus(string $oldStatus, string $newStatus): bool
    {
        // Don't downgrade from paid/confirmed/completed
        $finalStatuses = ['paid', 'confirmed', 'completed'];

        if (in_array($oldStatus, $finalStatuses) && $newStatus === 'pending') {
            return false;
        }

        // Don't update if same status
        if ($oldStatus === $newStatus) {
            return false;
        }

        return true;
    }

    /**
     * Get additional data based on new status
     */
    private function getAdditionalStatusData(string $status): array
    {
        return match ($status) {
            'paid' => ['paid_at' => now()],
            'cancelled' => ['cancelled_at' => now()],
            default => [],
        };
    }

    /**
     * Dispatch notification based on status change
     */
    private function dispatchStatusNotification(Booking $booking, string $oldStatus, string $newStatus): void
    {
        // Only send notification for significant status changes
        if ($oldStatus === $newStatus) {
            return;
        }

        match ($newStatus) {
            'paid' => SendBookingPaidEmail::dispatch($booking),
            'cancelled' => $this->handleCancelledNotification($booking, $oldStatus),
            'refunded' => SendRefundConfirmationEmail::dispatch($booking),
            default => null,
        };
    }

    /**
     * Handle cancelled notification (expired vs user cancelled)
     */
    private function handleCancelledNotification(Booking $booking, string $oldStatus): void
    {
        // If was pending, it's likely expired
        if ($oldStatus === 'pending') {
            SendPaymentExpiredEmail::dispatch($booking);
        }
    }

    /**
     * Log notification to database
     */
    private function logNotification(string $orderId, string $eventType, array $payload, ?string $ipAddress): void
    {
        $transaction = $this->transactionRepo->findByOrderId($orderId);

        PaymentLog::create([
            'transaction_id' => $transaction?->id,
            'order_id' => $orderId,
            'event_type' => $eventType,
            'payload' => $payload,
            'signature_verified' => false,
            'ip_address' => $ipAddress,
            'user_agent' => request()->userAgent(),
        ]);
    }

    /**
     * Mark notification as verified
     */
    private function markNotificationVerified(string $orderId, array $notification): void
    {
        PaymentLog::where('order_id', $orderId)
            ->where('event_type', 'webhook')
            ->whereJsonContains('payload->transaction_status', $notification['transaction_status'])
            ->latest()
            ->update(['signature_verified' => true]);
    }
}

Email Notification Jobs

Buat job untuk mengirim email:

php artisan make:job SendBookingPaidEmail
php artisan make:job SendPaymentExpiredEmail
php artisan make:job SendRefundConfirmationEmail

php artisan make:mail BookingPaidMail --markdown=emails.booking-paid
php artisan make:mail PaymentExpiredMail --markdown=emails.payment-expired

<?php
// app/Jobs/SendBookingPaidEmail.php

namespace App\\Jobs;

use App\\Mail\\BookingPaidMail;
use App\\Models\\Booking;
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 SendBookingPaidEmail implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        public Booking $booking
    ) {
        $this->onQueue('emails');
    }

    public function handle(): void
    {
        $this->booking->load(['user', 'property', 'property.owner']);

        // Send to guest
        Mail::to($this->booking->user->email)
            ->send(new BookingPaidMail($this->booking, 'guest'));

        // Send to property owner
        Mail::to($this->booking->property->owner->email)
            ->send(new BookingPaidMail($this->booking, 'owner'));
    }
}

<?php
// app/Jobs/SendPaymentExpiredEmail.php

namespace App\\Jobs;

use App\\Mail\\PaymentExpiredMail;
use App\\Models\\Booking;
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 SendPaymentExpiredEmail implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        public Booking $booking
    ) {
        $this->onQueue('emails');
    }

    public function handle(): void
    {
        $this->booking->load(['user', 'property']);

        Mail::to($this->booking->user->email)
            ->send(new PaymentExpiredMail($this->booking));
    }
}

Mailable Classes

<?php
// app/Mail/BookingPaidMail.php

namespace App\\Mail;

use App\\Models\\Booking;
use Illuminate\\Bus\\Queueable;
use Illuminate\\Mail\\Mailable;
use Illuminate\\Mail\\Mailables\\Content;
use Illuminate\\Mail\\Mailables\\Envelope;
use Illuminate\\Queue\\SerializesModels;

class BookingPaidMail extends Mailable
{
    use Queueable, SerializesModels;

    public function __construct(
        public Booking $booking,
        public string $recipientType = 'guest' // 'guest' or 'owner'
    ) {}

    public function envelope(): Envelope
    {
        $subject = $this->recipientType === 'guest'
            ? 'Pembayaran Berhasil - ' . $this->booking->booking_code
            : 'Booking Baru - ' . $this->booking->property->name;

        return new Envelope(subject: $subject);
    }

    public function content(): Content
    {
        return new Content(
            markdown: 'emails.booking-paid',
            with: [
                'booking' => $this->booking,
                'recipientType' => $this->recipientType,
                'propertyUrl' => route('properties.show', $this->booking->property),
                'bookingUrl' => route('bookings.show', $this->booking),
            ],
        );
    }
}

Email Templates

{{-- resources/views/emails/booking-paid.blade.php --}}

<x-mail::message>
@if($recipientType === 'guest')
# Pembayaran Berhasil! ๐ŸŽ‰

Halo {{ $booking->user->name }},

Pembayaran untuk booking Anda telah berhasil dikonfirmasi.

**Detail Booking:**

| | |
|---|---|
| Kode Booking | **{{ $booking->booking_code }}** |
| Property | {{ $booking->property->name }} |
| Lokasi | {{ $booking->property->city }}, {{ $booking->property->province }} |
| Check-in | {{ $booking->check_in_date->format('d M Y') }} |
| Check-out | {{ $booking->check_out_date->format('d M Y') }} |
| Durasi | {{ $booking->nights }} malam |
| Total Dibayar | Rp {{ number_format($booking->total_price, 0, ',', '.') }} |

<x-mail::button :url="$bookingUrl">
Lihat Detail Booking
</x-mail::button>

**Langkah Selanjutnya:**
- Simpan kode booking untuk keperluan check-in
- Owner akan menghubungi Anda untuk detail lebih lanjut
- Datang tepat waktu sesuai jadwal check-in

@else
# Booking Baru Diterima! ๐Ÿ“ฉ

Halo {{ $booking->property->owner->name }},

Ada booking baru untuk property Anda yang sudah dibayar.

**Detail Booking:**

| | |
|---|---|
| Kode Booking | **{{ $booking->booking_code }}** |
| Property | {{ $booking->property->name }} |
| Tamu | {{ $booking->user->name }} |
| Email | {{ $booking->user->email }} |
| Telepon | {{ $booking->user->phone ?? '-' }} |
| Check-in | {{ $booking->check_in_date->format('d M Y') }} |
| Check-out | {{ $booking->check_out_date->format('d M Y') }} |
| Jumlah Tamu | {{ $booking->guests }} orang |
| Total | Rp {{ number_format($booking->total_price, 0, ',', '.') }} |

@if($booking->notes)
**Catatan dari Tamu:**
{{ $booking->notes }}
@endif

<x-mail::button :url="$bookingUrl">
Konfirmasi Booking
</x-mail::button>

Silakan konfirmasi booking ini dan hubungi tamu untuk koordinasi.
@endif

Terima kasih,<br>
{{ config('app.name') }}
</x-mail::message>

{{-- resources/views/emails/payment-expired.blade.php --}}

<x-mail::message>
# Pembayaran Expired โฐ

Halo {{ $booking->user->name }},

Mohon maaf, waktu pembayaran untuk booking Anda telah habis.

**Detail Booking:**

| | |
|---|---|
| Kode Booking | {{ $booking->booking_code }} |
| Property | {{ $booking->property->name }} |
| Check-in | {{ $booking->check_in_date->format('d M Y') }} |

Booking ini telah dibatalkan secara otomatis karena pembayaran tidak diterima dalam batas waktu yang ditentukan.

<x-mail::button :url="route('properties.show', $booking->property)">
Booking Ulang
</x-mail::button>

Jika Anda masih tertarik dengan property ini, silakan lakukan booking ulang.

Terima kasih,<br>
{{ config('app.name') }}
</x-mail::message>

Handle Finish Redirect

Update BookingController untuk handle finish redirect dengan lebih robust:

<?php
// app/Http/Controllers/BookingController.php (update finish method)

/**
 * Handle finish redirect from Midtrans
 *
 * PENTING: Jangan update status di sini!
 * Finish redirect hanya untuk UX, bukan sumber kebenaran.
 * Status update harus dari webhook.
 */
public function finish(Request $request)
{
    $orderId = $request->get('order_id');
    $statusCode = $request->get('status_code');
    $transactionStatus = $request->get('transaction_status');

    Log::info('Finish redirect received', [
        'order_id' => $orderId,
        'status_code' => $statusCode,
        'transaction_status' => $transactionStatus,
    ]);

    // Find transaction
    if (!$orderId) {
        return redirect()->route('bookings.index')
            ->with('error', 'Invalid redirect');
    }

    $transaction = $this->transactionRepo->findByOrderId($orderId);

    if (!$transaction) {
        return redirect()->route('bookings.index')
            ->with('error', 'Transaksi tidak ditemukan');
    }

    $booking = $transaction->booking;

    // Ensure user owns this booking
    if ($booking->user_id !== auth()->id()) {
        abort(403);
    }

    // Redirect based on transaction_status from query params
    // Note: Ini hanya untuk UX, status sebenarnya dari webhook
    return match ($transactionStatus) {
        'capture', 'settlement' => redirect()->route('bookings.success', $booking),
        'pending' => redirect()->route('bookings.pending', $booking),
        'deny' => redirect()->route('bookings.show', $booking)
            ->with('error', 'Pembayaran ditolak'),
        'cancel' => redirect()->route('bookings.show', $booking)
            ->with('info', 'Pembayaran dibatalkan'),
        'expire' => redirect()->route('bookings.show', $booking)
            ->with('error', 'Waktu pembayaran habis'),
        default => redirect()->route('bookings.show', $booking),
    };
}

Admin: Check Payment Status Manual

Tambahkan fitur untuk admin/user cek status manual:

<?php
// app/Http/Controllers/BookingController.php

/**
 * Manually refresh payment status from Midtrans
 */
public function refreshStatus(Booking $booking)
{
    if ($booking->user_id !== auth()->id() && !auth()->user()->isAdmin()) {
        abort(403);
    }

    $transaction = $booking->transaction;

    if (!$transaction) {
        return back()->with('error', 'Tidak ada transaksi');
    }

    // Get fresh status from Midtrans
    $status = $this->paymentService->checkPaymentStatus($transaction->order_id);

    if (!$status) {
        return back()->with('error', 'Gagal mengambil status dari Midtrans');
    }

    // Process as notification (will update if needed)
    $this->paymentService->handleNotification($status, request()->ip());

    return back()->with('success', 'Status berhasil di-refresh');
}

Route untuk Refresh Status

// routes/web.php

Route::middleware(['auth'])->group(function () {
    // ... existing routes

    Route::post('/bookings/{booking}/refresh-status', [BookingController::class, 'refreshStatus'])
        ->name('bookings.refresh-status');
});

Testing Notification Flow

TESTING SCENARIOS:

1. CREDIT CARD SUCCESS
   โ”œโ”€โ”€ Create booking
   โ”œโ”€โ”€ Pay with card 4811...
   โ”œโ”€โ”€ Webhook: transaction_status=capture, fraud_status=accept
   โ”œโ”€โ”€ Expected: booking.status = paid
   โ”œโ”€โ”€ Expected: Email sent to guest & owner
   โ””โ”€โ”€ Verify: Check database & email log

2. BANK TRANSFER FLOW
   โ”œโ”€โ”€ Create booking
   โ”œโ”€โ”€ Select Bank Transfer (BCA)
   โ”œโ”€โ”€ Webhook: transaction_status=pending
   โ”œโ”€โ”€ Expected: booking.status = pending
   โ”œโ”€โ”€ Simulate payment in Sandbox
   โ”œโ”€โ”€ Webhook: transaction_status=settlement
   โ”œโ”€โ”€ Expected: booking.status = paid
   โ””โ”€โ”€ Expected: Email sent

3. PAYMENT EXPIRED
   โ”œโ”€โ”€ Create booking
   โ”œโ”€โ”€ Select any method, don't complete
   โ”œโ”€โ”€ Wait for expiry (or simulate)
   โ”œโ”€โ”€ Webhook: transaction_status=expire
   โ”œโ”€โ”€ Expected: booking.status = cancelled
   โ””โ”€โ”€ Expected: Expired email sent

4. DUPLICATE NOTIFICATION
   โ”œโ”€โ”€ Complete a payment
   โ”œโ”€โ”€ First webhook: processed normally
   โ”œโ”€โ”€ Second webhook (same data): should be ignored
   โ””โ”€โ”€ Verify: Only one email sent, one log entry marked verified

5. INVALID SIGNATURE
   โ”œโ”€โ”€ Send webhook with wrong signature
   โ”œโ”€โ”€ Expected: Rejected with error
   โ””โ”€โ”€ Verify: Logged as warning

Queue Configuration

Pastikan queue berjalan untuk email:

# Development
php artisan queue:work --queue=emails,default

# Atau untuk testing
php artisan queue:work --once


Handler untuk callback dan notification sudah lengkap dengan idempotency check dan email notifications. Di bagian terakhir, kita akan membahas deployment ke production dan security best practices.

Bagian 9: Production Deployment dan Best Practices

Selamat! Kita sudah sampai di bagian terakhir. Di bagian ini kita akan membahas cara switch ke production, security checklist, monitoring, dan best practices untuk payment system yang aman dan reliable.

Switch ke Production

Step 1: Apply Production di Midtrans

  1. Login ke dashboard.midtrans.com
  2. Klik "Apply for Production" atau "Ajukan Production"
  3. Lengkapi dokumen yang diminta:
    • KTP pemilik/direktur
    • NPWP perusahaan
    • SIUP/NIB
    • Akta perusahaan
  4. Tunggu approval (1-3 hari kerja)
  5. Setelah approved, dapatkan Production API Keys

Step 2: Update Environment

# .env (Production)

APP_ENV=production
APP_DEBUG=false

# Midtrans Production Keys
MIDTRANS_SERVER_KEY=Mid-server-xxxxxxxxxxxxx
MIDTRANS_CLIENT_KEY=Mid-client-xxxxxxxxxxxxx
MIDTRANS_IS_PRODUCTION=true

# Update notification URL ke domain production
MIDTRANS_NOTIFICATION_URL=https://sewarumah.com/midtrans/notification

Step 3: Update Midtrans Dashboard

Di Production environment:

  1. Settings โ†’ Configuration
  2. Update Payment Notification URL: https://sewarumah.com/midtrans/notification
  3. Update Finish Redirect URL: https://sewarumah.com/bookings/finish

Security Checklist

SECURITY CHECKLIST:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                                                                      โ”‚
โ”‚  ๐Ÿ”‘ API KEYS                                                        โ”‚
โ”‚  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€                                                       โ”‚
โ”‚  โ–ก Server Key HANYA di backend (.env)                               โ”‚
โ”‚  โ–ก Server Key TIDAK di-commit ke repository                         โ”‚
โ”‚  โ–ก Gunakan environment variables, bukan hardcode                    โ”‚
โ”‚  โ–ก Rotate keys secara berkala                                       โ”‚
โ”‚  โ–ก Berbeda keys untuk staging dan production                        โ”‚
โ”‚                                                                      โ”‚
โ”‚  ๐Ÿ” WEBHOOK SECURITY                                                โ”‚
โ”‚  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€                                                 โ”‚
โ”‚  โ–ก SELALU verify signature sebelum process                          โ”‚
โ”‚  โ–ก Log semua incoming webhook                                       โ”‚
โ”‚  โ–ก Gunakan HTTPS only                                               โ”‚
โ”‚  โ–ก Validate required fields                                         โ”‚
โ”‚  โ–ก Handle idempotency (duplicate notifications)                     โ”‚
โ”‚                                                                      โ”‚
โ”‚  ๐Ÿ’ณ TRANSACTION SECURITY                                            โ”‚
โ”‚  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€                                            โ”‚
โ”‚  โ–ก Validate amount di server (jangan trust client)                  โ”‚
โ”‚  โ–ก Check duplicate order_id                                         โ”‚
โ”‚  โ–ก Implement rate limiting                                          โ”‚
โ”‚  โ–ก Audit trail untuk semua perubahan                               โ”‚
โ”‚                                                                      โ”‚
โ”‚  ๐Ÿ—„๏ธ DATA SECURITY                                                   โ”‚
โ”‚  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€                                                    โ”‚
โ”‚  โ–ก JANGAN log full card number                                      โ”‚
โ”‚  โ–ก JANGAN simpan CVV                                                โ”‚
โ”‚  โ–ก Encrypt sensitive data di database                               โ”‚
โ”‚  โ–ก Regular security audit                                           โ”‚
โ”‚                                                                      โ”‚
โ”‚  ๐ŸŒ INFRASTRUCTURE                                                  โ”‚
โ”‚  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€                                                   โ”‚
โ”‚  โ–ก SSL/TLS certificate valid                                        โ”‚
โ”‚  โ–ก Firewall configured                                              โ”‚
โ”‚  โ–ก Database tidak public accessible                                 โ”‚
โ”‚  โ–ก Regular backups                                                  โ”‚
โ”‚                                                                      โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Rate Limiting

Tambahkan rate limiting untuk webhook endpoint:

<?php
// routes/web.php

use Illuminate\\Support\\Facades\\RateLimiter;
use Illuminate\\Cache\\RateLimiting\\Limit;

// Di AppServiceProvider atau RouteServiceProvider
RateLimiter::for('webhook', function ($request) {
    return Limit::perMinute(100)->by($request->ip());
});

// Route dengan rate limiter
Route::post('/midtrans/notification', [WebhookController::class, 'handle'])
    ->middleware('throttle:webhook')
    ->withoutMiddleware(['web']);

IP Whitelisting (Optional)

Untuk keamanan ekstra, whitelist IP Midtrans:

<?php
// app/Http/Middleware/MidtransIpWhitelist.php

namespace App\\Http\\Middleware;

use Closure;
use Illuminate\\Http\\Request;

class MidtransIpWhitelist
{
    /**
     * Midtrans IP ranges (check docs for latest)
     */
    private array $allowedIps = [
        '103.208.23.0/24',
        '103.208.24.0/24',
        '103.127.16.0/24',
    ];

    public function handle(Request $request, Closure $next)
    {
        // Skip in non-production untuk development
        if (app()->environment('local', 'staging')) {
            return $next($request);
        }

        $clientIp = $request->ip();

        foreach ($this->allowedIps as $range) {
            if ($this->ipInRange($clientIp, $range)) {
                return $next($request);
            }
        }

        // Log unauthorized attempt
        \\Log::warning('Webhook from unauthorized IP', [
            'ip' => $clientIp,
            'payload' => $request->all(),
        ]);

        return response()->json(['error' => 'Unauthorized'], 403);
    }

    private function ipInRange(string $ip, string $range): bool
    {
        if (strpos($range, '/') === false) {
            return $ip === $range;
        }

        [$subnet, $bits] = explode('/', $range);
        $subnet = ip2long($subnet);
        $ip = ip2long($ip);
        $mask = -1 << (32 - $bits);

        return ($ip & $mask) === ($subnet & $mask);
    }
}

Database Indexes

Pastikan indexes sudah optimal untuk query yang sering:

<?php
// database/migrations/xxxx_add_indexes_for_performance.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
    {
        // Transactions indexes
        Schema::table('transactions', function (Blueprint $table) {
            $table->index('transaction_status');
            $table->index('payment_type');
            $table->index('created_at');
        });

        // Payment logs indexes
        Schema::table('payment_logs', function (Blueprint $table) {
            $table->index(['order_id', 'event_type', 'signature_verified']);
            $table->index('created_at');
        });

        // Bookings composite index for common queries
        Schema::table('bookings', function (Blueprint $table) {
            $table->index(['user_id', 'status', 'created_at']);
            $table->index(['property_id', 'status', 'check_in_date']);
        });
    }
};

Queue Configuration untuk Production

<?php
// config/queue.php

'connections' => [
    'redis' => [
        'driver' => 'redis',
        'connection' => 'default',
        'queue' => env('REDIS_QUEUE', 'default'),
        'retry_after' => 90,
        'block_for' => null,
        'after_commit' => false,
    ],
],

# .env
QUEUE_CONNECTION=redis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379

Supervisor Configuration:

; /etc/supervisor/conf.d/sewa-rumah-worker.conf

[program:sewa-rumah-payments]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/sewa-rumah/artisan queue:work redis --queue=payments --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/sewa-rumah/storage/logs/payment-worker.log
stopwaitsecs=3600

[program:sewa-rumah-emails]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/sewa-rumah/artisan queue:work redis --queue=emails --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/sewa-rumah/storage/logs/email-worker.log
stopwaitsecs=3600

[program:sewa-rumah-default]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/sewa-rumah/artisan queue:work redis --queue=default --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
user=www-data
numprocs=1
redirect_stderr=true
stdout_logfile=/var/www/sewa-rumah/storage/logs/default-worker.log

# Reload supervisor
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start all

Monitoring dan Alerting

1. Log Failed Payments

<?php
// app/Listeners/LogFailedPayment.php

namespace App\\Listeners;

use Illuminate\\Support\\Facades\\Log;
use Illuminate\\Support\\Facades\\Notification;
use App\\Notifications\\PaymentFailedAlert;

class LogFailedPayment
{
    public function handle($event): void
    {
        Log::channel('payments')->error('Payment failed', [
            'booking_id' => $event->booking->id,
            'order_id' => $event->orderId,
            'reason' => $event->reason,
        ]);

        // Alert admin via Slack/Email
        if (app()->environment('production')) {
            Notification::route('slack', config('services.slack.webhook'))
                ->notify(new PaymentFailedAlert($event));
        }
    }
}

2. Health Check Endpoint

<?php
// routes/web.php

Route::get('/health', function () {
    $checks = [
        'database' => false,
        'redis' => false,
        'midtrans' => false,
    ];

    // Check database
    try {
        \\DB::connection()->getPdo();
        $checks['database'] = true;
    } catch (\\Exception $e) {
        // Database down
    }

    // Check Redis
    try {
        \\Redis::ping();
        $checks['redis'] = true;
    } catch (\\Exception $e) {
        // Redis down
    }

    // Check Midtrans (optional, bisa skip di production)
    $checks['midtrans'] = !empty(config('midtrans.server_key'));

    $allHealthy = !in_array(false, $checks);

    return response()->json([
        'status' => $allHealthy ? 'healthy' : 'unhealthy',
        'checks' => $checks,
        'timestamp' => now()->toIso8601String(),
    ], $allHealthy ? 200 : 503);
});

3. Daily Report Command

<?php
// app/Console/Commands/DailyPaymentReport.php

namespace App\\Console\\Commands;

use App\\Models\\Transaction;
use Illuminate\\Console\\Command;
use Illuminate\\Support\\Facades\\Mail;

class DailyPaymentReport extends Command
{
    protected $signature = 'report:daily-payments';
    protected $description = 'Generate daily payment report';

    public function handle(): void
    {
        $yesterday = now()->subDay();

        $stats = [
            'total_transactions' => Transaction::whereDate('created_at', $yesterday)->count(),
            'successful' => Transaction::whereDate('created_at', $yesterday)
                ->whereIn('transaction_status', ['capture', 'settlement'])
                ->count(),
            'failed' => Transaction::whereDate('created_at', $yesterday)
                ->whereIn('transaction_status', ['deny', 'cancel', 'expire'])
                ->count(),
            'total_revenue' => Transaction::whereDate('created_at', $yesterday)
                ->whereIn('transaction_status', ['capture', 'settlement'])
                ->sum('gross_amount'),
            'by_payment_type' => Transaction::whereDate('created_at', $yesterday)
                ->whereIn('transaction_status', ['capture', 'settlement'])
                ->selectRaw('payment_type, count(*) as count, sum(gross_amount) as total')
                ->groupBy('payment_type')
                ->get(),
        ];

        // Send report
        Mail::to(config('mail.admin_email'))->send(new \\App\\Mail\\DailyPaymentReport($stats));

        $this->info('Daily report sent');
    }
}

// app/Console/Kernel.php atau routes/console.php (Laravel 11)

Schedule::command('report:daily-payments')->dailyAt('08:00');

Backup Strategy

# Database backup script
#!/bin/bash
# /scripts/backup-db.sh

DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="/backups/mysql"
DB_NAME="sewa_rumah"

# Create backup
mysqldump -u root -p$DB_PASSWORD $DB_NAME | gzip > $BACKUP_DIR/$DB_NAME_$DATE.sql.gz

# Keep only last 30 days
find $BACKUP_DIR -type f -mtime +30 -delete

# Upload to S3 (optional)
aws s3 cp $BACKUP_DIR/$DB_NAME_$DATE.sql.gz s3://your-bucket/backups/

Testing Checklist Sebelum Go Live

PRE-PRODUCTION CHECKLIST:

โ–ก FUNCTIONALITY
  โ–ก Test semua payment methods di Sandbox
  โ–ก Test webhook untuk setiap status (success, pending, failed, expire)
  โ–ก Test email notifications terkirim
  โ–ก Test idempotency (duplicate webhook)
  โ–ก Test edge cases (timeout, double click, dll)

โ–ก SECURITY
  โ–ก SSL certificate valid dan tidak expired
  โ–ก Server key tidak exposed di frontend
  โ–ก Signature verification berfungsi
  โ–ก Rate limiting aktif
  โ–ก CSRF protection untuk non-webhook routes

โ–ก PERFORMANCE
  โ–ก Database indexes created
  โ–ก Queue workers running
  โ–ก Redis/cache configured
  โ–ก Response time < 3 detik

โ–ก MONITORING
  โ–ก Error logging configured
  โ–ก Payment logs terpisah
  โ–ก Health check endpoint ready
  โ–ก Alert notifications configured

โ–ก DOCUMENTATION
  โ–ก API keys documented (secure location)
  โ–ก Deployment procedure documented
  โ–ก Rollback procedure documented
  โ–ก Contact support Midtrans tersimpan

Architecture Summary

FINAL ARCHITECTURE:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                                                                      โ”‚
โ”‚                         PRESENTATION LAYER                          โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”     โ”‚
โ”‚  โ”‚  Controllers (thin)    โ”‚    Blade Views    โ”‚    API        โ”‚     โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜     โ”‚
โ”‚                              โ”‚                                       โ”‚
โ”‚                              โ–ผ                                       โ”‚
โ”‚                         SERVICE LAYER                               โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”     โ”‚
โ”‚  โ”‚  BookingService  โ”‚  PaymentService  โ”‚  MidtransService     โ”‚     โ”‚
โ”‚  โ”‚  PropertyService โ”‚  NotificationHandler                     โ”‚     โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜     โ”‚
โ”‚                              โ”‚                                       โ”‚
โ”‚                              โ–ผ                                       โ”‚
โ”‚                       REPOSITORY LAYER                              โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”     โ”‚
โ”‚  โ”‚  PropertyRepository  โ”‚  BookingRepository                   โ”‚     โ”‚
โ”‚  โ”‚  TransactionRepository                                      โ”‚     โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜     โ”‚
โ”‚                              โ”‚                                       โ”‚
โ”‚                              โ–ผ                                       โ”‚
โ”‚                          DATA LAYER                                 โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”     โ”‚
โ”‚  โ”‚  Eloquent Models  โ”‚  MySQL Database  โ”‚  Redis Cache        โ”‚     โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜     โ”‚
โ”‚                                                                      โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”     โ”‚
โ”‚  โ”‚                    EXTERNAL SERVICES                        โ”‚     โ”‚
โ”‚  โ”‚  Midtrans API  โ”‚  Email (SMTP)  โ”‚  Queue (Redis)           โ”‚     โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜     โ”‚
โ”‚                                                                      โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Penutup

Selamat! Kamu sudah berhasil membangun sistem payment yang lengkap dengan Laravel dan Midtrans. Berikut ringkasan apa yang sudah dipelajari:

YANG SUDAH DIPELAJARI:

โœ… Bagian 1: Konsep Midtrans dan payment gateway
โœ… Bagian 2: Setup Laravel dan database schema
โœ… Bagian 3: Seeder untuk data testing
โœ… Bagian 4: Repository Pattern untuk clean architecture
โœ… Bagian 5: Service Layer untuk business logic
โœ… Bagian 6: Integrasi Midtrans Snap
โœ… Bagian 7: Webhook testing dengan Ngrok
โœ… Bagian 8: Handle callback dan notifications
โœ… Bagian 9: Production deployment dan security

Tips Terakhir:

  1. Selalu test di Sandbox dulu sebelum production
  2. Webhook adalah sumber kebenaran โ€” jangan andalkan client callbacks
  3. Log everything โ€” akan sangat membantu saat debugging
  4. Monitor terus โ€” payment system harus selalu up
  5. Stay updated โ€” ikuti changelog Midtrans untuk update API

Resources:

Semoga tutorial ini bermanfaat untuk projek kamu. Selamat coding dan sukses dengan payment integration-nya! ๐Ÿš€