Panduan · Terbit
Integrasi payment gateway di Symfony: PUBLIC_ACCESS di atas aturan catch-all, ON CONFLICT alih-alih EntityManager yang tertutup, dan Idempotency-Key yang lahir bersama pesanan
Panduan ini memasang Kasera Pay di aplikasi Symfony 7.4 memakai SDK PHP resmi kasera/kasera-pay, Doctrine di atas Postgres, dan security-bundle dengan halaman login. Seluruh kode di bawah dijalankan untuk panduan ini, termasuk empat puluh pengiriman event yang sama secara serentak yang berakhir dengan satu baris event dan satu pesanan dibayar. Symfony 8.1 membutuhkan PHP 8.4; di PHP 8.3, composer create-project memasang 7.4, versi LTS yang dipakai di sini.
Kontraknya sama dengan integrasi di Laravel. Yang berbeda ada tiga. Symfony tidak memeriksa CSRF pada controller biasa, jadi tidak ada yang perlu dikecualikan. Yang menolak webhook adalah firewall, dan urutan aturannya menentukan. Lalu Doctrine menutup EntityManager setelah satu pelanggaran unik, sehingga pola dedupe yang biasa dipakai di framework lain berubah menjadi deretan 500.
1. Kredensial dan Client
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. Secret mode tes tidak pernah bisa memverifikasi payload live.
# composer require kasera/kasera-pay symfony/orm-pack symfony/security-bundle symfony/uid
# .env.local: kp_test_ selama membangun, kp_live_ setelah go-live.
KASERA_API_KEY=kp_test_...
KASERA_BASE_URL=https://pay.kasera.id
KASERA_WEBHOOK_SECRET=whsec_...
DATABASE_URL="postgresql://toko:...@127.0.0.1:5432/toko?serverVersion=16&charset=utf8"# config/services.yaml, di bawah services:
Kasera\Pay\Client:
arguments:
$apiKey: '%env(KASERA_API_KEY)%'
$baseUrl: '%env(KASERA_BASE_URL)%'Dengan satu definisi itu, Client bisa di-autowire ke controller mana pun. Konstruktornya menerima API key, base URL, dan transport opsional; tidak ada opsi batas waktu.
2. Kunci idempotensi lahir bersama pesanan
<?php
// src/Entity/Pesanan.php
namespace App\Entity;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity]
class Pesanan
{
public const MENUNGGU = 'menunggu';
public const DIBAYAR = 'dibayar';
public const PERLU_DICEK = 'perlu_dicek';
public const BATAL = 'batal';
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
public ?int $id = null;
// Dibuat bersama pesanan, jadi sudah ada sebelum API dipanggil dan tidak
// pernah dibuat dua kali oleh dua request yang berbarengan.
#[ORM\Column(type: UuidType::NAME, unique: true)]
public readonly Uuid $idempotencyKey;
#[ORM\Column(length: 64, unique: true, nullable: true)]
public ?string $kaseraId = null;
#[ORM\Column(length: 20)]
public string $status = self::MENUNGGU;
public function __construct(
#[ORM\Column(length: 64, unique: true)]
public string $nomor,
#[ORM\Column(type: Types::BIGINT)]
public int $total,
#[ORM\Column(length: 120)]
public string $namaPembeli,
) {
$this->idempotencyKey = Uuid::v4();
}
}
// src/Entity/KaseraEvent.php: tabel kasera_event, primary key id (string 64),
// kolom type dan diterima_pada.Hanya header Idempotency-Key yang mencegah satu pesanan menjadi dua pembayaran; external_id hanya label yang disimpan dan dikembalikan. Pola yang paling sering ditulis, “buat kunci di controller kalau kolomnya masih kosong, lalu flush”, adalah cek-lalu-tulis. Pembeli yang menekan tombol bayar dua kali, atau browser yang mengirim ulang, membuat beberapa request membaca kolom kosong yang sama:
# 10 POST serentak ke /pesanan/{nomor}/bayar untuk satu pesanan baru.
kunci dibuat di controller saat masih kosong -> 9 kunci berbeda, 9 permintaan pembayaran
(putaran terburuk dari tiga)
kunci dibuat di konstruktor Pesanan -> 1 kunci (enam putaran)Kunci yang dibuat di konstruktor entitas ikut tersimpan saat pesanan pertama kali di-persist, jadi tidak ada request yang pernah membuatnya. Latar belakangnya ada di idempotency untuk pembayaran.
3. Membuat permintaan pembayaran
<?php
// src/Controller/BayarController.php
namespace App\Controller;
use App\Entity\Pesanan;
use Doctrine\ORM\EntityManagerInterface;
use Kasera\Pay\ApiException;
use Kasera\Pay\Client;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
final class BayarController extends AbstractController
{
public function __construct(
private readonly Client $kasera,
private readonly EntityManagerInterface $em,
) {
}
#[Route('/pesanan/{nomor}/bayar', name: 'pesanan_bayar', methods: ['POST'])]
public function __invoke(#[MapEntity(mapping: ['nomor' => 'nomor'])] Pesanan $pesanan): Response
{
if ($pesanan->status !== Pesanan::MENUNGGU) {
return new Response('Pesanan ini tidak menunggu pembayaran.', 409);
}
try {
$tx = $this->kasera->createTransaction([
'amount' => $pesanan->total, // rupiah utuh, integer
'description' => 'Pesanan ' . $pesanan->nomor,
'external_id' => $pesanan->nomor, // label, bukan kunci
'customer' => ['name' => $pesanan->namaPembeli], // Virtual Account butuh nama
'payment_methods' => ['qris', 'va_bca'],
'return_url' => $this->generateUrl('pesanan_selesai', ['nomor' => $pesanan->nomor], UrlGeneratorInterface::ABSOLUTE_URL),
], $pesanan->idempotencyKey->toRfc4122());
} catch (ApiException $e) {
// status 0: tidak ada jawaban sama sekali (gagal konek atau timeout curl).
// Aman diulang: kunci yang sama mengembalikan payment request yang sama.
return $e->status === 0
? new Response('Gateway pembayaran tidak menjawab, silakan coba lagi.', 504)
: new Response(sprintf('Gateway pembayaran menolak: %s (%s)', $e->errorCode, $e->requestId), 502);
}
$pesanan->kaseraId = $tx['id'];
$this->em->flush();
return new RedirectResponse($tx['checkout_url'], 303);
}
#[Route('/pesanan/{nomor}/selesai', name: 'pesanan_selesai', methods: ['GET'])]
public function selesai(#[MapEntity(mapping: ['nomor' => 'nomor'])] Pesanan $pesanan): Response
{
// Halaman ini hanya informasi. Status yang berlaku datang dari webhook.
return new Response(sprintf('Pesanan %s: %s', $pesanan->nomor, $pesanan->status));
}
}SDK melempar satu kelas untuk dua kegagalan yang berbeda arti. ApiException dengan status 0 dan errorCode network_error berarti tidak ada jawaban sama sekali, dan hasilnya tidak diketahui: bisa saja permintaan pembayarannya sudah dibuat. Status lain berarti API menjawab dan menolak, misalnya 422 validation_failed. Pada pengujian panduan ini, API yang menerima koneksi lalu diam membuat createTransaction menunggu tepat 30,00 detik sebelum melempar, dan controller menjawab 504. Klik berikutnya mengirim kunci yang sama dan berakhir dengan 303 ke halaman pembayaran.
Dua detail payload yang sering terlewat. customer.name wajib kalau Virtual Account ditawarkan, dan tanpa itu create ditolak 422. amount dikirim sebagai integer rupiah, bukan desimal.
4. Controller webhook: getContent(), bukan json_encode
<?php
// src/Controller/KaseraWebhookController.php
namespace App\Controller;
use App\Entity\Pesanan;
use Doctrine\DBAL\Connection;
use Kasera\Pay\SignatureException;
use Kasera\Pay\Webhook;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class KaseraWebhookController
{
public function __construct(
private readonly Connection $db,
#[Autowire(env: 'KASERA_WEBHOOK_SECRET')]
private readonly string $secret,
) {
}
#[Route('/webhooks/kasera-pay', name: 'kasera_webhook', methods: ['POST'])]
public function __invoke(Request $request): Response
{
try {
$event = Webhook::constructEvent(
$request->getContent(), // body mentah, byte demi byte
$request->headers->get('Kasera-Signature-V1', ''),
$this->secret,
);
} catch (SignatureException) {
return new Response('', 400);
}
if (!str_starts_with($event['type'], 'payment.')) {
return new Response('', 200); // test.ping dan tipe lain: cukup diakui
}
$this->db->transactional(function (Connection $db) use ($event): void {
// Arbiter dedupe adalah INSERT ini sendiri: pengiriman ulang yang
// berbarengan menunggu di sini, lalu mendapat 0 baris.
$baru = $db->executeStatement(
'INSERT INTO kasera_event (id, type, diterima_pada) VALUES (?, ?, now()) ON CONFLICT (id) DO NOTHING',
[$event['id'], $event['type']],
);
if ($baru === 0) {
return; // sudah pernah diproses
}
$data = $event['data'];
$pesanan = $db->fetchAssociative(
'SELECT id, total, status FROM pesanan WHERE kasera_id = ? FOR UPDATE',
[$data['payment_request_id']],
);
if ($pesanan === false) {
return; // tagihan dari dashboard: tidak punya pesanan di toko ini
}
$statusBaru = match ($event['type']) {
'payment.paid' => match (true) {
$pesanan['status'] === Pesanan::DIBAYAR => null,
$data['amount'] === (int) $pesanan['total'] && $data['currency'] === 'IDR' => Pesanan::DIBAYAR,
default => Pesanan::PERLU_DICEK,
},
'payment.expired', 'payment.failed' => $pesanan['status'] === Pesanan::MENUNGGU ? Pesanan::BATAL : null,
default => null,
};
if ($statusBaru !== null) {
$db->update('pesanan', ['status' => $statusBaru], ['id' => $pesanan['id']]);
}
});
return new Response('', 200);
}
}Webhook::constructEvent memeriksa header Kasera-Signature-V1 berbentuk t=<unix>,v1=<hex>: HMAC-SHA256 atas t, satu titik, lalu body mentah. Timestamp yang melenceng lebih dari 300 detik, ke belakang maupun ke depan, ditolak, dan selama 24 jam setelah rotasi secret cukup satu dari dua entri v1 yang cocok. Rinciannya ada di referensi webhook.
Byte yang ditandatangani didapat dari $request->getContent(). Payload yang di-decode lalu disusun ulang dengan json_encode tidak pernah sama: PHP menulis garis miring sebagai \/ dan huruf non-ASCII sebagai \u, sehingga payload 413 byte dengan nama pembeli beraksen menjadi 422 byte. Menambahkan JSON_UNESCAPED_SLASHES dan JSON_UNESCAPED_UNICODE hanya menyamakan kasus yang sederhana: pengirim Kasera Pay menulis & sebagai &, jadi nama “Siti & Ani” tetap gagal. Memanggil toArray() lebih dulu tidak merusak apa pun, karena Symfony menyimpan body-nya.
Keputusan di dalam transaksi itu disengaja. Pesanan dicari lewat payment_request_id yang disimpan saat create, bukan lewat external_id. payment.paid tetap dicatat walaupun payment.expired datang lebih dulu, karena uang yang diterima selalu menang; pengujian panduan ini mengirim expired lalu paid dan pesanan berakhir dibayar, dan dua puluh kali kiriman keduanya secara serentak berakhir sama. Nominal yang tidak cocok ditandai untuk diperiksa, bukan dijawab 500 yang akan diulang. Tagihan yang dibuat dari dashboard tidak punya pesanan di toko ini, jadi id event-nya dicatat dan dijawab 200.
5. Dedupe: ON CONFLICT, karena EntityManager menutup diri
Pengiriman webhook bersifat at-least-once: event yang sama bisa datang lebih dari sekali dengan id yang sama. Cara yang terasa paling Symfony, find() lalu persist() dan flush(), gagal dengan cara yang khas Doctrine. Kiriman yang tiba bersamaan sama-sama tidak menemukan apa-apa, lalu yang kalah balapan mendapat UniqueConstraintViolationException. Menangkapnya pun tidak menolong: setelah itu persist() dan flush() melempar “The EntityManager is closed.” untuk sisa request.
# 40 kiriman event yang sama, dilepas serentak, 40 worker PHP, Postgres 16,
# dengan jeda 50 ms antara pemeriksaan dan penulisan (putaran terburuk dari tiga).
INSERT ... ON CONFLICT DO NOTHING (kode di atas)
-> 40 x 200, 1 baris event, 1 kali diproses (setiap putaran)
find() lalu persist + flush (ORM)
-> 19 x 200, 21 x 500 (UniqueConstraintViolationException)
cek status pesanan saja, tanpa tabel event
-> 40 x 200, 12 kali diprosesSetiap 500 adalah pengiriman gagal yang akan diulang Kasera Pay, dan alarm yang berbunyi untuk event yang sebenarnya sudah tercatat. INSERT ... ON CONFLICT DO NOTHING lewat DBAL menjadikan insert itu sendiri penentunya: kiriman kedua menunggu sampai yang pertama selesai, lalu mendapat 0 baris dan berhenti. Pengujian ini juga dijalankan dengan ON CONFLICT dihapus, dan hasilnya 1 kali 200 dan 39 kali 500.
6. Firewall: aturan pertama yang cocok
# config/packages/security.yaml
security:
firewalls:
main:
lazy: true
provider: app_user_provider
form_login:
login_path: app_login
check_path: app_login
# Hanya aturan PERTAMA yang cocok yang dipakai: aturan publik harus di atas catch-all.
access_control:
- { path: ^/login, roles: PUBLIC_ACCESS }
- { path: ^/webhooks/kasera-pay$, roles: PUBLIC_ACCESS }
- { path: ^/, roles: ROLE_USER }access_control dibaca dari atas dan berhenti di aturan pertama yang cocok. Aturan PUBLIC_ACCESS untuk webhook yang ditulis di bawah catch-all tidak pernah tersentuh. Alternatifnya adalah firewall tersendiri untuk path webhook dengan security: false; keduanya menghasilkan 200 pada pengujian panduan ini.
7. Penolakan yang terjadi sebelum controller jalan
Setiap baris di bawah dijawab tanpa controller webhook dipanggil:
# Symfony 7.4.20, security-bundle 7.4.20, PHP 8.3.6, kasera/kasera-pay 0.1.0.
form_login + { path: ^/, roles: ROLE_USER }, tanpa aturan webhook
-> 302 Location: /login
aturan PUBLIC_ACCESS webhook ditulis SETELAH catch-all
-> 302 Location: /login
aturan PUBLIC_ACCESS webhook ditulis SEBELUM catch-all
-> 200
firewall tanpa entry point (tanpa form_login)
-> 401
route "/webhooks/kasera-pay", POST ke ".../kasera-pay/"
-> 404 (GET dialihkan 301; POST tidak pernah)
json_encode($request->toArray()) -> 413 byte menjadi 422, tanda tangan gagal: 400
# Yang TIDAK menolak apa pun:
POST lintas situs tanpa token CSRF ke controller biasa -> 200
$request->toArray(), lalu getContent() -> 413 dari 413 byteGaris miring. Symfony hanya mengalihkan GET dan HEAD ke versi path yang lain. POST ke path dengan garis miring yang berbeda langsung dijawab 404, sehingga log aplikasi terlihat bersih sementara Kasera Pay mencatat pengiriman gagal. Tulis path route dan URL di dashboard persis sama.
Sebelum go-live
URL webhook wajib https dan mengarah ke alamat publik, dan alamat itu dijawab langsung, tanpa redirect dari http ke https atau dari domain tanpa www. Uji empat hal di mode tes: kiriman sah dijawab 200, event yang sama dua kali menghasilkan satu perubahan, tanda tangan yang dirusak dijawab 400, dan kegagalan basis data menghasilkan 500 supaya kiriman diulang. Cara menerima webhook saat aplikasi masih berjalan di laptop ada di webhook di localhost dan mode tes, dan urutan pemeriksaan lengkapnya di checklist sebelum go-live.
Pertanyaan yang sering muncul
Kenapa webhook dijawab 302 ke /login padahal route-nya ada?
Karena firewall Symfony memeriksa access_control sebelum controller dipanggil, dan aturan catch-all seperti path ^/ dengan ROLE_USER ikut menangkap POST dari Kasera Pay. Firewall yang memakai form_login menjawabnya dengan 302 ke halaman login. Kasera Pay tidak mengikuti redirect apa pun, jadi kiriman itu dihitung gagal. Tambahkan aturan PUBLIC_ACCESS untuk path webhook DI ATAS catch-all, karena Symfony hanya memakai aturan pertama yang cocok. Pada pengujian panduan ini, aturan yang sama di bawah catch-all tetap menghasilkan 302.
Apakah route webhook perlu dikecualikan dari CSRF seperti di Laravel?
Tidak. Di Symfony 7.4 perlindungan CSRF hanya berlaku untuk formulir, form_login, logout, dan controller yang memintanya secara eksplisit. Controller biasa tidak pernah diperiksa, termasuk setelah check_header dinyalakan. Pada pengujian panduan ini, POST lintas situs tanpa token ke controller webhook dijawab 200. Yang mengotentikasi kiriman adalah tanda tangan Kasera-Signature-V1.
Bisakah batas waktu SDK PHP diturunkan dari 30 detik?
Tidak lewat opsi. SDK kasera/kasera-pay 0.1.0 memasang batas sambung 10 detik dan batas total 30 detik di dalam kodenya. Satu-satunya jalan adalah transport sendiri, argumen ketiga konstruktor Client, berupa callable yang menerima method, URL, header, dan body lalu mengembalikan status dan body mentah. Untuk kebanyakan toko 30 detik sudah cukup; yang penting kegagalannya dijawab 504 dan percobaan ulang membawa kunci yang sama.
Bolehkah webhook diproses lewat Symfony Messenger supaya jawabannya cepat?
Untuk pekerjaan yang boleh tertunda, boleh: email konfirmasi, panggilan ke sistem gudang, pembuatan faktur. Untuk perubahan status pesanan dan catatan id event, jangan. Kalau controller sudah menjawab 200 lalu pesan di antrean gagal, Kasera Pay tidak akan mengirim ulang. Server penjual punya sepuluh detik untuk menjawab, dan satu transaksi basis data seperti di atas selesai jauh di bawah itu.