Laravel adalah framework PHP paling banyak dipakai untuk toko online dan aplikasi SaaS di Indonesia. Artikel ini menunjukkan cara menambahkan pembayaran QRIS dinamis ke aplikasi Laravel 10/11 hanya dengan Http client bawaan, tanpa package pihak ketiga. Contoh memakai API NasionalPay, tapi polanya sama untuk payment gateway QRIS lain: generate → tampilkan QR → terima webhook → verifikasi ulang.
Persiapan
- Laravel 10 atau 11, PHP 8.1+.
- Akun merchant payment gateway dan Store Key toko yang sudah aktif. Di NasionalPay: daftar, buat toko, tunggu aktivasi admin, salin Store Key dari Merchant → Detail Toko.
- URL aplikasi HTTPS yang bisa diakses publik untuk menerima webhook (saat development pakai ngrok atau expose).
Langkah 1: Konfigurasi
Simpan Store Key di .env dan baca lewat file config, jangan hardcode di controller. Ini memudahkan ganti key antar toko atau environment.
# .env
NASIONALPAY_BASE=https://merchant.nasionalpay.com/api/payment
NASIONALPAY_STORE_KEY=isi_store_key_toko_kamu<?php
// config/nasionalpay.php
return [
'base' => env('NASIONALPAY_BASE', 'https://merchant.nasionalpay.com/api/payment'),
'key' => env('NASIONALPAY_STORE_KEY'),
];Langkah 2: Service class
Bungkus semua panggilan API dalam satu service supaya controller tetap tipis dan mudah diuji (bisa di-mock dengan Http::fake()).
<?php
// app/Services/NasionalPay.php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class NasionalPay
{
public function generate(string $channel, int $amount, string $customer): array
{
return $this->post('/generate', [
'channel' => $channel, // QRIS atau DANA
'amount' => $amount,
'player_username' => $customer, // email / ID pelanggan
]);
}
public function status(string $trxId): array
{
return $this->post('/status', ['transaction_id' => $trxId]);
}
private function post(string $path, array $body): array
{
$res = Http::timeout(30)
->acceptJson()
->post(config('nasionalpay.base') . $path, $body + ['key' => config('nasionalpay.key')]);
$json = $res->json();
if (! $res->ok() || empty($json['success'])) {
throw new RuntimeException($json['message'] ?? 'Gagal menghubungi payment gateway');
}
return $json['data'];
}
}Langkah 3: Migration & model
Tambahkan kolom untuk menyimpan ID transaksi gateway, nominal yang ditagihkan, dan waktu lunas di tabel orders. Kolom trx_id sebaiknya unik supaya webhook mudah mencari pesanan.
<?php
Schema::table('orders', function (Blueprint $table) {
$table->string('trx_id')->nullable()->unique();
$table->string('qr_image')->nullable();
$table->string('qr_expired_at')->nullable();
$table->unsignedInteger('charged_amount')->nullable();
$table->timestamp('paid_at')->nullable();
});Langkah 4: Controller generate QR
<?php
// app/Http/Controllers/PaymentController.php
namespace App\Http\Controllers;
use App\Models\Order;
use App\Services\NasionalPay;
class PaymentController extends Controller
{
public function pay(Order $order, NasionalPay $gateway)
{
abort_if($order->paid_at, 400, 'Pesanan sudah lunas');
// Idempoten: jangan buat QR baru kalau sudah ada dan belum kedaluwarsa
if (! $order->trx_id) {
$data = $gateway->generate('QRIS', (int) $order->total, $order->email);
$order->update([
'trx_id' => $data['transaction_id'],
'qr_image' => $data['qris_image'],
'qr_expired_at' => $data['expired_at'],
'charged_amount' => (int) $data['amount'],
]);
}
return view('payment.qr', compact('order'));
}
public function check(Order $order, NasionalPay $gateway)
{
if ($order->paid_at) {
return response()->json(['status' => 'success']);
}
$status = $gateway->status($order->trx_id)['status'] ?? 'pending';
if ($status === 'success') {
$order->update(['paid_at' => now()]);
}
return response()->json(['status' => $status]);
}
}View payment.qr cukup menampilkan <img src="{{ $order->qr_image }}">, nominal charged_amount, batas waktu, dan sedikit JavaScript yang memanggil route check setiap 5 detik lalu redirect ke halaman sukses saat status success.
Langkah 5: Route webhook yang idempoten
Webhook adalah POST JSON dari gateway ke URL yang kamu daftarkan di panel merchant. Tiga aturan: kecualikan dari CSRF, jangan percaya body callback mentah (verifikasi ulang via /status), dan pastikan aman diproses berulang kali.
<?php
// routes/api.php (prefix /api → tidak kena CSRF)
Route::post('/webhook/nasionalpay', [WebhookController::class, 'handle']);
// app/Http/Controllers/WebhookController.php
class WebhookController extends Controller
{
public function handle(Request $request, NasionalPay $gateway)
{
$trxId = $request->input('data.transaction_id');
if (! $trxId) {
return response()->json(['status' => 'bad request'], 400);
}
$order = Order::where('trx_id', $trxId)->first();
if (! $order) {
return response()->json(['status' => 'not found'], 404);
}
if ($order->paid_at) {
return response()->json(['status' => 'ok']); // sudah diproses
}
$data = $gateway->status($trxId); // verifikasi ulang
if (($data['status'] ?? '') === 'success' && (int) $data['amount'] >= $order->charged_amount) {
DB::transaction(function () use ($order) {
$order->update(['paid_at' => now()]);
event(new OrderPaid($order)); // kirim email, buka akses, dll
});
}
return response()->json(['status' => 'ok']);
}
}Kalau kamu menaruh route di web.php, tambahkan URL-nya ke pengecualian CSRF (di Laravel 11: $middleware->validateCsrfTokens(except: ['webhook/*']) pada bootstrap/app.php). Balas selalu HTTP 200 setelah diproses; balasan 500 membuat gateway menganggap callback gagal.
Langkah 6: Testing tanpa uang asli
<?php
Http::fake([
'*/generate' => Http::response(['success' => true, 'data' => [
'transaction_id' => 'TRXTEST123', 'amount' => 15000,
'qris_image' => 'https://example.test/qr.png', 'expired_at' => '2026-12-31 23:59:00',
]]),
'*/status' => Http::response(['success' => true, 'data' => ['status' => 'success', 'amount' => 15000]]),
]);
$this->postJson('/api/webhook/nasionalpay', ['data' => ['transaction_id' => 'TRXTEST123']])
->assertOk();
$this->assertNotNull($order->fresh()->paid_at);Checklist produksi
- Store Key hanya di .env server; jangan pernah kirim ke Blade/JS.
- Antrekan efek samping (email, aktivasi produk) lewat queue supaya webhook dibalas cepat.
- Tangani status failed: tampilkan tombol "Buat QR baru" kalau pelanggan terlambat membayar.
- Log setiap callback masuk (ID transaksi + hasil) untuk memudahkan rekonsiliasi. Panel NasionalPay juga menyimpan Log Callback per toko.
- Simpan charged_amount saat generate dan bandingkan dengan amount dari /status sebelum menandai lunas.
Biaya & alternatif tanpa coding
Di NasionalPay tidak ada biaya pendaftaran atau bulanan; MDR QRIS 2% per transaksi sukses, penarikan ke BCA/BRI/BNI/DANA/ShopeePay 1% + Rp4.500. Kalau toko kamu memakai WordPress, tidak perlu menulis kode sama sekali: pakai plugin WooCommerce NasionalPay yang menerapkan pola di atas secara otomatis.