Gratis update & dukungan instalasi

Blog 18 Sep 2026

Integrasi QRIS Dinamis di Aplikasi Kasir dengan Midtrans dan Laravel

R Oleh renz mobellgnd
Integrasi QRIS Dinamis di Aplikasi Kasir dengan Midtrans dan Laravel

Cara Integrasi QRIS di Aplikasi Kasir dengan Midtrans dan Laravel

Punya toko kafe, resto, atau minimarket dan mau terima pembayaran QRIS langsung dari aplikasi kasir kamu sendiri? Di 2026 ini, QRIS sudah punya sekitar 66 juta pengguna dan lebih dari 44 juta merchant di seluruh Indonesia, dengan transaksi menembus 12,55 miliar di semester pertama saja (data Bank Indonesia). Artinya, kalau aplikasi kasirmu belum support QRIS, kamu kehilangan mayoritas pelanggan yang sudah nggak bawa cash. Nah, di tutorial ini kita bahas cara integrasi QRIS di aplikasi kasir dengan Midtrans dan Laravel — fokus ke mode POS (generate QR dinamis per transaksi), bukan Snap checkout yang biasa dipakai di website e-commerce.

QRIS statis vs dinamis: kenapa dinamis?

Sebelum ngoding, kamu wajib paham bedanya. Bank Indonesia membagi QRIS MPM (Merchant Presented Mode) jadi dua jenis:

  1. QRIS statis — satu kode QR yang sama untuk semua transaksi, biasanya dicetak jadi stiker. Pelanggan scan, lalu input nominal sendiri di aplikasi dompet digital. Sederhana, tapi rawan salah ketik nominal dan susah dilacak per transaksi.
  2. QRIS dinamis — QR di-generate khusus untuk setiap transaksi, sudah memuat nominal. Satu QR hanya berlaku untuk satu pembayaran. Lebih aman (unik per transaksi), mengurangi risiko salah nominal, dan jauh lebih mudah direkonsiliasi.

Untuk aplikasi kasir POS, dinamis adalah pilihan yang benar. Alasannya sederhana: kasir butuh nominal terkunci, struk otomatis, dan audit yang rapi. Dengan QR dinamis, setiap QR punya order_id unik yang langsung cocok sama record transaksi di database. Kalau kamu belum punya aplikasi kasir sama sekali, mulai dulu dari tutorial membuat aplikasi kasir Laravel Vue.js yang sudah pernah kami tulis.

Sekarang, kenapa Midtrans? Karena Midtrans sudah terintegrasi sebagai acquirer GoPay dan ShopeePay untuk QRIS, sehingga satu QR dinamis yang kamu generate bisa di-scan oleh GoPay, ShopeePay, DANA, OVO, dan semua e-wallet QRIS-compliant lainnya. Kamu nggak perlu integrasi satu-satu ke tiap PSP. Detail lengkapnya ada di dokumentasi QRIS Midtrans.

Prasyarat: apa yang dibutuhkan

Sebelum mulai, pastikan kamu punya:

  1. Laravel 11 atau 12 terinstal (PHP 8.2+).
  2. Akun Midtrans — daftar di Midtrans dashboard, bisa pakai sandbox gratis untuk testing.
  3. Server Key dan Client Key dari Midtrans (Settings → Access Keys).
  4. Akun merchant QRIS yang sudah aktif di Midtrans (otomatis aktif kalau bisnis kamu sudah diverifikasi).
  5. Web server dengan HTTPS (wajib untuk webhook Midtrans). Untuk lokal, pakai ngrok atau expose.dev.
  6. Pengetahuan dasar Laravel: routing, controller, migration, dan queue jobs.

Kalau kamu mau langsung pakai sistem yang sudah jadi tanpa ngoding dari nol, ada aplikasi kasir POS siap pakai di Kios Koding yang bisa kamu jadikan baseline.

Langkah 1: Setup Midtrans dan API key

Pertama, tambahkan kredensial Midtrans ke .env Laravel kamu:

MIDTRANS_SERVER_KEY=SB-Mid-server-xxxxxxxxxxxxx
MIDTRANS_CLIENT_KEY=SB-Mid-client-xxxxxxxxxxxxx
MIDTRANS_IS_SANDBOX=true
MIDTRANS_NOTIF_URL=https://yourapp.com/api/midtrans/notification

Pakai sandbox dulu (SB- prefix) selama development. Setelah production, ganti dengan production key dan set MIDTRANS_IS_SANDBOX=false.

Lalu buat service Midtrans sendiri (atau pakai package community seperti khumam/laravel-midtrans). Di tutorial ini, kita tulis service minimal supaya kamu paham alurnya:

// app/Services/MidtransService.php
namespace App\Services;

