Panduan · Terbit
Integrasi payment gateway di Livewire 4: nominal yang bisa diganti dari browser, klik ganda, dan wire:poll
Di Livewire, tombol Bayar memanggil method PHP langsung dari Blade, jadi integrasi payment gateway terasa seperti menulis controller biasa. Bedanya ada pada satu hal yang mudah terlewat: setiap properti publik komponen dikirim ke browser dan bisa dikirim balik dengan nilai lain. Nominal yang disimpan di properti publik lalu dipakai untuk membuat tagihan adalah nominal yang dipilih pembeli. Panduan ini memasang Kasera Pay di komponen Livewire 4 dengan susunan yang menutup celah itu:
- komponen menyimpan model
Order, bukan nominal, dan nominal dibaca dari database di dalam action; Idempotency-Keyberbentukorder-{id}-{attempt}, sama untuk setiap klik pada percobaan yang sama;payment.expiredmenaikkanattempt, sehingga klik berikutnya membuat tagihan baru;- halaman status memakai
wire:pollyang hanya dirender selama pesanan belum dibayar.
Semua kode di bawah dijalankan pada Livewire 4.4.8, Laravel 13.39, PHP 8.3, dan SQLite, dengan API tiruan di port lain sebagai pengganti Kasera Pay dan Chromium lewat Playwright sebagai pembeli. API Kasera Pay yang sesungguhnya tidak dipanggil. Kontrak API-nya sama dengan panduan integrasi payment gateway di Laravel, dan verifikasi tanda tangan webhook dipakai apa adanya dari sana. Jalur yang lebih singkat untuk memanggil API adalah SDK PHP resmi (kasera/kasera-pay), yang bisa dipanggil dari action yang sama.
Properti publik adalah input pengguna
Komponen berikut terlihat wajar: nominal disalin ke properti saat mount(), lalu dipakai saat tombol Bayar ditekan. Pada uji, satu baris di konsol browser mengganti nominalnya, dan permintaan pembayaran dibuat untuk Rp 1.000 alih-alih Rp 285.000. Tidak ada error, tidak ada peringatan.
{{-- JANGAN: nominal sebagai properti publik biasa --}}
new class extends Component {
public int $orderId;
public int $amount;
public string $description;
public function mount(Order $order)
{
$this->orderId = $order->id;
$this->amount = $order->amount;
$this->description = $order->description;
}
public function pay(KaseraPay $kasera)
{
$transaction = $kasera->createTransaction([
'amount' => $this->amount, // nilai yang terakhir dikirim browser
'description' => $this->description,
'external_id' => "order-{$this->orderId}",
], "order-{$this->orderId}-0");
$this->redirect($transaction['checkout_url']);
}
};// Di konsol browser, pada halaman checkout tadi:
Livewire.all()[0].$wire.$set('amount', 1000)
// Lalu tekan Bayar: permintaan pembayaran dibuat untuk Rp 1.000.Menambahkan #[Locked] pada $amount menutup celah ini: perubahan yang sama dijawab 500 dengan CannotUpdateLockedPropertyException dan API tidak dipanggil. Susunan yang lebih kuat tidak menyimpan nominal di properti sama sekali. Model Eloquent di properti publik dikunci Livewire secara otomatis; pada uji, mengganti order dari konsol tidak mengubah pesanan yang ditagih. Kalau komponen hanya menyimpan id berupa angka, kunci id itu:
use Livewire\Attributes\Locked;
#[Locked]
public int $orderId;Hal yang sama berlaku ke arah sebaliknya. Isi properti publik tertulis di atribut wire:snapshot pada HTML halaman. Pada uji, properti $apiKey yang diisi dari config muncul utuh sebagai teks di sumber halaman. API key, signing secret, dan respons API mentah tidak pernah disimpan di properti publik; ambil key dari config() di dalam service, seperti di bawah.
1. Kredensial, tabel, dan service
API key berawalan kp_test_ selama membangun dan kp_live_ setelah go-live. Signing secret webhook berbeda per mode dan diambil dari dashboard, menu Developer.
# .env (kp_test_ selama membangun, kp_live_ setelah go-live)
KASERA_PAY_KEY=kp_test_...
KASERA_PAY_WEBHOOK_SECRET=whsec_...// config/services.php
'kasera_pay' => [
'key' => env('KASERA_PAY_KEY'),
'base_url' => env('KASERA_PAY_BASE_URL', 'https://pay.kasera.id'),
'webhook_secret' => env('KASERA_PAY_WEBHOOK_SECRET'),
],Kolom attempt adalah bagian dari Idempotency-Key. Key yang dihitung dari id pesanan dan nomor percobaan tidak perlu disimpan lebih dulu, sehingga dua klik yang datang bersamaan tidak berlomba membuat key masing-masing.
<?php
// database/migrations/xxxx_create_kasera_pay_tables.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('orders', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('amount');
$table->string('description');
$table->string('status')->default('pending');
$table->unsignedInteger('attempt')->default(0);
$table->string('kasera_transaction_id')->nullable()->index();
$table->timestamp('paid_at')->nullable();
$table->timestamps();
});
Schema::create('kasera_pay_events', function (Blueprint $table) {
$table->id();
$table->string('event_id')->unique();
$table->timestamp('created_at')->nullable();
});
}
};<?php
// app/Models/Order.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Order extends Model
{
protected $guarded = [];
protected $casts = ['paid_at' => 'datetime'];
}<?php
// app/Services/KaseraPay.php
namespace App\Services;
use Illuminate\Support\Facades\Http;
class KaseraPay
{
public function createTransaction(array $payload, string $idempotencyKey): array
{
return Http::baseUrl(config('services.kasera_pay.base_url'))
->withToken(config('services.kasera_pay.key'))
->withHeaders(['Idempotency-Key' => $idempotencyKey])
->acceptJson()
->timeout(15)
->post('/v1/transactions', $payload)
->throw()
->json();
}
}2. Komponen checkout
Livewire 4 membuat komponen satu berkas secara bawaan, dengan awalan ⚡ pada nama berkasnya. Action pay() membaca ulang pesanan, menolak pesanan yang sudah dibayar, lalu membuat permintaan pembayaran dan mengarahkan pembeli ke checkout_url. Karena URL itu berada di domain lain, $this->redirect() biasa sudah cukup; pada uji, redirect()->away() sampai ke halaman yang sama.
{{-- resources/views/pages/⚡checkout.blade.php --}}
<?php
use App\Models\Order;
use App\Services\KaseraPay;
use Livewire\Component;
new class extends Component {
// Model Eloquent di properti publik dikunci Livewire: id-nya tidak bisa
// diganti dari browser. Nominal TIDAK disimpan sebagai properti sendiri.
public Order $order;
public function pay(KaseraPay $kasera)
{
// Baca ulang dari database, jangan percaya apa pun yang ikut dari browser.
$order = Order::findOrFail($this->order->id);
if ($order->status !== 'pending') {
return $this->redirectRoute('orders.show', $order);
}
$transaction = $kasera->createTransaction([
'amount' => (int) $order->amount,
'description' => $order->description,
'external_id' => "order-{$order->id}",
'return_url' => route('orders.show', $order),
], "order-{$order->id}-{$order->attempt}");
$order->update(['kasera_transaction_id' => $transaction['id']]);
// checkout_url ada di domain lain: redirect biasa ke luar aplikasi.
$this->redirect($transaction['checkout_url']);
}
};
?>
<div>
<p>{{ $order->description }}: Rp {{ number_format($order->amount, 0, ',', '.') }}</p>
<button type="button" wire:click="pay" wire:loading.attr="disabled">
<span wire:loading.remove wire:target="pay">Bayar</span>
<span wire:loading wire:target="pay">Menyiapkan pembayaran...</span>
</button>
</div>wire:loading.attr="disabled" mematikan tombol selama request berjalan. Pada uji, tiga klik berjarak 0, 100, dan 250 milidetik dari tab yang sama hanya menghasilkan satu request Livewire. Tetapi tab lain, refresh, dan percobaan ulang tidak tertahan oleh tombol: sepuluh tab yang menekan Bayar bersamaan menghasilkan sepuluh panggilan create, dan kesepuluhnya membawa key order-1-0 yang sama, sehingga hanya ada satu permintaan pembayaran. Penjaga yang sebenarnya adalah key itu. Key yang dibuat acak setiap klik, misalnya dengan Str::uuid(), membuat setiap klik menjadi tagihan sendiri. Hanya header ini yang mencegah tagihan ganda; external_id hanyalah label. Rinciannya ada di dokumentasi idempotency.
return_url menunjuk ke halaman status pesanan. Pada mode tes, URL http:// dari mesin lokal diterima; create di mode live wajib https.
3. Halaman status yang berhenti polling sendiri
Setelah membayar, pembeli kembali dengan tambahan ?id=payreq_...&status=succeeded di URL. Parameter itu tidak dibaca, karena siapa pun bisa mengetiknya. Halaman status hanya menampilkan kolom status pesanan, dan wire:poll berada di dalam cabang yang dirender selama pesanan belum dibayar.
{{-- resources/views/pages/⚡order-status.blade.php --}}
<?php
use App\Models\Order;
use Livewire\Component;
new class extends Component {
public Order $order;
};
?>
<div>
@if ($order->status === 'paid')
<p>Pembayaran diterima. Pesanan #{{ $order->id }} sedang diproses.</p>
@else
{{-- Polling berhenti sendiri begitu elemen ini tidak dirender lagi. --}}
<div wire:poll.3s>
<p>Menunggu konfirmasi pembayaran untuk pesanan #{{ $order->id }}...</p>
</div>
@endif
</div>Pada uji, halaman yang dibuka dengan status=succeeded di URL tetap menampilkan “Menunggu konfirmasi”, polling dua kali dalam tujuh detik, lalu berhenti sepenuhnya begitu pesanan berubah menjadi dibayar: nol request dalam tujuh detik berikutnya. Livewire juga memperlambat polling sendiri saat tab berada di latar belakang, jadi pembeli yang meninggalkan tab terbuka tidak membebani server.
4. Route, CSRF, dan webhook
Endpoint update milik Livewire tetap dilindungi CSRF dan tidak disentuh. Yang dikecualikan hanya route webhook, karena pengirimnya server Kasera Pay yang tidak membawa sesi. Pada uji, menghapus pengecualian ini membuat setiap pengiriman dijawab 419.
<?php
// routes/web.php
use App\Http\Controllers\KaseraPayWebhookController;
use Illuminate\Support\Facades\Route;
Route::livewire('/checkout/{order}', 'pages::checkout')->name('checkout');
Route::livewire('/pesanan/{order}', 'pages::order-status')->name('orders.show');
Route::post('/kasera-pay/webhook', KaseraPayWebhookController::class);// bootstrap/app.php
->withMiddleware(function (Middleware $middleware): void {
$middleware->validateCsrfTokens(except: ['kasera-pay/webhook']);
})Handler di bawah memakai kelas KaseraPaySignature dari panduan Laravel tanpa perubahan: header Kasera-Signature-V1, HMAC-SHA256 atas body mentah, dan toleransi lima menit. Pesanan dicocokkan lewat external_id, bukan lewat id permintaan pembayaran, karena satu pesanan bisa punya beberapa percobaan.
<?php
// app/Http/Controllers/KaseraPayWebhookController.php
namespace App\Http\Controllers;
use App\Models\Order;
use App\Support\KaseraPaySignature;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
use RuntimeException;
class KaseraPayWebhookController extends Controller
{
public function __invoke(Request $request): Response
{
$rawBody = $request->getContent();
if (! KaseraPaySignature::verify(
$rawBody,
$request->header('Kasera-Signature-V1'),
(string) config('services.kasera_pay.webhook_secret'),
)) {
return response('', 400);
}
$event = json_decode($rawBody, true);
$data = $event['data'] ?? [];
DB::transaction(function () use ($event, $data): void {
$first = DB::table('kasera_pay_events')->insertOrIgnore([
'event_id' => $event['id'],
'created_at' => now(),
]) === 1;
if (! $first) {
return;
}
$orderId = (int) str_replace('order-', '', $data['external_id'] ?? '');
$order = Order::whereKey($orderId)->lockForUpdate()->first();
if ($order === null) {
return;
}
if ($event['type'] === 'payment.paid') {
if ((int) $data['amount'] !== (int) $order->amount) {
// Batalkan id event juga: jawaban 500 tercatat di log pengiriman.
throw new RuntimeException("Nominal tidak cocok untuk order {$order->id}");
}
$order->update(['status' => 'paid', 'paid_at' => $data['paid_at']]);
}
if ($event['type'] === 'payment.expired'
&& $order->status === 'pending'
&& $order->kasera_transaction_id === $data['payment_request_id']) {
// Klik Bayar berikutnya memakai key baru, jadi tagihan baru.
$order->increment('attempt');
}
});
return response('', 200);
}
}Yang dibuktikan pada uji terhadap handler ini:
- pengiriman bertanda tangan benar dijawab 200, secret yang salah 400, dan
tberumur 600 detik 400; - 30 pengiriman kembar yang datang bersamaan dijawab 200 semuanya, dengan tepat satu baris event;
- nominal yang tidak cocok dijawab 500 dan baris event-nya ikut batal, sehingga tercatat di log pengiriman;
payment.expireduntuk permintaan yang sedang berjalan menaikkanattempt, dan klik berikutnya mengirim keyorder-1-1;payment.paidyang datang telat untuk permintaan lama tetap menandai pesanan dibayar;paid_atberzona +07:00 tersimpan sebagai 05:04:58 UTC untuk 12:04:58 WIB, karena Eloquent mengonversinya ke zona waktu aplikasi.
Pembayaran yang telat punya satu akibat yang perlu diputuskan sendiri: kalau pembeli sempat membayar permintaan lama dan permintaan baru, pesanan ditandai dibayar sekali, dan pembayaran kedua dikembalikan dengan transfer dari rekening sendiri. Daftar event dan isinya ada di dokumentasi webhook.
5. Feature test
Http::fake() menutup sisi create dan Livewire::test() menjalankan action komponen tanpa browser. Setiap test di bawah sudah dilihat gagal: key acak dari Str::uuid() menjatuhkan tiga test, menghapus increment('attempt') menjatuhkan test kedaluwarsa, dan merender wire:poll tanpa syarat menjatuhkan test halaman status.
<?php
// tests/Feature/CheckoutTest.php
namespace Tests\Feature;
use App\Models\Order;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
use Tests\TestCase;
class CheckoutTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
config()->set('services.kasera_pay.key', 'kp_test_example');
config()->set('services.kasera_pay.base_url', 'https://pay.kasera.id');
config()->set('services.kasera_pay.webhook_secret', 'whsec_example');
Http::fake(['pay.kasera.id/v1/transactions' => Http::response([
'id' => 'payreq_9b2f',
'status' => 'pending',
'checkout_url' => 'https://pay.kasera.id/p/tok_abc',
], 201)]);
}
private function deliver(array $event)
{
$body = json_encode($event);
$t = time();
return $this->call('POST', '/kasera-pay/webhook', [], [], [], [
'HTTP_KASERA-SIGNATURE-V1' => 't=' . $t . ',v1=' . hash_hmac('sha256', $t . '.' . $body, 'whsec_example'),
'CONTENT_TYPE' => 'application/json',
], $body);
}
public function test_pay_sends_the_amount_from_the_database_and_redirects(): void
{
$order = Order::create(['amount' => 285000, 'description' => 'Gunpla HG']);
Livewire::test('pages::checkout', ['order' => $order])
->call('pay')
->assertRedirect('https://pay.kasera.id/p/tok_abc');
Http::assertSent(fn ($request) => $request['amount'] === 285000
&& $request->hasHeader('Idempotency-Key', "order-{$order->id}-0"));
}
public function test_paying_twice_reuses_the_same_key(): void
{
$order = Order::create(['amount' => 285000, 'description' => 'Gunpla HG']);
Livewire::test('pages::checkout', ['order' => $order])->call('pay');
Livewire::test('pages::checkout', ['order' => $order])->call('pay');
$keys = Http::recorded()->map(fn ($pair) => $pair[0]->header('Idempotency-Key')[0])->unique();
$this->assertSame(["order-{$order->id}-0"], $keys->values()->all());
}
public function test_an_expired_request_gets_a_new_key_on_the_next_click(): void
{
$order = Order::create(['amount' => 285000, 'description' => 'Gunpla HG']);
Livewire::test('pages::checkout', ['order' => $order])->call('pay');
$this->deliver(['id' => 'evt_1', 'type' => 'payment.expired', 'data' => [
'payment_request_id' => 'payreq_9b2f', 'external_id' => "order-{$order->id}", 'amount' => 285000,
]])->assertOk();
Livewire::test('pages::checkout', ['order' => $order])->call('pay');
Http::assertSent(fn ($request) => $request->hasHeader('Idempotency-Key', "order-{$order->id}-1"));
}
public function test_the_status_page_polls_only_while_pending(): void
{
$order = Order::create(['amount' => 285000, 'description' => 'Gunpla HG']);
Livewire::test('pages::order-status', ['order' => $order])->assertSeeHtml('wire:poll.3s');
$order->update(['status' => 'paid']);
Livewire::test('pages::order-status', ['order' => $order])->assertDontSeeHtml('wire:poll');
}
}Sebelum menukar kp_test_ dengan kp_live_, jalankan checklist sebelum go-live.
Pertanyaan yang sering muncul
Apakah aman menyimpan nominal pesanan di properti publik komponen Livewire?
Tidak kalau nominal itu yang dikirim ke API. Properti publik ikut ke browser dan bisa diubah dari sana, misalnya lewat $wire.$set di konsol, lalu nilai baru itulah yang dipakai action berikutnya. Pada uji untuk panduan ini, mengubah amount dari 285000 menjadi 1000 membuat permintaan pembayaran dibuat untuk Rp 1.000. Simpan model Eloquent-nya, yang dikunci Livewire, dan baca nominal dari database di dalam action.
Apakah #[Locked] cukup untuk melindungi nominal?
#[Locked] menolak perubahan dari browser dengan CannotUpdateLockedPropertyException dan jawaban 500, jadi nominal yang dikunci tidak bisa diganti. Tetapi yang paling aman tetap tidak memakai nilai properti untuk uang sama sekali: baca ulang pesanan dari database di dalam action, karena harga atau status pesanan bisa sudah berubah sejak halaman dibuka.
Kenapa tombol Bayar yang ditekan berkali-kali tidak membuat tagihan ganda?
Dua hal yang berbeda. Pada Livewire 4.4.8, klik kedua dan ketiga saat permintaan pertama masih berjalan tidak menghasilkan request kedua dari tab yang sama. Tab lain, refresh, dan percobaan ulang tidak tertahan oleh itu: sepuluh tab yang menekan Bayar bersamaan menghasilkan sepuluh panggilan create. Yang membuat sepuluh panggilan itu tetap satu permintaan pembayaran adalah Idempotency-Key yang sama untuk satu percobaan pesanan.
Apakah Livewire punya package Kasera Pay sendiri?
Tidak ada package khusus Livewire, dan tidak diperlukan, karena Livewire berjalan di atas Laravel. SDK PHP resmi (composer require kasera/kasera-pay, PHP 8.1 ke atas) bisa dipakai dari action komponen dan menjadi jalur yang lebih singkat. Panduan ini memakai HTTP client bawaan Laravel supaya setiap langkahnya terlihat.
Apakah halaman status dengan wire:poll bisa menggantikan webhook?
Tidak. Halaman status hanya membaca kolom status pesanan di database, dan kolom itu diubah oleh webhook. Parameter status=succeeded yang ikut di URL kembali tidak dibaca sama sekali, karena URL itu bisa diketik siapa saja.