Panduan · Terbit
Integrasi payment gateway di CodeIgniter 3: webhook yang dijawab 403 oleh CSRF, db_debug yang diam di produksi, dan nama berkas yang hanya gagal di server Linux
Panduan ini memasang Kasera Pay di aplikasi CodeIgniter 3 yang sudah berjalan, tanpa migrasi ke CodeIgniter 4 dan tanpa package tambahan. Yang dipakai hanya cURL, hash_hmac, dan hash_equals dari PHP, ditambah library, controller, dan query builder bawaan CodeIgniter 3. Kodenya tidak memakai fitur yang hanya ada di PHP 8, jadi bisa dibaca server PHP 7 sekalipun. Aplikasi CodeIgniter 4 punya panduan integrasi CodeIgniter 4 sendiri, karena hampir semua jebakannya berbeda.
Setiap berkas di bawah dijalankan saat panduan ini ditulis, pada CodeIgniter 3.1.13 (rilis terbaru paket codeigniter/framework), PHP 8.3.6, dan MariaDB 10.11.14, dengan tiruan API lokal di tempat Kasera Pay. Yang tidak diuji adalah API Kasera Pay yang sungguhan; kontraknya diambil dari dokumentasi webhook dan referensi API. Tujuh hal yang terbukti saat pengujian, dan yang menjadi isi halaman ini:
- Tanpa
csrf_exclude_uris, setiap pengiriman webhook dijawab 403, dan pengecualian yang ditulis sebagai nama controller juga tetap 403. input->post()daninput_stream()membuat pengiriman yang sah ditolak 400. Hanyaraw_input_streamyang cocok dengan tanda tangannya.- Controller bernama
kasera_pay_webhook.phpberhuruf kecil menjadi 404 di Linux, padahal jalan di laptop Windows. - Dengan
db_debugFALSE di produksi, tabel event yang hilang tetap dijawab 200 dan pesanannya tetap ditandai lunas, tanpa satu pun catatan dedupe. paid_atdari webhook berbentuk ISO 8601 dengan offset. MariaDB menolaknya dalam mode strict dan memotongnya diam-diam tanpa mode itu.composer_autoloadTRUE tidak memuatvendor/di akar proyek.- Di PHP 8.2 ke atas, mode development mencetak deprecation milik CodeIgniter sendiri, dan redirect ke halaman pembayaran gagal karena header sudah terkirim.
Alurnya, sebelum menulis kode
Server membuat permintaan pembayaran, pembeli dibawa ke checkout_url dan membayar di sana, lalu Kasera Pay mengirim payment.paid bertanda tangan ke endpoint webhook. Hanya event itu yang menentukan bahwa uangnya masuk. Kepulangan pembeli ke return_url adalah navigasi, bukan bukti. Metode yang aktif hari ini adalah QRIS dan Virtual Account tujuh bank; daftar yang berlaku untuk akun tertentu dibaca dari GET /v1/payment_methods, seperti dijelaskan di dokumentasi metode pembayaran.
1. Kredensial dari environment server
CodeIgniter 3 tidak mengenal .env. Kebiasaan yang paling sering ditemui adalah menulis kunci langsung di application/config/, dan direktori itu ikut ter-commit. Kunci yang pernah masuk riwayat git harus dianggap bocor. Taruh nilainya di konfigurasi server, lalu baca dengan getenv().
# Apache: di dalam <VirtualHost> atau .htaccess
SetEnv KASERA_PAY_API_KEY kp_test_...
SetEnv KASERA_PAY_WEBHOOK_SECRET whsec_...
SetEnv CI_ENV production
# Nginx + PHP-FPM: kunci di berkas pool, misalnya /etc/php/8.3/fpm/pool.d/www.conf.
# clear_env bawaannya yes, jadi variabel yang di-export di shell TIDAK sampai ke PHP.
env[KASERA_PAY_API_KEY] = kp_test_...
env[KASERA_PAY_WEBHOOK_SECRET] = whsec_...
# dan CI_ENV di blok location PHP pada konfigurasi Nginx:
# fastcgi_param CI_ENV production;Baris CI_ENV ikut di sana karena index.php CodeIgniter 3 membaca $_SERVER['CI_ENV'], dan tanpa itu aplikasi berjalan sebagai development, termasuk di server produksi.
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
// application/config/kaserapay.php
// Berkas ini boleh ter-commit karena tidak berisi rahasia: nilainya dibaca dari
// environment server (SetEnv di Apache, env[] di pool PHP-FPM). CodeIgniter 3
// tidak membaca .env sendiri, dan kunci yang pernah masuk riwayat git harus
// dianggap bocor.
$config['kaserapay_api_key'] = getenv('KASERA_PAY_API_KEY'); // kp_test_... lalu kp_live_...
$config['kaserapay_base_url'] = 'https://pay.kasera.id';
$config['kaserapay_webhook_secret'] = getenv('KASERA_PAY_WEBHOOK_SECRET'); // whsec_...
$config['kaserapay_tolerance'] = 300;2. Library pembuat permintaan pembayaran
CodeIgniter 3 tidak membawa HTTP client, jadi library ini memakai cURL langsung. Dua hal di dalamnya menentukan. Body galat tetap dibaca saat status 4xx datang, karena di situlah error.code berada. Dan timeout tidak boleh dijawab dengan kunci idempotency baru: hasil create yang terputus memang tidak diketahui, dan mengulanginya dengan kunci yang sama adalah satu-satunya cara aman untuk mengetahuinya.
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
// application/libraries/Kaserapay_client.php
// Nama berkas WAJIB diawali huruf kapital. Di Windows atau macOS huruf kecil
// tetap jalan; di server Linux load->library() gagal mencarinya.
class Kaserapay_client
{
private $api_key;
private $base_url;
public function __construct()
{
$CI =& get_instance();
$CI->config->load('kaserapay');
$this->api_key = (string) $CI->config->item('kaserapay_api_key');
$this->base_url = rtrim($CI->config->item('kaserapay_base_url'), '/');
}
/**
* $idempotency_key dibuat SEKALI per pesanan lalu disimpan. Kunci baru di
* setiap percobaan ulang menghapus proteksinya tanpa error apa pun.
*/
public function create_transaction(array $payload, $idempotency_key)
{
return $this->send('POST', '/v1/transactions', $payload, array(
'Idempotency-Key: '.$idempotency_key,
));
}
public function retrieve_transaction($id)
{
return $this->send('GET', '/v1/transactions/'.rawurlencode($id));
}
private function send($method, $path, $payload = NULL, array $headers = array())
{
$headers[] = 'Authorization: Bearer '.$this->api_key;
$headers[] = 'Accept: application/json';
$ch = curl_init($this->base_url.$path);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
if ($payload !== NULL)
{
$headers[] = 'Content-Type: application/json';
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
$body = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($body === FALSE)
{
// Timeout atau koneksi putus: hasil create TIDAK diketahui. Ulangi
// dengan Idempotency-Key yang sama, jangan dengan kunci baru.
throw new RuntimeException('Kasera Pay: '.$error);
}
$decoded = json_decode($body, TRUE);
if ( ! is_array($decoded))
{
throw new RuntimeException('Kasera Pay: response bukan JSON, status '.$status);
}
if ($status >= 400)
{
$code = isset($decoded['error']['code']) ? $decoded['error']['code'] : 'unknown';
throw new RuntimeException('Kasera Pay '.$status.': '.$code);
}
return $decoded;
}
}3. Controller: buat permintaan, lalu antar pembeli
Kunci idempotency dibuat sekali per pesanan, disimpan, lalu dipakai apa adanya di setiap percobaan. Pada pengujian, sepuluh permintaan bersamaan ke pesanan yang sama mengirim satu kunci yang sama ke API, berkat UPDATE bersyarat di bawah. Field external_id dan merchant_ref hanya label; dua create dengan external_id sama tetap menjadi dua permintaan pembayaran.
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
// application/controllers/Checkout.php
class Checkout extends CI_Controller
{
public function pay($order_id)
{
$this->load->database();
$this->load->helper('url');
$this->load->library('kaserapay_client');
$order = $this->db->get_where('orders', array('id' => (int) $order_id))->row_array();
if ($order === NULL)
{
show_404();
}
// Kunci idempotency milik PESANAN, bukan milik percobaan. UPDATE bersyarat
// lalu baca ulang: dua klik bersamaan tidak bisa menyimpan dua kunci
// berbeda, keduanya memakai kunci yang menang.
if (empty($order['kasera_idempotency_key']))
{
$this->db->where('id', $order['id'])
->where('kasera_idempotency_key IS NULL', NULL, FALSE)
->update('orders', array('kasera_idempotency_key' => bin2hex(random_bytes(16))));
$order = $this->db->get_where('orders', array('id' => $order['id']))->row_array();
}
$payment = $this->kaserapay_client->create_transaction(array(
'amount' => (int) $order['amount'],
'description' => 'Pesanan '.$order['number'],
'external_id' => $order['number'],
'merchant_ref' => 'INV-'.$order['number'],
'customer' => array('name' => $order['customer_name']),
'return_url' => site_url('checkout/selesai/'.$order['id']),
), $order['kasera_idempotency_key']);
$this->db->where('id', $order['id'])
->update('orders', array('kasera_payment_id' => $payment['id']));
redirect($payment['checkout_url']);
}
}Helper url dimuat eksplisit karena site_url() dan redirect() berasal dari sana; tanpa itu yang muncul adalah galat fungsi tidak dikenal. Pesanan di atas Rp 1.000.000 membutuhkan field tambahan: delivery_date, delivery_terms_shown bernilai true, keterangan minimal 10 karakter, nama pembeli, dan nomor telepon atau email pembeli. Create tanpa salah satunya ditolak 422 dengan kode yang menyebut field-nya. Rinciannya ada di panduan menerima pembayaran di atas Rp 1.000.000.
4. Route, nama berkas, dan CSRF
<?php
// application/config/routes.php
// Controller-nya di application/controllers/Kasera_pay_webhook.php, huruf
// pertama kapital. Di route ditulis huruf kecil.
$route['webhook/kasera-pay']['post'] = 'kasera_pay_webhook/handle';Sejak CodeIgniter 3.0, nama berkas controller dan library wajib diawali huruf kapital. Sistem berkas Windows dan macOS tidak membedakan huruf besar dan kecil, jadi berkas berhuruf kecil tetap jalan di laptop. Di server Linux, route yang sama menjadi 404. Pada webhook, 404 berarti setiap pengiriman diulang dan akhirnya berhenti, sementara pesanan yang dibayar tidak pernah terpenuhi.
<?php
// application/config/config.php, hanya baris yang berubah.
$config['csrf_protection'] = TRUE;
// Tanpa baris ini setiap pengiriman webhook dijawab 403 "The action you have
// requested is not allowed." Isinya URI (boleh regex), bukan nama controller.
$config['csrf_exclude_uris'] = array('webhook/kasera-pay');Berbeda dengan CodeIgniter 4 yang mengalihkan permintaan di produksi, CodeIgniter 3 menjawab penolakan CSRF dengan 403. Pengecualiannya dicocokkan dengan URI permintaan. Pada pengujian, kasera_pay_webhook/handle di dalam csrf_exclude_uris tetap menghasilkan 403, dan webhook/kasera-pay menghasilkan 200. Kasera Pay mengulang pengiriman yang tidak dijawab 2xx sampai 7 kali dalam sekitar 33 jam, dan sesudah itu berhenti.
5. Webhook: tanda tangan, dedupe, dan pemenuhan pesanan
-- MySQL / MariaDB. Unique key inilah yang mengerjakan dedupe-nya;
-- tanpa itu kode di controller hanya terlihat aman.
CREATE TABLE kasera_pay_events (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
event_id VARCHAR(64) NOT NULL,
created_at DATETIME NOT NULL,
UNIQUE KEY uq_kasera_pay_events_event_id (event_id)
) ENGINE=InnoDB;
ALTER TABLE orders
ADD COLUMN kasera_idempotency_key CHAR(32) NULL,
ADD COLUMN kasera_payment_id VARCHAR(64) NULL;<?php
defined('BASEPATH') OR exit('No direct script access allowed');
// application/controllers/Kasera_pay_webhook.php
class Kasera_pay_webhook extends CI_Controller
{
public function handle()
{
$this->config->load('kaserapay');
// raw_input_stream adalah byte persis seperti dikirim. input->post()
// kosong untuk body JSON, dan input_stream() mengurainya sebagai
// formulir: keduanya tidak bisa dipakai memeriksa tanda tangan.
$raw = $this->input->raw_input_stream;
$header = (string) $this->input->get_request_header('Kasera-Signature-V1');
if ( ! $this->verify($raw, $header,
(string) $this->config->item('kaserapay_webhook_secret'),
(int) $this->config->item('kaserapay_tolerance')))
{
return $this->reply(400, array('error' => 'bad signature'));
}
$event = json_decode($raw, TRUE);
$this->load->database();
// Di produksi db_debug bernilai FALSE: query yang gagal tidak melempar
// apa pun, hanya mengembalikan FALSE. Setiap hasil query diperiksa
// sendiri, kalau tidak handler ini menjawab 200 untuk event yang tidak
// pernah tercatat.
$this->db->trans_begin();
try
{
// Unique key yang memutuskan, bukan SELECT lebih dulu: dua pengiriman
// yang datang bersamaan sama-sama membaca "belum ada".
$ok = $this->db->query(
'INSERT IGNORE INTO kasera_pay_events (event_id, created_at) VALUES (?, ?)',
array($event['id'], date('Y-m-d H:i:s'))
);
if ($ok === FALSE)
{
throw new RuntimeException('insert event gagal');
}
if ($this->db->affected_rows() === 0)
{
// Sudah pernah diproses. Tetap dijawab 200: yang tidak boleh terjadi
// dua kali adalah pemenuhan pesanannya, bukan jawabannya.
$this->db->trans_commit();
return $this->reply(200, array('ok' => TRUE));
}
if (isset($event['type']) && $event['type'] === 'payment.paid')
{
$this->fulfil($event['data']);
}
$this->db->trans_commit();
}
catch (Exception $e)
{
// Catatan event ikut batal, jadi pengiriman ulang memenuhi pesanannya,
// bukan dijawab 200 sebagai duplikat.
$this->db->trans_rollback();
log_message('error', 'kasera pay webhook: '.$e->getMessage());
return $this->reply(500, array('error' => 'retry'));
}
return $this->reply(200, array('ok' => TRUE));
}
private function verify($raw, $header, $secret, $tolerance)
{
if ($header === '' || $secret === '')
{
return FALSE;
}
$timestamp = NULL;
$signatures = array();
foreach (array_map('trim', explode(',', $header)) as $part)
{
if (strpos($part, 't=') === 0)
{
$timestamp = substr($part, 2);
}
elseif (strpos($part, 'v1=') === 0)
{
$signatures[] = substr($part, 3);
}
}
if ($timestamp === NULL || ! ctype_digit($timestamp) || $signatures === array())
{
return FALSE;
}
// Toleransi inilah yang mencegah pengiriman lama yang tersadap diputar
// ulang. Tanpa pemeriksaan ini tanda tangan lama cocok selamanya.
if (abs(time() - (int) $timestamp) > $tolerance)
{
return FALSE;
}
$expected = hash_hmac('sha256', $timestamp.'.'.$raw, $secret);
foreach ($signatures as $candidate)
{
if (hash_equals($expected, $candidate))
{
return TRUE;
}
}
return FALSE;
}
private function fulfil(array $payment)
{
$order = $this->db->get_where('orders', array('number' => $payment['external_id']))->row_array();
if ($order === NULL || (int) $order['amount'] !== (int) $payment['amount'])
{
// Jangan dijawab 200: pesanan yang tidak cocok perlu dilihat orang,
// dan 500 membuatnya tercatat di log pengiriman.
throw new RuntimeException('pesanan tidak cocok: '.$payment['external_id']);
}
// paid_at datang sebagai ISO 8601 dengan offset (+07:00). Kolom DATETIME
// MySQL menolaknya kalau stricton aktif, dan memotongnya diam-diam kalau
// tidak. Ubah sendiri ke WIB sebelum disimpan.
$paid_at = new DateTime($payment['paid_at']);
$paid_at->setTimezone(new DateTimeZone('Asia/Jakarta'));
$ok = $this->db->where('id', $order['id'])
->where('status !=', 'paid')
->update('orders', array('status' => 'paid', 'paid_at' => $paid_at->format('Y-m-d H:i:s')));
if ($ok === FALSE)
{
throw new RuntimeException('update pesanan gagal');
}
}
private function reply($status, array $body)
{
$this->output
->set_status_header($status)
->set_content_type('application/json')
->set_output(json_encode($body));
}
}Header Kasera-Signature-V1 berbentuk t=<unix>,v1=<hex>, dengan v1 adalah HMAC-SHA256 atas t, titik, lalu body mentah. Setelah rotasi signing secret, header membawa dua entri v1 selama 24 jam, jadi semua entri diperiksa dengan hash_equals. Toleransi 300 detik terhadap t mencegah pengiriman lama diputar ulang.
Tiga baris di controller itu terlihat berlebihan sampai alasannya diuji.
- Setiap hasil query diperiksa. File
database.phpbawaan mengisidb_debugdenganENVIRONMENT !== 'production'. Di produksi, query yang gagal hanya mengembalikan FALSE. Saat tabel event sengaja dihilangkan dan pemeriksaannya dihapus, webhook menjawab 200 dan pesanan ditandai lunas, padahal tidak ada catatan event sama sekali. Dengan pemeriksaan, jawabannya 500 dan pengiriman diulang. paid_atdiubah ke WIB lebih dulu. Nilai seperti2026-10-27T12:04:58+07:00ditolak MariaDB dengan galat 1292 dalam mode strict. CodeIgniter 3 memakaistrictonFALSE secara bawaan, yang membuang mode strict dari sesi, sehingga nilai yang sama tersimpan terpotong dengan peringatan. Nilai UTC seperti2026-10-27T05:04:58Ztersimpan sebagai05:04:58, tujuh jam dari waktu WIB-nya. Konversi eksplisit membuat keduanya tersimpan sebagai12:04:58.INSERT IGNORE, bukan SELECT lalu INSERT. Tiga puluh pengiriman kembar yang dikirim bersamaan menghasilkan 30 jawaban 200 dan tepat satu baris event. DenganINSERTbiasa, hasilnya satu 200 dan 29 jawaban 500, yang masing-masing memicu pengiriman ulang.
Pesanan yang nominalnya tidak sama dengan amount pada event dijawab 500 dan catatan event-nya ikut dibatalkan, jadi kasus itu terlihat di log pengiriman dan diproses lagi setelah diperbaiki. Event payment.expired tercatat tanpa mengubah pesanan. Jawab 2xx secepatnya; pekerjaan berat diantrikan, bukan dikerjakan di dalam handler.
6. Menguji tanpa uang berpindah
Kunci kp_test_ membuat objek yang berperilaku sama tanpa uang berpindah, dengan signing secret webhook tersendiri. Sisi webhook tidak butuh jaringan sama sekali: tandatangani body-nya sendiri, lalu kirim ke aplikasi lokal. CodeIgniter 3 tidak membawa kerangka feature test, jadi dua berkas ini cukup.
<?php
// php sign.php <secret> <body-file> [umur-detik] -> mencetak header Kasera-Signature-V1
$t = time() - (int) ($argv[3] ?? 0);
echo 't='.$t.',v1='.hash_hmac('sha256', $t.'.'.file_get_contents($argv[2]), $argv[1]);# Satu pengiriman bertanda tangan ke aplikasi lokal. Argumen ketiga php sign.php
# memundurkan t, untuk menguji penolakan pengiriman lama.
BODY='{"id":"evt_1","type":"payment.paid","livemode":false,"data":{"external_id":"ORD-1","amount":150000,"paid_at":"2026-10-27T12:04:58+07:00","status":"succeeded"}}'
printf '%s' "$BODY" > event.json
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost/webhook/kasera-pay \
-H 'Content-Type: application/json' \
-H "Kasera-Signature-V1: $(php sign.php whsec_test event.json)" \
--data-binary @event.jsonHasil yang diharapkan: 200 untuk pengiriman sah, 200 lagi untuk pengiriman kedua dengan satu baris event, 400 untuk secret yang salah, dan 400 untuk t yang dimundurkan 600 detik. Setiap pemeriksaan di halaman ini sudah dilihat gagal saat pengujian: menghapus pemeriksaan toleransi membuat pengiriman lama dijawab 200, mengganti raw_input_stream membuat pengiriman sah dijawab 400, dan menghapus pengecualian CSRF mengembalikan 403. Ulangi percobaan merusak itu sekali di aplikasi sendiri sebelum mempercayai hasil hijaunya.
7. Dua jebakan versi PHP
CodeIgniter 3.1.13 di PHP 8.2 ke atas memunculkan puluhan pesan Creation of dynamic property ... is deprecated dari kode CodeIgniter sendiri. Mode production menyembunyikan E_DEPRECATED. Mode development mencetaknya ke halaman, sehingga redirect() ke checkout_url gagal dengan headers already sent dan pembeli melihat halaman galat. Pada pengujian, mengganti error_reporting(-1) di cabang development pada index.php menjadi error_reporting(E_ALL & ~E_DEPRECATED) mengembalikan redirect-nya.
Jalur yang lebih singkat untuk server PHP 8.1 ke atas adalah SDK PHP resmi (composer require kasera/kasera-pay). Satu pengaturan menentukan apakah paketnya termuat:
<?php
// application/config/config.php
// TRUE berarti application/vendor/autoload.php, bukan vendor/ di akar proyek
// tempat composer require biasanya dijalankan. Kalau berkasnya tidak ada,
// CodeIgniter hanya menulis log, dan log_threshold bawaan 0 tidak menulis apa pun.
$config['composer_autoload'] = FCPATH.'vendor/autoload.php';Selanjutnya
Sebelum menukar kp_test_ dengan kp_live_, jalankan checklist sebelum go-live. Penjelasan dasar kontrak API tanpa kerangka apa pun ada di integrasi payment gateway di PHP tanpa framework.
Pertanyaan yang sering muncul
Apakah SDK PHP resmi Kasera Pay bisa dipakai di CodeIgniter 3?
Bisa, selama servernya menjalankan PHP 8.1 ke atas dengan ekstensi curl, karena itu syarat SDK kasera/kasera-pay. CodeIgniter 3 sendiri tidak menghalanginya. Yang perlu diperhatikan hanya autoload: composer require biasanya dijalankan di akar proyek, sedangkan composer_autoload TRUE mencari application/vendor/autoload.php. Isi pengaturan itu dengan FCPATH.'vendor/autoload.php'. Server yang masih di PHP 7 memakai library cURL di halaman ini, yang tidak membutuhkan Composer sama sekali.
Kenapa pengiriman webhook dijawab 403 The action you have requested is not allowed?
Karena csrf_protection aktif dan route webhook belum ada di csrf_exclude_uris. Pengirimnya server Kasera Pay, jadi tidak ada cookie dan token CSRF yang bisa disertakan, dan CodeIgniter 3 menolaknya sebelum controller dipanggil. Isi pengecualiannya dengan URI seperti yang diminta pengirim, yaitu webhook/kasera-pay. Nama controller dan method seperti kasera_pay_webhook/handle tetap dijawab 403.
Kenapa tidak memakai $this->input->post() untuk membaca event?
Karena body webhook adalah JSON, dan input->post() hanya berisi data formulir, jadi isinya kosong. input_stream() juga salah: ia mengurai body sebagai formulir. Tanda tangan dihitung atas byte persis seperti dikirim, jadi yang dipakai adalah $this->input->raw_input_stream, lalu json_decode setelah tanda tangannya cocok.
Bagaimana kalau aplikasinya memakai PostgreSQL, bukan MySQL?
Hanya kueri dedupe. INSERT IGNORE milik MySQL dan MariaDB; di PostgreSQL bentuknya INSERT INTO kasera_pay_events (event_id, created_at) VALUES (?, ?) ON CONFLICT (event_id) DO NOTHING, dan affected_rows() tetap menjawab 0 untuk event yang sudah ada. Pengujian di halaman ini dijalankan pada MariaDB.