use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class MidtransService
{
private string $serverKey;
private string $baseUrl;

public function __construct()
{
$this->serverKey = config('services.midtrans.server_key');
$this->baseUrl = config('services.midtrans.is_sandbox')
? 'https://api.sandbox.midtrans.com'
: 'https://api.midtrans.com';
}

private function authHeader(): string
{
return 'Basic ' . base64_encode($this->serverKey . ':');
}
}

Daftarkan binding di config/services.php:

'midtrans' => [
'server_key' => env('MIDTRANS_SERVER_KEY'),
'client_key' => env('MIDTRANS_CLIENT_KEY'),
'is_sandbox' => env('MIDTRANS_IS_SANDBOX', true),
],

Verifikasi koneksi dengan melakukan test charge kecil di sandbox. Kalau balik 201 dan ada transaction_status: pending, berarti setup kamu benar.

Langkah 2: Buat endpoint generate QR dinamis

Inti dari integrasi POS adalah memanggil POST /v2/charge dari backend Laravel. Setiap transaksi menghasilkan QR baru. Begini controllernya:

// app/Http/Controllers/QrisController.php
namespace App\Http\Controllers;

use App\Models\Order;
use App\Services\MidtransService;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Http;

class QrisController extends Controller
{
public function generate(Request $request, MidtransService $midtrans)
{
$request->validate([
'amount' => 'required|integer|min:100',
]);

$order = Order::create([
'order_id' => 'TRX-' . Str::uuid()->toString(),
'gross_amount' => $request->amount,
'status' => 'pending',
]);

$payload = [
'payment_type' => 'qris',
'transaction_details' => [
'order_id' => $order->order_id,
'gross_amount' => $order->gross_amount,
],
'qris' => [
'acquirer' => 'gopay',
],
];

$response = Http::withHeaders([
'Accept' => 'application/json',
'Content-Type' => 'application/json',
'Authorization' => $midtrans->authHeader(),
'X-Override-Notification' => config('services.midtrans.notif_url'),
])->post('https://api.sandbox.midtrans.com/v2/charge', $payload);

$body = $response->json();

if ($response->status() === 201) {
$qrAction = collect($body['actions'])
->firstWhere('name', 'generate-qr-code');

$order->update([
'transaction_id' => $body['transaction_id'],
'qr_url' => $qrAction['url'] ?? null,
'qr_string' => $body['qr_string'] ?? null,
]);

return response()->json([
'order_id' => $order->order_id,
'qr_url' => $order->qr_url,
'qr_string' => $order->qr_string,
'transaction_id' => $order->transaction_id,
]);
}

return response()->json(['error' => $body['status_message'] ?? 'Charge failed'], 400);
}
}

Beberapa hal penting dari kode di atas:

  1. X-Override-Notification wajib di-set kalau mau Midtrans kirim webhook ke URL spesifik (misalnya saat testing atau kalau kamu punya multiple environment). Ini khas mode POS, jarang dibahas di artikel Snap.
  2. acquirer: gopay berarti QR kamu bisa di-scan semua e-wallet QRIS — bukan hanya GoPay. Midtrans sebagai acquirer GoPay otomatis menerjemahkan ke standard QRIS nasional.
  3. order_id harus unik per transaksi. UUID + prefix TRX- sudah cukup.
  4. Simpan qr_string ke database supaya bisa di-render ulang kalau kasir butuh refresh tanpa call API lagi.

Response sukses mengembalikan actions array berisi URL gambar QR (generate-qr-code). Simpan URL itu — dipakai di langkah berikutnya.

Tambahkan route:

// routes/api.php
use App\Http\Controllers\QrisController;

Route::post('/qris/generate', [QrisController::class, 'generate']);

Alur lengkap POS integration ada di dokumentasi GoPay QRIS POS Integration Midtrans.

Langkah 3: Tampilkan QR di halaman kasir

Setelah endpoint generate jadi, frontend kasir tinggal POST ke /api/qris/generate, lalu render QR yang balik. Kalau kamu pakai Blade + sedikit JavaScript:

{{-- resources/views/kasir/qris.blade.php --}}
<div id="qris-panel" x-data="{ qrUrl: null, orderId: null }">
<button @click="generateQr()">Generate QR</button>

<template x-if="qrUrl">
<div>
<img :src="qrUrl" alt="QRIS dinamis untuk pembayaran" />
<p>Scan untuk bayar</p>
<p x-text="'Order: ' + orderId"></p>
</div>
</template>
</div>

<script>
function generateQr() {
fetch('/api/qris/generate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
},
body: JSON.stringify({ amount: window.cartTotal }),
})
.then(r => r.json())
.then(data => {
this.qrUrl = data.qr_url;
this.orderId = data.order_id;
});
}
</script>

