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
- Daftar di Midtrans: Buka dashboard.midtrans.com dan daftar
- Masuk ke Sandbox: Pastikan environment adalah "Sandbox"
- 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
- Buka ngrok.com
- Klik "Sign up" (gratis)
- Verify email
- 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:
- Login ke dashboard.midtrans.com
- Pastikan di environment Sandbox
- Pergi ke Settings โ Configuration
- Update Payment Notification URL:
<https://abc123.ngrok-free.app/midtrans/notification> - Update Finish Redirect URL:
<https://abc123.ngrok-free.app/bookings/finish> - 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
- Buat booking dan payment di aplikasi
- Di Midtrans Dashboard โ Transactions โ Pilih transaksi
- Klik "Resend Notification"
Method 2: Simulate Payment di Sandbox
Untuk Bank Transfer/VA:
- Buat booking dan pilih Bank Transfer
- Di Midtrans Dashboard โ Sandbox โ Simulator
- Masukkan VA number dan amount
- Klik "Pay"
- 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
- Login ke dashboard.midtrans.com
- Klik "Apply for Production" atau "Ajukan Production"
- Lengkapi dokumen yang diminta:
- KTP pemilik/direktur
- NPWP perusahaan
- SIUP/NIB
- Akta perusahaan
- Tunggu approval (1-3 hari kerja)
- 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:
- Settings โ Configuration
- Update Payment Notification URL:
https://sewarumah.com/midtrans/notification - 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:
- Selalu test di Sandbox dulu sebelum production
- Webhook adalah sumber kebenaran โ jangan andalkan client callbacks
- Log everything โ akan sangat membantu saat debugging
- Monitor terus โ payment system harus selalu up
- Stay updated โ ikuti changelog Midtrans untuk update API
Resources:
- Dokumentasi Midtrans: docs.midtrans.com
- Midtrans Support: [email protected]
- Laravel Documentation: laravel.com/docs
Semoga tutorial ini bermanfaat untuk projek kamu. Selamat coding dan sukses dengan payment integration-nya! ๐