Capabilities and the UI
A gateway's declared capabilities decide which controls Core draws — at checkout, in the admin refund modal, and for automatic renewals. Declare one to show it; omit it and Core routes around it.
On this page
A payment-gateway extension never renders a screen of its own. Core owns every surface a payment touches — the checkout method picker, the client invoice-pay modal, the admin refund modal, the automatic-renewal billing run, the Auto Gateways connection form — and decides what to draw from one thing your driver declares: its capabilities. Declare a capability and Core shows the control. Omit it and Core routes around it. There is no third state where a button appears and then fails when clicked — a control shown-then-failing only trains operators to click through the next one that lies. Core never enumerates gateway names; every surface asks whether the gateway backing this payment supports the action, and draws accordingly.
This article covers the PaymentCapability vocabulary, exactly what each capability lights up across checkout, refunds and renewals, and the honest-declaration principle that makes a partial gateway first-class: Core asks whether you support an action before it ever shows the control.
The capability vocabulary#
Capabilities are string constants on App\PaymentGateways\PaymentCapability. There are nine, grouped by what they govern:
namespace App\PaymentGateways;
final class PaymentCapability
{
// Collect
public const CHARGE = 'charge'; // collect a one-off payment (every gateway must)
// Reverse
public const REFUND = 'refund'; // reverse a captured payment (full amount)
public const PARTIAL_REFUND = 'partial_refund'; // reverse LESS than the full captured amount
// Checkout-flow shape
public const WEBHOOK = 'webhook'; // settle async via a server-to-server webhook/IPN
public const REDIRECT = 'redirect'; // off-site hosted-page redirect flow
public const HOSTED_FIELDS = 'hosted_fields'; // inline/embedded fields via the gateway's client SDK
public const CRYPTO = 'crypto'; // a cryptocurrency gateway (address/QR settlement)
public const THREE_D_SECURE = '3ds'; // 3-D Secure / SCA challenge support
// Billing
public const RECURRING = 'recurring'; // off-session auto-billing — required for renewals
public const ALL = [ /* all nine, in the order above */ ];
}PaymentCapability::ALL is the full set — the declaration for a gateway that genuinely does everything. Your capabilities() method returns some subset of it, and unlike the other extension kinds there is a floor: every automatic gateway must declare at least `CHARGE`. One value is a trap worth memorising — the constant is THREE_D_SECURE but its string is '3ds', and it is the string that goes in the manifest.
What is not a capability: settlement#
Before the table, the one thing that separates a payment gateway from every other extension. Crediting a wallet or marking an invoice paid is deliberately not a capability. No capability grants a driver that power and none needs to. When your ipn() has verified the money actually moved, it stores the proof on $deposit->detail, saves, and calls one helper:
$deposit->detail = $session; // proof of payment
$deposit->save();
$this->settle($deposit); // Core's idempotent, row-locked settlement boundarysettle() routes to Core's single boundary (PaymentController::userDataUpdate → PaymentService::finalizeSuccessfulPayment), which credits exactly once no matter how many times a webhook and a browser-return both fire. A driver that settled the balance itself would double-credit on that re-delivery. This is why WEBHOOK is a descriptor, not a grant: declaring it does not unlock settlement — it only tells Core, and the operator, to expect a server-to-server callback at /client/ipn/<slug>. Settlement stays core-owned for every gateway, declared or not.
What each capability lights up#
Each capability maps to a control Core draws or a decision it makes. Declare only the capabilities behind which you have a working method, because Core acts on every row in this table the moment you list its capability.
| Capability | Constant | What it lights up in Core |
|---|---|---|
charge | CHARGE | The gateway is offered as a payment method at all — at checkout and in the client invoice-pay modal; process() runs. Mandatory |
refund | REFUND | The admin refund to gateway path in the refund modal (refund() is called). Omit it and the admin refunds to the wallet instead — see below |
partial_refund | PARTIAL_REFUND | The refund modal accepts an amount less than the full captured total (a partial reversal) |
webhook | WEBHOOK | Declares the gateway settles over a server-to-server callback the operator wires up; ipn() acks with ipnAck() / ipnReject() |
redirect | REDIRECT | Declares the off-site hosted-page flow — process() returns a redirect_url and the buyer leaves to pay, then returns |
hosted_fields | HOSTED_FIELDS | Declares inline/embedded fields via the gateway's client SDK — process() returns a session and Core renders the confirm view |
crypto | CRYPTO | Declares a cryptocurrency gateway — address/QR settlement and on-chain confirmation (process() returns the crypto view) |
3ds | THREE_D_SECURE | Declares 3-D Secure / SCA challenge support in the checkout flow |
recurring | RECURRING | The gateway is eligible for automatic renewals (off-session auto-billing); omit it and Core never bills a renewal through it |
There are two kinds of capability in that list. Three gate a discrete decision Core makes without loading your driver: refund and partial_refund gate the admin refund modal, and recurring gates whether the automatic-renewal run will bill through this gateway. *The rest — `webhook`, `redirect`, `hosted_fields`, `crypto`, `3ds` — describe the shape of your checkout* so Core renders the right confirm surface and the operator knows what to set up. Together with charge they are the honest label on the box: they must match what process() returns and what ipn() does.
The same declarations drive three moments. At checkout, charge decides whether the gateway is offered at all, and redirect / hosted_fields / crypto tell Core which confirm surface to draw from what process() returns. In the admin refund modal, refund and partial_refund decide whether an operator can reverse the charge at the gateway or only credit the wallet. In the automatic-renewal run, recurring decides whether a saved instrument is billed off-session. Anything you do not declare, Core routes around.
One control is worth calling out as the standout payment-specific behaviour. The refund control never disappears; only its destination is gated. Every paid invoice can be refunded — the question the capability answers is where the money comes from. Declare refund and the admin modal offers refund to gateway, which calls your refund() and reverses the charge at the PSP. Omit it — as a gateway with no refund API must — and the modal falls back to a wallet credit: the customer is still made whole, but out of Core's balance instead of the gateway. That is exactly the seam RefundService checks before it offers the button:
$driver = app(GatewayResolver::class)->driverForDeposit($deposit);
if (! ($driver instanceof NullGateway)
&& in_array(PaymentCapability::REFUND, $driver->capabilities(), true)) {
return true; // offer "refund to gateway"
}So an operator never sees a refund to gateway button that will bounce; they see it only when your driver truly reverses charges, and a wallet credit otherwise. partial_refund refines the same modal — with it, an operator can key an amount below the captured total; without it, only a full reversal is on offer.
Core asks before it shows#
Every payment surface routes its decision through one seam, GatewayResolver::supports():
public function supports(?Gateway $gateway, string $capability): bool
{
return in_array($capability, $this->capabilitiesFor($gateway), true);
}The interesting part is capabilitiesFor(). It does not load your driver to answer — it reads the capability list off the installed extension's signed manifest (a cheap, signature-checked lookup) and runs it through PaymentCapability::sanitize():
public function capabilitiesFor(?Gateway $gateway): array
{
if ($gateway === null || ! $gateway->status || ! $gateway->isAutomatic()) {
return [];
}
$manifest = app(GatewayRegistry::class)->manifestOf($gateway->gateway_slug);
return is_array($manifest)
? PaymentCapability::sanitize($manifest['capabilities'] ?? [])
: [];
}Two properties follow. First, because gating reads the manifest and never executes driver code, a bug in your driver can never change a control's visibility — Core has already decided what to draw before any of your methods run. Second, a disabled gateway (Gateway.status = false, the operator's containment lever for compromised credentials), a manual gateway, or one whose extension is not installed and entitled declares nothing here and resolves to NullGateway, whose capabilities() is []. Either way an unresolved or disabled gateway declares nothing, so the surface degrades to the safe fallback — a wallet credit, or simply not offered — rather than throwing. Note that this UI gate reads entitlement; the callback path at /client/ipn/<slug> is deliberately more lenient about a lapsed licence so a confirmed payment still settles, but a disabled gateway resolves to NullGateway on both checkout and callback.
Declaring capabilities in your driver#
There are two ways a driver ends up with a capability set, and they have opposite defaults. Pick deliberately.
Extend `AbstractGateway` (recommended). Its capabilities() returns an empty array by default, so nothing is declared until you opt in. You override the method to list exactly what you implement:
use App\PaymentGateways\AbstractGateway;
use App\PaymentGateways\PaymentCapability;
class Gateway extends AbstractGateway
{
public function capabilities(): array
{
return [
PaymentCapability::CHARGE,
PaymentCapability::REFUND,
PaymentCapability::WEBHOOK,
PaymentCapability::REDIRECT,
];
}
// ...override only the methods for the capabilities above
}This is the safe path for any gateway that does not do everything: because the default hides all controls, a half-built gateway is safe rather than broken — the refund path can only appear once you have both declared REFUND and written refund().
Implement `GatewayInterface` directly. If you pull in GatewayModuleTrait yourself instead of extending AbstractGateway, the trait's default capabilities() returns PaymentCapability::ALL — the right choice only for a gateway that genuinely implements every capability. AbstractGateway starts you at nothing and makes you add; the trait starts you at everything and makes you subtract. A partial gateway should extend AbstractGateway so its default is honest. Whichever path you choose, remember the floor: the array must contain at least PaymentCapability::CHARGE.
Whichever you choose, the manifest's `capabilities` array must match your driver's `capabilities()` exactly. Core reads the manifest to gate the UI (see above) and trusts the driver at runtime; if they disagree, a control can appear that the driver refuses, or vice versa. One safety net applies to both paths and both sources: PaymentCapability::sanitize() intersects whatever you declare with the recognised set, so a typo like 'parital_refund' — or writing 'three_ds' instead of the real string '3ds' — is silently dropped rather than lighting up a control. A misspelled capability is a missing capability, never an error.
Honest capabilities: Stripe and PayPal#
The two worked examples in the repo are deliberately not full-house declarations, and they usefully declare different flow shapes.
Stripe declares seven of the nine.
public function capabilities(): array
{
return [
PaymentCapability::CHARGE,
PaymentCapability::REFUND,
PaymentCapability::PARTIAL_REFUND,
PaymentCapability::WEBHOOK,
PaymentCapability::REDIRECT,
PaymentCapability::RECURRING,
PaymentCapability::THREE_D_SECURE,
// omit HOSTED_FIELDS, CRYPTO
];
}Each omission is principled:
- `hosted_fields` — the driver uses Stripe's hosted Checkout page;
process()returns aredirect_urland card fields live on Stripe, not embedded here. There are no inline fields to declare. - `crypto` — it is a card gateway.
It does declare recurring: when a buyer with auto-billing enabled pays, the Checkout Session saves the card for off-session reuse (setup_future_usage), the webhook captures it with saveInstrument(), and chargeSaved() bills it on renewals — so it is honestly eligible for the automatic-renewal run. Its manifest matches the driver value-for-value (note the string "3ds", not the constant name):
"capabilities": ["charge", "refund", "partial_refund", "webhook", "redirect", "recurring", "3ds"]PayPal declares only four — and the difference from Stripe is the lesson:
public function capabilities(): array
{
return [
PaymentCapability::CHARGE,
PaymentCapability::REFUND,
PaymentCapability::PARTIAL_REFUND,
PaymentCapability::REDIRECT,
// omit WEBHOOK, HOSTED_FIELDS, RECURRING, CRYPTO, THREE_D_SECURE
];
}PayPal omits WEBHOOK where Stripe declares it — and that is exactly right. Stripe settles over a signed webhook (ipn() verifies Stripe-Signature and acks), so it declares webhook. PayPal's ipn() instead captures the order server-side on the buyer's browser return (Orders v2 capture-on-return) and finishes with returnSuccess() / returnFailed() — no server-to-server callback — so it does not. Two gateways, two honest answers, each matching what its ipn() actually does.
The shape holds for any gateway: a processor with inline Elements would add hosted_fields; one that saves the instrument and auto-bills would add recurring, which is what makes it eligible for renewals; an on-chain gateway would declare crypto and return the crypto QR view from process(). The failure mode to avoid is the opposite — declaring recurring to look complete and then leaving the renewal run stranded, or declaring refund with no refund API so every refund to gateway click bounces. That is the shown-then-failing control the whole system exists to prevent.
A partial gateway is first-class#
The capability system rewards honesty. There is no penalty for a smaller declaration: a gateway that only charges — one process(), one ipn(), no refund, no renewals — is a completely valid, shippable extension, and no core edits are ever needed to onboard it. Core simply offers it at checkout, credits the wallet on settlement, and refunds to balance when an operator asks.
Declare charge and add from there: back each declared capability with the method behind it, keep the manifest's capabilities array and your driver's capabilities() identical, and let Core route every surface — checkout, refunds, renewals — around your honest answer. Next, wire those declarations to real methods — the method contract specifies the exact return shape for each one; the overview shows how a gateway plugs into Core; and the quickstart walks a driver from empty class to installed extension.