Untuk mode production yang serius, biasanya kasir auto-polling status transaksi tiap 2–3 detik sampai settlement. Tapi lebih efisien pakai webhook (langkah berikutnya) + WebSocket/Broadcast event ke kasir supaya real-time tanpa polling.

Tips praktis: tambahkan countdown timer berdasarkan validityPeriod QRIS (default 15 menit). Setelah expired, QR nggak bisa dibayar, jadi kasir harus generate ulang.

Langkah 4: Webhook untuk notifikasi pembayaran

Ini bagian yang sering bikin bug. Saat customer scan dan bayar, Midtrans akan POST HTTP notification ke URL yang kamu daftarkan (atau via X-Override-Notification). Berikut controller webhooknya:

// app/Http/Controllers/MidtransNotificationController.php
namespace App\Http\Controllers;

use App\Models\Order;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;

class MidtransNotificationController extends Controller
{
public function handle(Request $request)
{
$data = $request->all();

// 1. Verifikasi signature key
$serverKey = config('services.midtrans.server_key');
$expected = hash('sha512',
$data['order_id'] .
$data['status_code'] .
$data['gross_amount'] .
$serverKey
);

if ($expected !== $data['signature_key']) {
Log::warning('Midtrans signature mismatch', ['order' => $data['order_id']]);
return response('Invalid signature', 403);
}

// 2. Cari order, pastikan idempotent
$order = Order::where('order_id', $data['order_id'])->first();
if (!$order) {
return response('Order not found', 404);
}

// 3. Cegah proses ganda
if (in_array($order->status, ['settlement', 'paid'])) {
return response('OK', 200);
}

// 4. Update status berdasarkan transaction_status
$status = $data['transaction_status'];
$newStatus = match($status) {
'settlement' => 'paid',
'pending' => 'pending',
'deny',
'cancel',
'expire' => 'failed',
default => 'unknown',
};

$order->update(['status' => $newStatus]);

if ($newStatus === 'paid') {
// Trigger event cetak struk
event(new \App\Events\PaymentSettled($order));
}

return response('OK', 200);
}
}

Beberapa poin kritis:

  1. Signature key dihitung dengan SHA512(order_id + status_code + gross_amount + server_key). Ini formula resmi Midtrans. Jangan skip verifikasi — tanpa ini, webhook kamu rentan dipalsukan.
  2. Idempotency — Midtrans bisa kirim notifikasi yang sama berkali-kali (retry, atau settlement + fraud_status update). Cek status sekarang sebelum update.
  3. Return 200 secepatnya. Midtrans expect response <30 detik. Kalau lambat, mereka retry. Kalau kamu butuh proses berat (cetak, kirim email), push ke queue.
  4. Untuk production, daftarkan URL webhook di dashboard Midtrans (Settings → Configuration → Payment Notification URL) supaya Midtrans tahu ke mana POST.

Route webhook:

