Blog · 7 September 2026 · 9 menit baca

Cara Integrasi QRIS Payment Gateway di Laravel (Service, Controller, Webhook)

Tutorial integrasi pembayaran QRIS di Laravel 10/11: buat service class dengan Http client, controller generate QR, route webhook yang idempoten, dan halaman polling status. Tanpa package tambahan.

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.

Coba NasionalPay gratis

Daftar akun merchant, buat toko, dan terima QRIS di website kamu hari ini. Tanpa biaya pendaftaran.