Community payment gateway modules for the billing system. Each gateway is a self-contained
folder that drops into a panel's modules/payment_gateways/ directory and adds a new
checkout option (Stripe, Razorpay, PayPal, and anything you build).
This repo is open to contributions. Fork it, add your gateway as a new folder, and open a pull request. Merged gateways become available to every operator running the platform.
- Template to copy:
_example/ - Full technical contract: reference below
# 1. Fork this repo on GitHub, then clone your fork
git clone https://github.com/<you>/payment-gateways.git
cd payment-gateways
# 2. Copy the template to your gateway's slug
cp -r _example acme_pay
# 3. Build it out
# - edit acme_pay/manifest.json (slug, class FQN, currencies)
# - edit acme_pay/src/*.php (rename class + namespace, implement methods)
# - edit acme_pay/frontend/*.js (browser SDK bridge)
# 4. Open a PR back to this repo
git checkout -b gateway/acme_pay
git add acme_pay
git commit -m "feat: add Acme Pay gateway"
git push origin gateway/acme_payThen open a pull request. See Contributing for the review checklist.
payment-gateways/ # this repo == a panel's modules/payment_gateways/ dir
_example/ # bare template - copy this to start a new gateway
_stripe/ # reference: SDK + client-secret + signed webhook
_razorpay/ # reference: hosted modal + returned-signature capture
_paypal/ # reference: pure-HTTP (no SDK) + API webhook verify
acme_pay/ # ← your PR adds a folder like this
README.md # this file
Folders prefixed with
_are not auto-discovered — they are templates and reference implementations to study and copy, not live gateways. Your own gateway folder must NOT start with an underscore.Stripe, Razorpay, and PayPal also ship built into the platform core (
app/PaymentGateways/Drivers/); the_stripe/_razorpay/_paypalfolders here are module-format mirrors of those drivers, provided purely as worked examples.
A single gateway folder:
<your_slug>/
manifest.json # Required - module metadata
src/
YourGateway.php # Required - PHP gateway class
frontend/
your_gateway.js # Required - browser-side SDK bridge
README.md # Recommended - credentials + webhook setup
The folder name (slug) must be lowercase, contain only letters, digits, and underscores,
and match the slug field in manifest.json.
To test your gateway you need a running install of the platform. Clone this repo into its
modules/payment_gateways/ directory (or symlink your single folder there):
# inside a panel install
git clone https://github.com/<you>/payment-gateways.git modules/payment_gatewaysThen:
- In the admin panel, open Billing → Payment Gateways and click Re-discover Modules. Your gateway appears in the list.
- Click Configure, enter sandbox/test credentials, set Mode to Sandbox, enable.
- Open a test user account, add funds, complete a checkout with your gateway.
- Send a test webhook with your provider's testing tool or
curl:
curl -X POST https://your-panel.test/billing/webhook/your_slug \
-H "Content-Type: application/json" \
-H "X-Example-Signature: $(echo -n '{"type":"payment.succeeded"}' | openssl dgst -sha256 -hmac 'your_secret' | awk '{print $2}')" \
-d '{"type":"payment.succeeded","id":"evt_test","data":{"transaction_id":"...","payment_id":"..."}}'- If you have a full panel checkout, run the discovery test suite:
vendor/bin/phpunit tests/Feature/Billing/ModuleDiscoveryTest.phpSwitch endpoints on $this->mode ('test' or 'live'), never a config field:
$baseUrl = $this->mode === 'live'
? 'https://api.yourprovider.com'
: 'https://sandbox.yourprovider.com';manifest.json must be valid JSON:
| Field | Type | Required | Description |
|---|---|---|---|
slug |
string | yes | Unique gateway identifier, lowercase, underscores only |
name |
string | yes | Human-readable display name |
version |
string | yes | SemVer string, e.g. "1.0.0" |
author |
string | yes | Author or organisation name |
gateway_class |
string | yes | Fully-qualified PHP class name |
autoload |
string | yes | Path to PHP file, relative to manifest directory |
frontend_js |
string | yes | Path to JS file, relative to manifest directory |
supports |
array | yes | ISO 4217 currency codes your gateway accepts |
supports_refunds |
bool | no | true if refund() is implemented (default false) |
{
"slug": "acme_pay",
"name": "Acme Pay",
"version": "1.0.0",
"author": "Acme Corp",
"gateway_class": "Modules\\PaymentGateways\\AcmePay\\AcmePayGateway",
"autoload": "src/AcmePayGateway.php",
"frontend_js": "frontend/acme_pay.js",
"supports": ["USD", "EUR", "GBP"],
"supports_refunds": true
}The manager require_onces the autoload file before instantiating your class. That single
require_once is the only thing the loader does — it does not run Composer, scan src/,
or load any vendor/ directory. A class with no external dependencies needs nothing more.
If your gateway needs third-party packages (a provider SDK, etc.), ship a composer.json and
a vendored vendor/ directory inside your module folder, then bootstrap your own autoloader
from the top of your autoload entry file. The platform never loads it for you.
acme_pay/
composer.json
vendor/ # commit this, or have your CI build it before the PR
autoload.php
src/AcmePayGateway.php
At the very top of src/AcmePayGateway.php:
<?php
namespace Modules\PaymentGateways\AcmePay;
// Load this module's own Composer dependencies (provider SDK, etc.).
// The platform require_once's this file but does NOT load module vendors for you.
$vendor = __DIR__ . '/../vendor/autoload.php';
if (is_file($vendor)) {
require_once $vendor;
}
use App\PaymentGateways\AbstractPaymentGateway;
// ... rest of your class- Never bundle a package the platform core already ships. PHP cannot hold two versions of the same class — whichever loaded first wins, so your code may silently run against the wrong version. Guzzle, Symfony components, Monolog, the Laravel HTTP client, and the Stripe /Razorpay SDKs are already present; use those instead of vendoring your own copy. Only vendor packages that are unique to your provider.
- Keep the entry file's top-level code to the autoload require only. The loader runs that
require_onceon every request, so do no other work at file scope. - Commit
vendor/(or build it in CI before opening the PR). There is nocomposer installstep on the panel — the folder is used as-is. - Need a conflicting version of a shared package? Isolate it with a prefixer such as php-scoper. This is heavy; only do it when a clash is unavoidable.
Your class must extend App\PaymentGateways\AbstractPaymentGateway and define
public const SLUG matching the manifest slug.
Do not define a constructor. The base class constructor receives a
App\Models\Billing\PaymentGatewayConfig $row and populates:
$this->config— decrypted credentials array (from$row->credentials)$this->mode—'test'or'live'(from$row->mode)$this->row— the raw model row
slug(): string — return self::SLUG. Used for registration keying.
name(): string — human-readable gateway name shown in admin and on checkout.
configSchema(): array — field definitions for the admin credentials form:
[
'key' => 'api_key', // array key in $this->config
'type' => 'text', // 'text' | 'password' | 'select' | 'checkbox'
'label' => 'API Key', // shown in admin form
'required' => true, // validated before saving
'encrypted' => true, // stored encrypted at rest (use for secrets)
]The base class validateConfig(array $config): array iterates this schema and checks
required fields automatically. Override only for cross-field validation.
createIntent(Transaction $tx): IntentResult — called when checkout begins. Create a
payment intent on your provider's API and return the result.
$tx->amount_payable— amount in minor units (e.g. cents)$tx->currency->code— ISO 4217 currency code$tx->id— embed in provider metadata so webhooks can resolve it
new IntentResult(
pgIntentId: string, // provider's intent/session ID
clientSecret: ?string, // browser token (null if unused)
frontendExtra: array, // extra keys merged into frontendPayload result
);frontendPayload(Transaction $tx, IntentResult $intent): array — object passed to your
JS mount(). Include only publishable/safe values, never API secrets.
captureAndVerify(Transaction $tx, array $clientPayload): CaptureResult — called
server-side after the browser flow. $clientPayload is whatever your JS passed to
onSuccess({...}).
Security:
$clientPayloadcomes from the browser and is attacker-controllable. Never treat it as proof of payment. Independently confirm with the provider — verify a returned signature (Razorpay HMACsorder_id|payment_id) or re-fetch/capture via the provider API (Stripe, PayPal) — and populatepaidAmountMinor/status/paidCurrencyfrom the provider's response, not from$clientPayloador$tx. On a declined or unverifiable payment, returnCaptureResult(status: 'failed', ...)rather than throwing. Return'requires_action'when the payment needs 3-D Secure / SCA. Gate credential access withrequireConfig(...)so an unconfigured gateway surfaces a clearGatewayExceptioninstead of a PHP fatal.
new CaptureResult(
pgPaymentId: string, // provider's payment reference ID
status: string, // 'succeeded' | 'failed' | 'requires_action'
rawResponse: array, // full API response for audit logging
paidAmountMinor: int, // amount confirmed paid, minor units
paidCurrency: string, // ISO 4217 currency code
pgFeeMinor: ?int, // provider processing fee, minor units (null if unknown)
);verifyWebhookSignature(Request $request): bool — validate the HMAC/signature on an
incoming webhook. Return false to reject with a 401; parseWebhookEvent() is not called
when this returns false.
$sig = $request->header('X-Your-Signature');
$secret = $this->config['webhook_secret'] ?? null;
$expected = hash_hmac('sha256', $request->getContent(), $secret);
return hash_equals($expected, $sig);Always use hash_equals() for constant-time comparison. Read the raw body with
$request->getContent() before any JSON decode — some providers require exact raw bytes.
parseWebhookEvent(Request $request): WebhookEvent — parse the verified body:
new WebhookEvent(
pgEventId: string, // provider's unique event ID (used for idempotency)
eventType: string, // provider-native event type string
paymentStatus: string, // 'succeeded' | 'failed' | 'pending' | 'refunded'
transactionId: ?string, // local transaction UUID from metadata (null if missing)
pgPaymentId: ?string, // provider's payment reference ID
rawPayload: array, // decoded webhook body for audit logging
);Map your provider's event type strings to the standard paymentStatus values with a match
expression (see _example/src/ExampleGateway.php::parseWebhookEvent()).
supportsRefunds(): bool — return true if you implement refund(). The admin panel
checks this before showing the refund button. Set together with manifest.supports_refunds.
refund(Transaction $tx, int $amountMinor, string $reason): RefundResult — the base
class throws NotSupportedException by default. Override to implement. Partial refunds work
by passing $amountMinor less than the captured amount — validate it does not exceed:
new RefundResult(
pgRefundId: string, // provider's refund reference ID
status: string, // 'succeeded' | 'failed' | 'pending'
refundedAmountMinor: int, // amount refunded, minor units
rawResponse: array, // full API response for audit logging
pgRefundFeeMinor: ?int, // provider refund fee, minor units (null if unknown)
);Return status: 'pending' for async refunds confirmed later via webhook.
$this->minorToMajor(int $minor, string $currency): float$this->majorToMinor(float $major, string $currency): int$this->requireConfig(string $key): mixed— fetch a credential or throwGatewayException$this->logEvent(string $level, string $msg, array $ctx = []): void— writes to thebilling_gatewayslog channel with your slug prepended
Register your gateway under window.billingGateways:
window.billingGateways = window.billingGateways || {};
window.billingGateways['your_slug'] = {
async mount(payload, onSuccess, onError) {
// render your widget into <div id="billing-gateway-mount">
}
};| Parameter | Type | Description |
|---|---|---|
payload |
object | Return value of frontendPayload() from the server |
onSuccess |
function | Call with a plain object when the user authorises payment |
onError |
function | Call with a human-readable error string on failure |
The checkout page provides <div id="billing-gateway-mount"> inside the payment form.
Render your provider's widget there. On completion:
onSuccess({ payment_id: 'PROVIDER_PAYMENT_ID', /* any keys captureAndVerify needs */ });The keys you pass become the $clientPayload array in captureAndVerify(). Design both
sides together. On any failure (declined, network, cancel):
onError('Human-readable message shown to the user');Load a hosted provider SDK dynamically inside mount(), wrapped in try-catch:
const script = document.createElement('script');
script.src = 'https://sdk.yourprovider.com/v1/checkout.js';
document.head.appendChild(script);
await new Promise((resolve, reject) => {
script.onload = resolve;
script.onerror = () => reject(new Error('Failed to load payment SDK'));
});HMAC-SHA256 (most common)
public function verifyWebhookSignature(Request $request): bool
{
$sig = $request->header('X-Your-Signature');
$secret = $this->config['webhook_secret'] ?? null;
if (!$sig || !$secret) {
return false;
}
$expected = hash_hmac('sha256', $request->getContent(), $secret);
return hash_equals($expected, $sig);
}Timestamp + HMAC (replay prevention) — validate the timestamp is within an acceptable window (e.g. 5 minutes) before verifying the HMAC:
public function verifyWebhookSignature(Request $request): bool
{
$header = $request->header('X-Your-Signature') ?? '';
$parts = [];
foreach (explode(',', $header) as $part) {
[$k, $v] = explode('=', $part, 2) + [1 => ''];
$parts[$k] = $v;
}
$timestamp = (int) ($parts['t'] ?? 0);
if (abs(time() - $timestamp) > 300) {
return false; // replay window exceeded
}
$secret = $this->config['webhook_secret'] ?? null;
$payload = $timestamp . '.' . $request->getContent();
$expected = hash_hmac('sha256', $payload, $secret);
return hash_equals($expected, $parts['v1'] ?? '');
}RSA signature verification — for providers using asymmetric signatures:
$pubKey = $this->config['webhook_public_key'];
$signature = base64_decode($request->header('X-Signature'));
$result = openssl_verify($request->getContent(), $signature, $pubKey, OPENSSL_ALGO_SHA256);
return $result === 1;Three complete, module-format reference gateways ship in this repo. Each demonstrates a
different real-world integration shape — read whichever is closest to your provider, then
copy _example/ (the bare template) to start your own:
| Folder | Provider | Pattern it demonstrates |
|---|---|---|
_stripe/ |
Stripe | Vendor SDK, client-secret, signed-payload webhook, processing-fee capture |
_razorpay/ |
Razorpay | Hosted checkout modal, returned-signature verification (no client secret) |
_paypal/ |
PayPal | Pure HTTP (no SDK), OAuth, major-unit amounts, API-based webhook verification |
These mirror the drivers built into the platform core (app/PaymentGateways/Drivers/).
Patterns they all follow that are easy to miss:
- Never trust the browser in
captureAndVerify()— confirm server-side, read amounts from the provider response. - Guard every credential access with
requireConfig(...)— clear error over PHP fatal. requires_actionis a real status, not an error — return it for 3-D Secure / SCA.- Intent id != payment id —
pgIntentIdis stored aspg_intent_id; the capturedpgPaymentIdis stored aspg_payment_idand is whatrefund()operates on. - Mode comes from
$this->mode, not a config field. - Webhooks dedupe on
pgEventId— return the provider's stable event id.
- Fork this repo and create a branch:
gateway/<your_slug>. - Copy
_example/to<your_slug>/and build it out. - Write a
README.mdin your folder covering the credentials required and webhook setup steps. - Test end-to-end against a panel install in sandbox mode (see Installing & Testing).
- Open a PR. One gateway per PR.
PRs are reviewed for:
- Correct implementation of all required interface methods
- Secure webhook signature verification (constant-time comparison, raw-body read)
- No leaking of API secrets to the frontend (
frontendPayload()is publishable-only) requireConfig()guarding every credential access- Sandbox/test mode support via
$this->mode - At least one supported currency confirmed working end-to-end
Open a GitHub issue for bugs in a gateway or the contract. For security-sensitive reports (a gateway leaking secrets, a signature bypass), do not open a public issue — email the maintainers instead.
See LICENSE.