Create your first payment gateway
Build a minimal but working payment-gateway extension from scratch — two files, a hosted checkout redirect and a signed webhook — then grow it capability by capability.
On this page
A payment-gateway extension is smaller than most people expect. There is no application to scaffold, no checkout page to build, no routes to register. Two files — a driver class and a manifest — and Core does the rest. This walkthrough builds a minimal but real gateway from scratch so you can see the whole shape before you fill in the detail.
We will stub a fictional "Acme Pay" that offers a hosted checkout page and a signed webhook — the same shape as the bundled Stripe gateway. The endpoints here (/v1/checkout/sessions, /v1/refunds) are illustrative — swap them for your processor's real ones — but everything Core touches (the base class, the return shapes, the helpers, the capabilities, the manifest) is exact.
What you'll build#
A gateway that can do two things: send a buyer to a hosted checkout page and settle the payment when the money actually moves. That is enough to be genuinely useful and to see every moving part. Once it works, adding refunds, partial refunds, an admin connectivity probe or a client-side card SDK is just more methods and capabilities of the same kind — the driver contract covers them all.
Your driver never settles a payment#
Before you write a line, know the one rule that makes payment gateways different from every other extension: a driver never credits a wallet or marks an invoice paid. It verifies that money moved, writes the proof onto the deposit, and calls $this->settle($deposit). That helper routes to Core's single settlement boundary — idempotent and row-locked — which credits exactly once no matter how many times a webhook and a browser-return both fire for the same payment. A driver that credited balance itself would double-credit the moment the processor retried a webhook. So the whole of settlement is three steps: verify, store proof, `settle()`. Nothing else.
Lay out the package#
A gateway is a folder with two files at its root:
acme-pay/
salieno.json the manifest
Gateway.php the driver classThat is the entire package. No composer.json, no resources/, no views, no assets — a hosted-redirect gateway renders nothing of its own. If you split logic into helper classes, put them under your namespace alongside the driver, but you never need to. The class is always named Gateway and the entry file is always Gateway.php.
Extend AbstractGateway and declare capabilities#
Start from App\PaymentGateways\AbstractGateway. It already implements every contract method with a safe "not supported" default and pulls in the driver-helper trait, so you override only what you actually build. Extending the base declares nothing — every control stays hidden until you opt in through capabilities().
<?php
namespace Salieno\Payment\Acme;
use App\Constants\Status;
use App\Models\Deposit;
use App\Models\GatewayCurrency;
use App\PaymentGateways\AbstractGateway;
use App\PaymentGateways\PaymentCapability;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class Gateway extends AbstractGateway
{
public function capabilities(): array
{
return [
PaymentCapability::CHARGE,
PaymentCapability::REFUND,
PaymentCapability::WEBHOOK,
PaymentCapability::REDIRECT,
];
}
}That is a valid, installable gateway. It just does nothing yet, because process() still returns the base's default. capabilities() is the authoritative source at runtime — Core reads it to decide which controls to render. CHARGE is mandatory; every gateway takes a payment. REFUND lights up the admin "refund to gateway" button — declare it and an admin refund reverses the charge at the processor; omit it and the same refund just credits the buyer's wallet instead. WEBHOOK and REDIRECT describe how the flow behaves (the buyer is sent off-site and the payment confirms over a callback), which is exactly what this driver does. Keep this list identical to the runtime capabilities() above — the manifest copy gates the UI cheaply, without loading your driver, and the two drifting apart is a bug. The full map lives in Capabilities.
Implement process()#
process($deposit) starts a payment. Core hands you the Deposit — the amount, the currency, the return URLs, an idempotency key — and you create the checkout on the processor and tell Core where to send the buyer. Return one of the checkout-dispatcher shapes; the simplest, and the one a hosted page uses, is a redirect, produced by $this->redirectTo($url):
/** Create a hosted checkout session and send the buyer to Acme Pay's page. */
public function process(Deposit $deposit): array
{
$secret = $this->getParam($deposit, 'secret_key');
if (! $secret) {
return $this->failCheckout(__('Acme Pay is not configured (missing secret key).'));
}
try {
$response = $this->http(['Authorization' => 'Bearer ' . $secret])
->asJson()
->post($this->baseUrl($deposit) . '/v1/checkout/sessions', [
// Integer minor unit — zero-decimal currencies (JPY/KRW/VND/…) must NOT be ×100.
'amount' => $this->toMinorUnit((float) $deposit->final_amount, (string) $deposit->method_currency),
'currency' => strtolower((string) $deposit->method_currency),
'reference' => $deposit->trx, // your idempotency key, echoed back on the webhook
'success_url' => $deposit->success_url,
'cancel_url' => $deposit->failed_url,
]);
} catch (\Throwable $e) {
return $this->failCheckout($e->getMessage());
}
$body = $response->json() ?? [];
if (! $response->successful() || empty($body['id']) || empty($body['url'])) {
return $this->failCheckout($body['error'] ?? __('Acme Pay did not return a checkout URL.'));
}
// Stash the processor's own reference so the webhook can resolve this exact attempt.
$deposit->btc_wallet = $body['id'];
$deposit->save();
return $this->redirectTo($body['url']);
}
/** The processor's API base, switched by the connection's sandbox toggle. */
protected function baseUrl(Deposit|GatewayCurrency $ctx): string
{
return $this->isSandbox($ctx)
? 'https://api.sandbox.acmepay.example'
: 'https://api.acmepay.example';
}Three habits to notice, because they apply to every method you write:
- Read credentials per call. A driver is stateless — Core instantiates it once with no constructor and passes the
Depositto every method — so pull each field withgetParam($deposit, 'secret_key'), which decrypts it from the per-currency config. Never hardcode a key or cache one on the instance; the next call may be a different currency on a different config row. - Charge in minor units with
toMinorUnit($amount, $currency). It is zero-decimal-aware: JPY, KRW and VND are not multiplied by 100, and getting that wrong overcharges a buyer 100×. Read the sandbox flag withisSandbox($ctx), and issue every API call throughhttp($headers)for an identifiable User-Agent, sane timeouts and TLS verification. - Stash the processor's reference on `$deposit->btc_wallet`. It is a free string for exactly this — the webhook arrives with no
Depositin hand, andbtc_walletis how it finds its way back to this attempt.$deposit->trxis your idempotency key; pass it to the processor so retryingprocess()for the same attempt reuses the session rather than opening a second charge.
Implement ipn()#
ipn($request) is the callback at /client/ipn/acme-pay — no CSRF, no auth, reachable by anyone. It is where the money is confirmed, so it does the real work: verify authenticity, store the proof, and settle through Core. For a hosted-redirect gateway the settling event is the processor's webhook; the buyer's browser just lands on success_url. Return $this->ipnAck() or $this->ipnReject():
/** Verify and settle an Acme Pay webhook (checkout completion). */
public function ipn(Request $request): Response
{
$payload = $request->getContent();
$signature = $request->header('Acme-Signature');
if (! $signature) {
return $this->ipnReject('No signature header');
}
// The signing secret is a GLOBAL credential, and a webhook carries no Deposit — read it from this
// gateway's own config via ownConfig(), without hardcoding the slug.
$secret = $this->getParam($this->ownConfig(), 'webhook_secret');
// Fail closed: an empty signing secret would validate an attacker-forged event (empty-key HMAC),
// settling a deposit that was never paid.
if (! $secret) {
return $this->ipnReject('Webhook signing secret is not configured');
}
if (! hash_equals(hash_hmac('sha256', $payload, $secret), $signature)) {
return $this->ipnReject('Invalid signature');
}
$event = json_decode($payload, true) ?? [];
if (($event['type'] ?? null) !== 'checkout.completed' || ($event['data']['status'] ?? null) !== 'paid') {
return $this->ipnAck('Ignored'); // acknowledge events you don't settle on, so retries stop
}
$deposit = $this->depositByReference($event['data']['id'] ?? ''); // matches the id you stashed on btc_wallet
if (! $deposit) {
return $this->ipnReject('Deposit not found', 404);
}
// Settle only a deposit that is still awaiting payment; the guard + Core's boundary make this idempotent.
if (in_array((int) $deposit->status, [Status::PAYMENT_INITIATE, Status::PAYMENT_PENDING], true)) {
$deposit->detail = $event['data']; // proof of payment — keep the processor's charge id here for refunds
$deposit->save();
$this->settle($deposit); // Core's single idempotent, row-locked settlement boundary
}
return $this->ipnAck();
}The shape is the settlement rule made concrete. depositByReference() resolves the deposit from the reference you stashed on btc_wallet in process(). ownConfig() is how a callback with no Deposit reaches its own global credentials — the webhook signing secret — without ever naming its own slug. And notice the driver stops at settle(): it writes the proof onto $deposit->detail and hands off. It never touches a balance. Gateways that capture on the buyer's return instead of over a webhook (like the bundled PayPal driver) do the same verification here but return $this->returnSuccess($deposit) / $this->returnFailed($deposit) to redirect the browser — see the driver contract.
Implement refund()#
Because you declared PaymentCapability::REFUND, Core will call refund($deposit, $amount) from the admin refund flow. Reverse the charge you stored on $deposit->detail, and be honest — report success only when the processor accepted it:
public function refund(Deposit $deposit, float $amount): array
{
$secret = $this->getParam($deposit, 'secret_key');
if (! $secret) {
return $this->error(__('Acme Pay secret key is not configured for this currency.'));
}
$chargeId = data_get($deposit->detail, 'charge_id'); // stored in ipn()
if (! $chargeId) {
return $this->error(__('No Acme Pay charge reference was found on the original payment to refund.'));
}
try {
$response = $this->http(['Authorization' => 'Bearer ' . $secret])
->asJson()
->post($this->baseUrl($deposit) . '/v1/refunds', [
'charge' => $chargeId,
'amount' => $this->toMinorUnit($amount, (string) $deposit->method_currency),
]);
$body = $response->json() ?? [];
if ($response->successful() && in_array($body['status'] ?? '', ['succeeded', 'pending'], true)) {
return $this->success(__('Acme Pay refund :s.', ['s' => $body['status']]), ['refund_id' => $body['id'] ?? null]);
}
return $this->error($body['error'] ?? __('Acme Pay declined the refund.'));
} catch (\Throwable $e) {
return $this->error(__('Acme Pay refund failed: :m', ['m' => $e->getMessage()]));
}
}That is the whole driver: a class, four capabilities, three methods. The objects you reached for — $deposit->final_amount, $deposit->trx, $deposit->detail, the stored secret_key — are handed to you or read through a helper; you never wire them up yourself. Every method returns a fixed shape (process() a dispatcher array, ipn() a Response, refund() an ['success' => bool, …]), and Core reads it generically.
Write salieno.json#
The manifest tells the marketplace and Core how to load and present your gateway, and supplies the fields Core renders into the config form.
{
"schema": "salieno.payment/1",
"kind": "payment",
"slug": "acme-pay",
"name": "Acme Pay",
"version": "1.0.0",
"namespace": "Salieno\\Payment\\Acme",
"driver": "Gateway",
"entry": "Gateway.php",
"requires_core": ">=1.0.0 <2.0.0",
"author": "Acme, Inc.",
"description": "Accept card payments through Acme Pay's hosted checkout. Settles over a signed webhook and supports refunds.",
"homepage": "https://acmepay.example/docs",
"capabilities": ["charge", "refund", "webhook", "redirect"],
"credentials": [
{
"key": "secret_key",
"label": "Secret Key",
"type": "password",
"required": true,
"global": true,
"help": "Your Acme Pay secret key (sk_live_… in production, sk_test_… for testing)."
},
{
"key": "webhook_secret",
"label": "Webhook Signing Secret",
"type": "password",
"required": true,
"global": true,
"help": "The signing secret for a webhook pointed at /client/ipn/acme-pay, subscribed to checkout.completed. Payments will NOT settle without it."
}
]
}A few fields carry weight. slug is the immutable join key: your marketplace product slug, the folder name, the /client/ipn/<slug> callback route, and the key every config row, currency and payment record is stored under — they must all match, and it never changes across updates. driver, namespace and entry must line up with the class above (entry is required and must end in .php). requires_core is the Core version window your gateway supports, not your own version. The capabilities array is what Core reads to gate the UI without loading your driver, so keep it identical to capabilities(). Each credential is {key, label, type, required, global, help} — type is text, password or select (a select adds an options map) — and Core renders the config form straight from it. Mark a field global: true when it belongs to the account rather than one currency; it is fanned across every currency row, which is exactly why the webhook secret must be global — ownConfig() reads it. Field-by-field detail is in The manifest, and the credentials list has its own guide in Credentials & config.
There are no views#
Worth stating plainly: you do not build a single screen. Core owns the checkout dispatcher, the admin gateway config form, the client payment page and the return pages, and drives each from what your driver returns and declares. A hosted-redirect gateway renders nothing at all — redirectTo() sends the buyer to the processor's own page, and the settlement rule keeps the credit inside Core. Your job ends at correct return shapes.
Ship it: submit, then install#
A gateway folder on a server does nothing on its own — distribution is marketplace-only and signed, and an extension loads only when it is registered from a signed install and the activated licence owns it. To get yours running:
- Submit the two-file package to marketplace.salieno.com and set its price at creation (free, or a one-time paid product). A reviewer approves it, and on approval the marketplace signs the artifact with its key.
- Install from the admin panel under Payments → Gateway Extensions. That library lists only gateways the licence owns; installing runs a signed resolve → download → verify → register path, checking every file against the pinned marketplace key before anything is written. On install Core auto-adds a config row under Auto Gateways, seeded from your manifest credentials, for the operator to fill in and hit Test connection.
From there every capability you declared lights up automatically — no Core edits, ever. On each resolve Core re-checks three gates: the gateway is registered and enabled, the on-disk code is genuine (re-verified against the pinned key, each file matching its recorded hash — code edited after install is refused), and the licence is still entitled. A copied or edited folder will not run. One payment-specific nuance: on an inbound webhook the entitlement gate is lenient — a confirmed payment must settle even if the licence has lapsed — but the genuineness check stays strict, and an operator who disables a gateway (the containment lever for leaked credentials) drops it to a no-op on both checkout and callback. The full path, plus how updates preserve every saved credential, is in Publishing & updates.
Before you submit, exercise the driver against your processor's sandbox — see Testing your gateway. Then flesh it out against the contract: override testConnection() for the admin probe, add partial_refund, a client-side SDK session for hosted_fields, or recurring for automatic renewals. Each one is the same small, self-contained shape you just wrote three times.