How Salieno payment gateway extensions work
A payment gateway extension is code only — one PHP driver plus a salieno.json manifest — that starts a payment, verifies the money over a webhook, and refunds it. The driver never credits balance itself.
On this page
A payment gateway extension teaches Salieno Core to take money through a gateway's API — Stripe, PayPal, or anything else with one. This is the front-door article for the series: read it to build the mental model, then follow the links at the end into the parts you need.
A gateway is the money-side analogue of a registrar or panel extension — same trust model, same distribution path — but a gateway moves money, receives webhooks, and issues refunds, so it differs in one load-bearing way. We start there, because it is the rule that keeps a gateway honest.
A driver never credits balance#
This is the defining rule of the whole series: a payment-gateway driver never credits a wallet or marks an invoice paid. It verifies that money arrived, records the proof, and hands off. Crediting is Core's job, and Core's alone.
The handoff is one method — $this->settle($deposit) — which routes into Core's single settlement boundary (PaymentController::userDataUpdate → PaymentService::finalizeSuccessfulPayment). That boundary is idempotent and row-locked: it credits a deposit exactly once no matter how many times it is called. That matters because a real payment arrives more than once. Stripe's webhook fires and the buyer's browser lands back on the success page; PayPal's return can be replayed. If a driver credited balance itself, both paths would fire and the customer would be paid twice. Because the driver only ever calls settle(), the second call is a no-op.
So the shape of ipn() is always the same three moves — verify, store proof, settle — and never a fourth:
if ($paid && in_array((int) $deposit->status, [Status::PAYMENT_INITIATE, Status::PAYMENT_PENDING], true)) {
$deposit->detail = $session; // proof of payment (carries payment_intent for refunds)
$deposit->save();
$this->settle($deposit); // Core's idempotent, row-locked settlement boundary
}That is the whole seam. Everything else in a driver — creating a checkout session, capturing an order, calling a refund endpoint — is talking to your gateway. The single line that touches Core's ledger is settle(), and it is the same line in every gateway ever written.
Code, not screens#
A payment gateway extension is code only. It is two files: one PHP driver class and a salieno.json manifest.
payments/your-gateway/
salieno.json # the manifest
Gateway.php # the driver class (namespace Salieno\Payment\YourGateway, class Gateway)There are no views, no templates, nothing to style. Core owns every screen — the checkout method list, the redirect/confirm step, the admin refund dialog, the Auto Gateways config form — and renders them generically from what your driver returns and declares. You never touch a Blade file, and you never edit Core.
The easiest way to write the driver is to extend App\PaymentGateways\AbstractGateway (which implements App\PaymentGateways\GatewayInterface and pulls in App\PaymentGateways\GatewayModuleTrait). It ships a safe "not supported" default for every method plus a trait of helpers, so you override only the methods you actually implement. The one thing you must always add is capabilities().
A driver is stateless: Core instantiates it once with no constructor and passes the Deposit to every call, so you read credentials and context per call, never from instance state. The Deposit is the whole context object: final_amount is the sum to charge (in method_currency), amount is the base amount credited to the wallet on success, trx is your idempotency key, btc_wallet is a free string to stash the gateway's own reference (a session or order id) so the webhook can find its way back, detail is where you store the verified proof, and success_url / failed_url are where the buyer returns.
The trait does the busywork: settle($deposit); getParam($ctx, 'secret_key') to read one encrypted credential; isSandbox($ctx); toMinorUnit() / fromMinorUnit() (zero-decimal aware — JPY, KRW and VND are not ×100); http(); redirectTo($url); failCheckout($msg); the webhook acks ipnAck() / ipnReject(); the browser-return responses returnSuccess($deposit) / returnFailed($deposit); the success(...) / error(...) envelopes; and depositByReference() / depositByTrx() to resolve a deposit from a gateway reference. The driver contract covers each method's arguments and exact return shape.
Where a gateway shows up#
Once a gateway is installed and a currency is configured, the same driver drives four surfaces, and Core draws all of them:
- Checkout —
process(Deposit $deposit): arraystarts the payment. It returns the checkout-dispatcher contract as an array, and the simplest form is a hosted redirect: create the session on your side, stash its id, and send the buyer off-site.
$deposit->btc_wallet = $session->id; // so the webhook can resolve this exact attempt
$deposit->save();
return $this->redirectTo($session->url); Four return shapes exist: ['error'=>true,'message'=>…] aborts to the deposit's failed_url; ['redirect'=>true,'redirect_url'=>…] sends the buyer to a hosted page (the simplest, and what Stripe and PayPal both use); ['session'=>$sdkSession, …] lets Core render a client-SDK confirm view; and ['view'=>…, 'url'=>…, 'method'=>'POST', 'val'=>[…]] renders an auto-submitting form.
- The webhook / return endpoint —
ipn(Request $request): Responseanswers/client/ipn/<slug>with no CSRF and no auth, because it is hit by your gateway's servers and by the returning buyer's browser. It must verify authenticity, store the proof on$deposit->detail, and settle only viasettle(). A server-to-server webhook replies withipnAck()/ipnReject(); a browser return replies withreturnSuccess($deposit)/returnFailed($deposit).
- Admin refunds —
refund(Deposit $deposit, float $amount): arrayreverses a captured payment, full or partial. It is called only when you declare therefundcapability, and it must be honest: returnsuccessonly when the gateway actually accepted the reversal.
if (in_array($refund->status ?? '', ['succeeded', 'pending'], true)) {
return $this->success(__('Stripe refund :status.', ['status' => $refund->status]), ['refund_id' => $refund->id ?? null]);
}
return $this->error(__('Stripe refund returned status: :s', ['s' => $refund->status ?? 'unknown']));- Auto Gateways config + Test connection — the settings screen where an operator enters credentials, and
testConnection(GatewayCurrency $config): arrayverifies them against the gateway (Stripe retrieves a balance; PayPal fetches an access token).
Core never enumerates gateway names. Every one of those controls is gated on capabilities(), not on "is this Stripe?" — so a gateway that cannot refund simply never shows a refund button. You supply behaviour; Core supplies pixels.
Capabilities: you declare what you do#
Not every gateway does everything. Some can refund, some cannot; some do recurring charges, some only take one-off payments. So Core never assumes — it asks. Your driver's capabilities() returns a subset of the known keys, and charge is mandatory — a gateway that cannot take a payment is not a gateway.
public function capabilities(): array
{
return [
PaymentCapability::CHARGE,
PaymentCapability::REFUND,
PaymentCapability::PARTIAL_REFUND,
PaymentCapability::WEBHOOK,
PaymentCapability::REDIRECT,
PaymentCapability::THREE_D_SECURE,
];
}Here is what each of the keys in App\PaymentGateways\PaymentCapability declares:
| Capability | Declares |
|---|---|
charge | the gateway can take a payment at all — required |
refund | the admin "refund to gateway" path is available (omit it and the admin credits the wallet instead) |
partial_refund | refunds of less than the full amount |
webhook | settlement arrives over a server-to-server webhook |
redirect | the buyer is sent to a hosted, off-site page |
hosted_fields | card fields are collected in-page |
recurring | the gateway can drive automatic renewals |
crypto | a cryptocurrency payment |
3ds | 3-D Secure authentication |
Two of these change real behaviour beyond a badge: refund decides whether the admin refund dialog talks to the gateway or falls back to crediting the wallet, and recurring decides whether the gateway is eligible for automatic renewals. Declaring a capability you can't truly back is the wrong move — it puts a control on screen that fails when clicked. Extending AbstractGateway declares nothing you don't ask for, so a half-built gateway is safe, not broken. PayPal ships four capabilities (charge, refund, partial_refund, redirect); Stripe ships six, adding webhook and 3ds.
A crucial detail: the capabilities array in salieno.json is what Core reads to gate the UI without loading your driver — a cheap, signature-checked lookup. The authoritative source at runtime is the driver's capabilities() method. Keep the two identical; Core trusts the code. The capabilities guide goes surface by surface.
What the manifest carries#
The manifest is small but load-bearing. It declares schema ("salieno.payment/1"), kind ("payment"), and the identity fields that tie everything together:
{
"schema": "salieno.payment/1",
"kind": "payment",
"slug": "stripe",
"name": "Stripe",
"version": "1.0.0",
"namespace": "Salieno\\Payment\\Stripe",
"driver": "Gateway",
"entry": "Gateway.php",
"requires_core": ">=1.0.0 <2.0.0"
}The slug is the identity everything hangs on: it is both the marketplace product slug and the join key every config row, currency and payment history record is stored under. It is lowercase and immutable across updates — never change it. namespace is the PSR-4 root and driver the class within it (Gateway); entry must end in .php; requires_core is one continuous semver range; version must be strictly greater on every release.
Beyond identity, the manifest declares two things that let a gateway onboard with zero core edits:
capabilities— the informational copy of your driver's list, so the marketplace and UI can gate without loading code (keep it identical to the driver).credentials— a list of{key, label, type, required, global, help}fields (withoptionsfor aselect). Core renders the Auto Gateways config form straight from it;typeistext,password, orselect, and aglobalcredential is fanned across every currency row (a webhook signing secret is global; a per-currency key is not).
{
"key": "webhook_secret",
"label": "Webhook Signing Secret",
"type": "password",
"required": true,
"global": true,
"help": "The signing secret (whsec_…) for a webhook endpoint pointed at /client/ipn/stripe. Payments will NOT settle without it."
}Credentials are read back per call from the encrypted gateway_currencies.gateway_parameter — never hardcode a key, and never write one into your folder. The manifest must satisfy two validators: the marketplace (kind, name, version, requires_core, entry) and the Core installer (schema, kind, slug, namespace, driver — and the namespaced driver file must exist) — so include all of them. The full field reference lives in the manifest article, and the config form in credentials & configuration.
Reading your own config when there is no Deposit#
Most methods receive the Deposit and read credentials from it. A webhook does not — it arrives cold, with no deposit in hand, and you still need your global secret (the webhook signing key) to verify it before you can even find which deposit it belongs to. That is what ownConfig() is for: it self-resolves your gateway's own configuration without you hardcoding your slug.
protected function webhookSecret(): ?string
{
$config = $this->ownConfig();
return $this->getParam($config, 'webhook_secret') ?: null;
}ownExtension(), ownGateway() and ownConfig() all self-resolve the currently executing gateway, so a driver stays portable — copyable slug-and-all — without ever naming itself.
Verify, or fail closed#
Because ipn() runs unauthenticated, verification is the entire security boundary. The rule is absolute: prove the payment is real before you settle, and if you can't prove it, reject it. A missing signature is a rejection; an empty signing secret is also a rejection, because an empty-key HMAC validates anything an attacker sends:
$secret = $this->webhookSecret();
if (! $secret) {
// An empty signing secret would validate an attacker-forged event and settle a deposit
// without payment. Fail closed.
return $this->ipnReject('Webhook signing secret is not configured');
}PayPal takes the same posture from the other direction: it never trusts the return URL. On the buyer's return it re-fetches the order from PayPal and confirms it is COMPLETED before capturing, guarding against a spoofed or replayed return_url. The principle is identical — a driver only calls settle() on money it has independently confirmed with the gateway.
Distribution: marketplace-only, signed#
Gateways are distributed only through marketplace.salieno.com, and only in signed form. There is no local upload path, and a folder copied onto a server will not run — not because of a config flag, but because of how Core resolves a driver. Installed gateway code lives as a runtime artifact that is gitignored and deliberately off the normal autoload path. Every time Core resolves a driver, the gateway registry runs three gates in order:
- Registered — there is an enabled
gateway_extensionsrow for the slug. A hand-copied folder has no row and never gets this far. - Genuine — the stored signed artifact re-verifies against Core's pinned Ed25519 marketplace key, and each on-disk file's SHA-256 matches the hash recorded at install. You cannot forge the signature, and you cannot edit the code after install without breaking the hash — so never write state into your own folder.
- Entitled — the activated licence still owns this gateway, as decided by the marketplace. The same signed code copied onto an install that never bought it fails here.
Payments add one wrinkle the other extension types don't need. A confirmed payment must settle even if the licence has since lapsed — the customer already paid, and losing that money to a billing gap would be indefensible. So on an inbound webhook or browser return, the entitlement gate is lenient, while genuine stays strict — verification never relaxes. And a gateway an operator has disabled (Gateway.status = false, the containment lever for leaked credentials) resolves to NullGateway on both checkout and callback, so a compromised gateway takes no new money and processes no new settlements. Any slug that resolves to nothing falls back to NullGateway, so a checkout or a callback fails safely instead of running unverified code. The security & trust model explains each gate in full.
Publishing follows from this. You submit the package as a reviewed author; on approval the marketplace signs the artifact with its key. Pricing is set at creation — free or a paid one_time purchase — the platform takes a 20% fee on paid sales, and a buyer's entitlement covers every version (installs are gated on entitlement, not on the buyer's Core support term). Operators install from the Core admin at Payments → Gateway Extensions (/admin/gateway/extensions) with one click — resolve, single-use grant, download, verify, register — which auto-adds a config row under Auto Gateways seeded from your manifest credentials.
Because a gateway is a stateless artifact, updates are painless. You publish a strictly-greater semver; the install shows an "update available" badge and applies it through the same signed path as an atomic code swap. No data is lost — every saved credential and currency lives in the database keyed by the immutable slug, which an update never changes; and since entitlement covers all versions, updating a paid gateway never charges again. See publishing & updates for the flow.
Where to go next#
The rest of the series builds on this model:
- [Quickstart](/payment-development/payment-quickstart) — scaffold a working driver and get it resolving.
- [The manifest](/payment-development/payment-manifest) — every
salieno.jsonfield, explained. - [The driver contract](/payment-development/payment-contract) — each method's arguments and exact return shape.
- [Capabilities](/payment-development/payment-capabilities) — the capability system in depth, surface by surface.
- [Credentials & configuration](/payment-development/payment-credentials) — the config form, global vs per-currency, and the sandbox toggle.
- [Settlement & webhooks](/payment-development/payment-settlement) —
settle(), idempotency, and verifying an unauthenticated callback. - [Publishing & updates](/payment-development/payment-publish) — submit, get signed, release new versions.
- [Security & trust](/payment-development/payment-security) — the three gates in full, the webhook leniency, and the disable lever.
Stripe and PayPal both ship in the repo as worked examples — Stripe for the hosted-redirect-plus-webhook shape, PayPal for the redirect-and-capture-on-return shape — and are worth reading alongside these articles. Start with the quickstart when you are ready to write code.