Pendahuluan
Kamu sudah membangun aplikasi e-commerce Laravel yang cantik. Produk tersedia, keranjang belanja jalan, tinggal satu hal terakhir: sistem pembayaran. Di sinilah banyak developer Indonesia tersandung.
Midtrans adalah payment gateway #1 di Indonesia — dipakai oleh Tokopedia, Bukalapak, OVO, dan ribuan bisnis lokal. Mendukung semua metode bayar yang familiar bagi pengguna Indonesia:
- Transfer Bank (BCA, Mandiri, BNI, BRI)
- E-Wallet (GoPay, ShopeePay, OVO, Dana)
- Kartu Kredit/Debit
- Alfamart/Indomaret (convenience store)
- QRIS (semua dompet digital)
Di artikel ini, kita akan integrasikan Midtrans Snap (payment page yang di-host Midtrans) ke Laravel — cara termudah dan paling aman.
Alur Pembayaran Midtrans
1. Customer klik "Bayar"
│
2. Laravel → Kirim request ke Midtrans API
│
3. Midtrans → Kembalikan snap_token
│
4. Frontend → Tampilkan Midtrans payment page (modal)
│
5. Customer → Pilih metode bayar & selesaikan pembayaran
│
6. Midtrans → Kirim HTTP Notification ke webhook kita
│
7. Laravel → Verifikasi signature → Update status order
│
8. Customer → Diarahkan ke halaman sukses/gagal
Kunci arsitektur ini: kita tidak pernah menyentuh data kartu kredit — semua ditangani Midtrans. Aman dari PCI-DSS compliance.
Langkah 1: Daftar Akun Midtrans & Setup Sandbox
- Buka dashboard.midtrans.com → daftar gratis
- Pilih mode Sandbox (kiri atas)
- Masuk ke Settings → Access Keys
- Catat Server Key dan Client Key (Sandbox)
# .env
MIDTRANS_SERVER_KEY=SB-Mid-server-xxxxxxxxxxxx
MIDTRANS_CLIENT_KEY=SB-Mid-client-xxxxxxxxxxxx
MIDTRANS_IS_PRODUCTION=false
MIDTRANS_IS_SANITIZED=true
MIDTRANS_IS_3DS=true
Langkah 2: Install Package Midtrans
composer require midtrans/midtrans-php
Buat config file:
php artisan make:config midtrans
<?php
// config/midtrans.php
return [
'server_key' => env('MIDTRANS_SERVER_KEY'),
'client_key' => env('MIDTRANS_CLIENT_KEY'),
'is_production' => env('MIDTRANS_IS_PRODUCTION', false),
'is_sanitized' => env('MIDTRANS_IS_SANITIZED', true),
'is_3ds' => env('MIDTRANS_IS_3DS', true),
];
Buat Service class untuk mengkapsulasi logika Midtrans:
<?php
// app/Services/MidtransService.php
namespace App\Services;
use Midtrans\Config;
use Midtrans\Snap;
class MidtransService
{
public function __construct()
{
Config::$serverKey = config('midtrans.server_key');
Config::$isProduction = config('midtrans.is_production');
Config::$isSanitized = config('midtrans.is_sanitized');
Config::$is3ds = config('midtrans.is_3ds');
}
public function createSnapToken(array $params): string
{
return Snap::getSnapToken($params);
}
}
Langkah 3: Buat Model Order
php artisan make:model Order -m
<?php
// database/migrations/xxxx_create_orders_table.php
public function up(): void
{
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('order_id')->unique(); // ID unik untuk Midtrans
$table->decimal('total_amount', 12, 2);
$table->enum('status', [
'pending', 'paid', 'expired', 'cancelled', 'failed'
])->default('pending');
$table->string('snap_token')->nullable();
$table->string('payment_type')->nullable();
$table->timestamp('paid_at')->nullable();
$table->json('items')->nullable(); // Detail produk yang dibeli
$table->timestamps();
});
}
<?php
// app/Models/Order.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Order extends Model
{
protected $fillable = [
'user_id', 'order_id', 'total_amount',
'status', 'snap_token', 'payment_type', 'paid_at', 'items',
];
protected $casts = [
'items' => 'array',
'paid_at' => 'datetime',
];
public function user()
{
return $this->belongsTo(User::class);
}
}
php artisan migrate
Langkah 4: Controller — Buat Snap Token
<?php
// app/Http/Controllers/OrderController.php
namespace App\Http\Controllers;
use App\Models\Order;
use App\Services\MidtransService;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class OrderController extends Controller
{
public function __construct(private MidtransService $midtrans) {}
// Tampilkan halaman checkout
public function checkout(Request $request)
{
$items = [
[
'id' => 'PROD-001',
'price' => 150000,
'quantity' => 2,
'name' => 'Kursus Laravel Premium',
],
];
$totalAmount = collect($items)->sum(fn($item) => $item['price'] * $item['quantity']);
// Buat order di database
$order = Order::create([
'user_id' => auth()->id(),
'order_id' => 'ORDER-' . strtoupper(Str::random(10)),
'total_amount' => $totalAmount,
'status' => 'pending',
'items' => $items,
]);
// Siapkan parameter untuk Midtrans
$params = [
'transaction_details' => [
'order_id' => $order->order_id,
'gross_amount' => (int) $order->total_amount,
],
'item_details' => $items,
'customer_details' => [
'first_name' => auth()->user()->name,
'email' => auth()->user()->email,
],
// Callback URL setelah pembayaran
'callbacks' => [
'finish' => route('order.finish'),
],
];
// Dapatkan snap token dari Midtrans
$snapToken = $this->midtrans->createSnapToken($params);
// Simpan snap token
$order->update(['snap_token' => $snapToken]);
return view('orders.checkout', [
'order' => $order,
'snapToken' => $snapToken,
'clientKey' => config('midtrans.client_key'),
]);
}
// Halaman setelah pembayaran
public function finish(Request $request)
{
$orderId = $request->get('order_id');
$order = Order::where('order_id', $orderId)->firstOrFail();
return view('orders.finish', compact('order'));
}
}
Langkah 5: Frontend — Tampilkan Snap Payment Page
{{-- resources/views/orders/checkout.blade.php --}}
@extends('layouts.app')
@section('content')
<div class="max-w-2xl mx-auto p-6">
<h1 class="text-2xl font-bold mb-4">Checkout</h1>
<div class="bg-white rounded-lg shadow p-6 mb-4">
<h2 class="font-semibold mb-2">Detail Pesanan</h2>
<p>Order ID: {{ $order->order_id }}</p>
<p>Total: Rp {{ number_format($order->total_amount, 0, ',', '.') }}</p>
</div>
{{-- Tombol pembayaran --}}
<button id="pay-button"
class="w-full bg-blue-600 text-white py-3 rounded-lg font-semibold hover:bg-blue-700">
Bayar Sekarang
</button>
</div>
{{-- Midtrans Snap.js --}}
<script src="https://app.sandbox.midtrans.com/snap/snap.js"
data-client-key="{{ $clientKey }}"></script>
<script>
document.getElementById('pay-button').onclick = function() {
snap.pay('{{ $snapToken }}', {
onSuccess: function(result) {
// Pembayaran berhasil
console.log('success', result);
window.location.href = '/orders/finish?order_id={{ $order->order_id }}';
},
onPending: function(result) {
// Menunggu pembayaran (transfer bank)
console.log('pending', result);
window.location.href = '/orders/finish?order_id={{ $order->order_id }}';
},
onError: function(result) {
// Pembayaran gagal
console.log('error', result);
alert('Pembayaran gagal. Silakan coba lagi.');
},
onClose: function() {
// User menutup popup tanpa bayar
console.log('popup closed');
}
});
};
</script>
@endsection
Untuk production, ganti URL Snap.js:
<script src="https://app.midtrans.com/snap/snap.js" ...>
Langkah 6: Webhook Notification Handler (Terpenting!)
Webhook adalah jantung integrasi Midtrans. Setiap kali status pembayaran berubah, Midtrans mengirim HTTP POST ke URL yang kita daftarkan.
Setup Route
// routes/web.php atau routes/api.php
// PENTING: route ini harus bebas dari CSRF verification!
Route::post('/midtrans/notification', [MidtransNotificationController::class, 'handle'])
->name('midtrans.notification');
// app/Http/Middleware/VerifyCsrfToken.php
protected $except = [
'/midtrans/notification',
];
Controller Notification
<?php
// app/Http/Controllers/MidtransNotificationController.php
namespace App\Http\Controllers;
use App\Models\Order;
use Illuminate\Http\Request;
use Midtrans\Config;
use Midtrans\Notification;
class MidtransNotificationController extends Controller
{
public function handle(Request $request)
{
// Setup Midtrans config
Config::$serverKey = config('midtrans.server_key');
Config::$isProduction = config('midtrans.is_production');
$notification = new Notification();
$orderId = $notification->order_id;
$transactionStatus = $notification->transaction_status;
$fraudStatus = $notification->fraud_status;
$paymentType = $notification->payment_type;
// Cari order di database
$order = Order::where('order_id', $orderId)->first();
if (! $order) {
return response()->json(['message' => 'Order not found'], 404);
}
// Idempotency: jangan proses ulang jika sudah dibayar
if ($order->status === 'paid') {
return response()->json(['message' => 'Already processed']);
}
// Tentukan status berdasarkan notification Midtrans
if ($transactionStatus === 'capture') {
// Kartu kredit — cek fraud detection
$newStatus = ($fraudStatus === 'accept') ? 'paid' : 'failed';
} elseif ($transactionStatus === 'settlement') {
// Transfer bank atau e-wallet — sudah settle
$newStatus = 'paid';
} elseif (in_array($transactionStatus, ['pending'])) {
$newStatus = 'pending';
} elseif (in_array($transactionStatus, ['deny', 'expire', 'cancel'])) {
$newStatus = 'cancelled';
} else {
$newStatus = 'failed';
}
// Update order
$order->update([
'status' => $newStatus,
'payment_type' => $paymentType,
'paid_at' => $newStatus === 'paid' ? now() : null,
]);
// Jika berhasil bayar, lakukan aksi bisnis
if ($newStatus === 'paid') {
// Misal: kirim email konfirmasi via Queue
// SendOrderConfirmationEmail::dispatch($order);
}
return response()->json(['message' => 'OK']);
}
}
Daftarkan Notification URL di Dashboard Midtrans
- Login ke Midtrans Dashboard
- Settings → Configuration
- Payment Notification URL:
https://domain-kamu.com/midtrans/notification - Klik Save
Untuk testing di localhost, gunakan ngrok:
ngrok http 8000
# Copy URL https://xxx.ngrok.io → paste ke Midtrans Dashboard
Langkah 7: Halaman Status Pembayaran
{{-- resources/views/orders/finish.blade.php --}}
@extends('layouts.app')
@section('content')
<div class="max-w-lg mx-auto p-6 text-center">
@if($order->status === 'paid')
<div class="text-green-600 text-5xl mb-4">✅</div>
<h1 class="text-2xl font-bold mb-2">Pembayaran Berhasil!</h1>
<p>Order ID: {{ $order->order_id }}</p>
<p>Total: Rp {{ number_format($order->total_amount, 0, ',', '.') }}</p>
<p class="text-gray-500 text-sm mt-2">Dibayar: {{ $order->paid_at->format('d M Y H:i') }}</p>
@elseif($order->status === 'pending')
<div class="text-yellow-500 text-5xl mb-4">⏳</div>
<h1 class="text-2xl font-bold mb-2">Menunggu Pembayaran</h1>
<p>Selesaikan pembayaran sebelum waktu habis.</p>
@else
<div class="text-red-500 text-5xl mb-4">❌</div>
<h1 class="text-2xl font-bold mb-2">Pembayaran Gagal</h1>
<a href="/checkout" class="text-blue-600 underline">Coba lagi</a>
@endif
</div>
@endsection
Langkah 8: Pindah ke Production
# .env — Production
MIDTRANS_SERVER_KEY=Mid-server-xxxxxxxxxxxx # Key Production (tanpa "SB-")
MIDTRANS_CLIENT_KEY=Mid-client-xxxxxxxxxxxx
MIDTRANS_IS_PRODUCTION=true
<!-- Ganti URL Snap.js -->
<script src="https://app.midtrans.com/snap/snap.js" ...>
Checklist production:
- Server Key & Client Key sudah diganti ke production
- Notification URL terdaftar di dashboard production
- HTTPS aktif (wajib untuk Midtrans production)
- Tes transaksi dengan kartu uji sebelum go live
Troubleshooting: Error yang Sering Muncul
Signature key mismatch / 401 Unauthorized
Penyebab: Server Key salah, atau mencampur key Sandbox dengan Production.
Solusi:
# Cek key di .env
grep MIDTRANS .env
# Pastikan:
# - Sandbox: dimulai dengan "SB-Mid-server-"
# - Production: dimulai dengan "Mid-server-" (tanpa SB)
# - MIDTRANS_IS_PRODUCTION harus sesuai dengan jenis key
Notification webhook tidak diterima
Penyebab: URL webhook belum didaftarkan, atau CSRF blocking request Midtrans.
Solusi:
// 1. Pastikan route dikecualikan dari CSRF
// app/Http/Middleware/VerifyCsrfToken.php
protected $except = [
'/midtrans/notification',
];
// 2. Untuk testing localhost, gunakan ngrok
// ngrok http 8000
// → masukkan URL https di Midtrans Dashboard
// 3. Cek log Laravel
tail -f storage/logs/laravel.log
Status pembayaran tetap “pending” setelah bayar
Penyebab: Webhook handler tidak memproses transaction_status = settlement dengan benar.
Solusi:
// Pastikan handle status 'settlement' (transfer bank selesai settle)
} elseif ($transactionStatus === 'settlement') {
$newStatus = 'paid'; // ← Jangan terlewat!
}
// Untuk testing manual, gunakan Midtrans Simulator
// Dashboard → Sandbox → Simulator Transaction
Snap.js tidak muncul di halaman pembayaran
Penyebab: Client Key salah, atau URL Snap.js tidak sesuai dengan environment.
Solusi:
<!-- Sandbox: gunakan sandbox URL -->
<script src="https://app.sandbox.midtrans.com/snap/snap.js"
data-client-key="{{ $clientKey }}"></script>
<!-- Production: gunakan URL production -->
<script src="https://app.midtrans.com/snap/snap.js"
data-client-key="{{ $clientKey }}"></script>
Pertanyaan yang Sering Diajukan
Midtrans vs Xendit, mana yang lebih cocok untuk startup Indonesia?
Midtrans lebih mature dan support lebih lengkap (BCA VA, Alfamart, dll). Banyak dokumentasi dan tutorial bahasa Indonesia. Xendit lebih mudah didaftarkan (dokumen lebih simpel) dan punya disbursement API yang lebih baik untuk payout. Keduanya kompetitif — pilih yang timmu lebih familiar.
Bagaimana cara testing webhook di localhost menggunakan ngrok?
Install ngrok, jalankan ngrok http 8000, copy URL https://xxxx.ngrok.io yang diberikan, paste sebagai Notification URL di Midtrans Sandbox Dashboard. Setiap simulasi pembayaran dari dashboard Midtrans akan masuk ke local server kamu.
Apakah Midtrans mendukung subscription/recurring payment?
Ya, melalui fitur Token/Recurring Payment Midtrans menggunakan kartu kredit yang sudah di-save tokennya. Namun setup lebih kompleks. Untuk langganan sederhana, banyak developer membuat sistem manual (auto-send payment link setiap bulan via Queue).
Berapa biaya transaksi Midtrans?
Midtrans tidak ada biaya setup atau bulanan. Biaya per transaksi sekitar 0.5%–3% tergantung metode pembayaran. Transfer bank (VA) lebih murah (~0.5%–1%), kartu kredit lebih tinggi (~2–3%). Cek halaman pricing resmi untuk detail terbaru.
Kesimpulan
Kamu sudah berhasil mengintegrasikan Midtrans ke Laravel! Alur lengkap dari buat order, tampilkan payment page, hingga terima webhook sudah berjalan.
Kunci keberhasilan integrasi ini:
- Signature verification — jangan pernah skip, ini keamanan utama
- Idempotency — handle notifikasi yang dikirim ulang dengan aman
- Webhook handler solid — ini yang menentukan status order benar atau tidak
Ingin memproses webhook lebih andal dan tidak blocking? Kombinasikan dengan Queue dan Jobs di artikel sebelumnya — dispatch job dari webhook handler agar proses bisnis berjalan di background!
Selamat membangun e-commerce Laravel pertamamu — kamu satu langkah lebih dekat ke produk yang siap jual! 💳🇮🇩