// routes/api.php
Route::post('/midtrans/notification', [MidtransNotificationController::class, 'handle']);
Penting: Laravel CSRF protection exclude route webhook. Tambahkan /api/midtrans/* ke $except di app/Http/Middleware/VerifyCsrfToken.php, atau pakai api middleware group yang sudah tanpa CSRF.

Langkah 5: Cetak struk setelah settlement

Setelah webhook update status ke paid, langkah terakhir adalah cetak struk. Kalau kasir pakai thermal printer (misal Epson TM series), pakai escpos/php-printer:

// app/Jobs/PrintReceipt.php
namespace App\Jobs;

use App\Models\Order;
use Mike42\Escpos\PrintConnectors\NetworkPrintConnector;
use Mike42\Escpos\Printer;
use Illuminate\Bus\Queueable;

class PrintReceipt implements ShouldQueue
{
use Queueable;

public function __construct(public int $orderId) {}

public function handle()
{
$order = Order::with('items')->find($this->orderId);
if (!$order || $order->status !== 'paid') return;

$connector = new NetworkPrintConnector(
config('pos.printer_ip'),
config('pos.printer_port', 9100)
);
$printer = new Printer($connector);

$printer->setEmphasis(true);
$printer->text("TOKO KAMU\n");
$printer->setEmphasis(false);
$printer->text("Jl. Contoh No. 1\n");
$printer->feed();

$printer->text("Order: {$order->order_id}\n");
$printer->text("Tanggal: {$order->updated_at}\n");
$printer->feed();

foreach ($order->items as $item) {
$printer->text(sprintf("%-20s %3d %10s\n",
$item->name, $item->qty, 'Rp' . number_format($item->subtotal, 0, ',', '.')
));
}

$printer->feed();
$printer->setEmphasis(true);
$printer->text(sprintf("TOTAL %30s\n", 'Rp' . number_format($order->gross_amount, 0, ',', '.')));
$printer->setEmphasis(false);
$printer->text("Pembayaran: QRIS (Settled)\n");
$printer->feed(2);

$printer->text("Terima kasih!\n");
$printer->cut();
$printer->close();
}
}

Dispatch job ini dari event PaymentSettled:

// app/Listeners/PrintReceiptListener.php
class PrintReceiptListener
{
public function handle(PaymentSettled $event)
{
PrintReceipt::dispatch($event->order->id);
}
}

Kenapa pakai queue? K karena thermal printer bisa nge-hang atau offline. Kalau synchronous, kasir stuck nunggu. Dengan queue, retry otomatis dan kasir bisa lanjut melayani customer berikutnya.

Tips: idempotency dan rekonsiliasi harian

Dua hal yang sering luput dari tutorial tapi kritikal di production:

Idempotency

Midtrans mengirim notification berkali-kali. Selalu cek status order sebelum update. Pakai pattern ini di webhook:

if ($order->status === 'paid' && $data['transaction_status'] === 'settlement') {
return response('OK', 200); // sudah diproses, skip
}

Atau lebih robust, simpan signature_key yang sudah diproses di cache:

$cacheKey = "midtrans:processed:{$data['order_id']}:{$data['transaction_id']}";
if (Cache::has($cacheKey)) {
return response('OK', 200);
}
Cache::put($cacheKey, true, now()->addDay());

Ini mencegah double-print struk, double-update inventory, dan double-emit receipt email.

Rekonsiliasi harian

Jangan cuma andalkan webhook. Webhook bisa gagal (network down, server maintenance). Bangun job rekonsiliasi harian yang membandingkan record database dengan status di Midtrans via API:

// app/Console/Commands/ReconcileQrisTransactions.php
class ReconcileQrisTransactions extends Command
{
protected $signature = 'qris:reconcile {--date=}';

public function handle(MidtransService $midtrans)
{
$date = $this->option('date') ?? now()->subDay()->format('Y-m-d');
$orders = Order::whereDate('created_at', $date)
->where('status', 'pending')
->get();

$mismatch = 0;
foreach ($orders as $order) {
$status = $midtrans->getTransactionStatus($order->transaction_id);
$mtStatus = $status['transaction_status'] ?? null;

if ($mtStatus === 'settlement' && $order->status !== 'paid') {
$order->update(['status' => 'paid']);
$this->info("Reconciled: {$order->order_id}");
$mismatch++;
} elseif ($mtStatus === 'expire') {
$order->update(['status' => 'expired']);
}
}

$this->info("Reconciled {$mismatch} mismatched orders.");
}
}

Jadwalkan dengan scheduler:

// app/Console/Kernel.php atau routes/console.php (Laravel 11+)
Schedule::command('qris:reconcile')->dailyAt('02:00');

Tips tambahan

  1. Timeout handling — set validityPeriod QR (default 15 menit). Kalau lewat, Midtrans kirim webhook expire.
  2. Logging — log semua webhook raw payload ke table midtrans_notifications untuk audit trail.
  3. Multi-outlet — kalau punya banyak cabang, gunakan satu Midtrans account tapi bedah merchant_id per outlet, atau pakai akun terpisah.
  4. MDR — QRIS transactions up to Rp 100.000 dapat MDR 0% dari Oktober 2026 (regulasi Bank Indonesia), jadi makin murah buat transaksi kecil.

Kesimpulan

Integrasi QRIS dinamis di aplikasi kasir Laravel kamu sebenarnya bukan hal yang rumit kalau paham alurnya: POST /v2/charge untuk generate QR → tampilkan di layar kasir → handle webhook settlement dengan signature verification → cetak struk via queue → jalankan rekonsiliasi harian untuk safety net. Yang membedakan implementasi POS dengan Snap checkout adalah per-transaksi QR generation, idempotency notification, dan coupling langsung ke proses fisik kasir (cetak struk, update inventory). Untuk developer Indonesia, ini skill yang high-demand karena QRIS sudah jadi standar pembayaran de facto — data Bank Indonesia mencatat transaksi QRIS tumbuh hampir 100% year-on-year di 2026.

Kalau kamu mau mulai dari source code yang sudah jadi dan tinggal kustomisasi, cek produk siap pakai di Kios Koding — termasuk aplikasi kasir POS yang sudah integrate QRIS. Atau kalau mau bangun sendiri, baca juga tutorial membuat aplikasi kasir Laravel Vue.js kami sebagai fondasi sebelum menambahkan payment gateway. Selamat ngoding, dan semoga transaksi QRIS di tokomu makin lancar!

Referensi & bacaan lanjutan:

  1. GoPay QRIS POS Integration — Midtrans Docs
  2. QRIS API Reference — Midtrans
  3. Bank Indonesia — Data & Statistik QRIS