Skip to main content
You only teach it how to talk to your provider.
Read Fundamentals first if you haven’t.

Generate it

Drop --webhook if the provider has no webhooks. Gateways are registered when an Octane worker boots, so the new one only reaches checkout after the reload.

Two naming rules

Both are enforced by how discovery works, so they aren’t optional.
1

The module must be named {Something}Gateway

PaymentServiceProvider scans Modules/Gateways/* and derives a key from the name, so AcmeGateway becomes acme.
2

The payment service must be at Modules{Module}\Services{Module}PaymentService

And getGatewayName() has to return that same key.
Registration is lazy: nothing is instantiated at boot, so credentials are only decrypted when a payment actually happens. Don’t put API calls in your constructor.

What you implement

BasePaymentService handles every billing flow already. It looks at $paymentData['type'] and routes to the right one.
You write six methods. The generator stubs all of them.
app/Services/AcmeGatewayPaymentService.php

enabled is the on/off switch

Keep it false until the gateway is fully configured. A half-set-up gateway appearing at checkout is worse than it not appearing at all.
The available-gateways list is cached for five minutes, so your settings controller has to call flushPaymentGatewayCache() after saving. The generator does this. Remove it and admins will think saving is broken.

Verification: pick one

Four rules, and none of them are stylistic.
Re-query the provider inside executePayment(). Query strings are attacker-controlled.
Providers retry. handlePaymentSuccess() has to be safe to call again for the same payment, so check the PaymentToken state first.
Not just the status. Confirm what was paid is what you asked for.

Native subscriptions

Optional.
If your provider owns the recurring schedule (Stripe Subscriptions, PayPal Billing Agreements), add a flow handler. Core will use it instead of one-off payments. Each is a should...() predicate plus a handle...() action. Core probes with method_exists() first, so a half-finished handler falls back to the normal flow rather than erroring.
The argument lists are long and positional: handleCheckout() takes 23, handleRenewal() 7, handleInvoicePayment() 6. module:doctor checks yours against those counts.
Declare it explicitly rather than relying on the class name:
Modules/Gateways/BankWireGateway/Services/CheckoutHandler.php is a short readable example: it creates the invoice and waits for proof of payment.

Several checkout options from one module

Optional.
Implement GatewayVariantInterface to appear more than once, like “PayPal” and “PayPal Subscription”.

Saved payment methods

Optional.
Customers can save a card or account once, then have renewals and invoices charged to it automatically. To offer that, add Services\{Module}VaultProvider implementing App\Services\Payment\Vault\VaultProviderInterface. Core finds it by name, the same way it finds your payment service, and module:doctor checks it. clientConfig()['flow'] picks how the details are collected:
beginSetup() returns ['redirect_url' => ...] and the customer is sent there. Point the provider’s return URL at a route in your module, and finish through core from it:
PayPalGateway’s VaultController is the reference.
Charging has its own rules:
ChargeRequest::$idempotencyKey stays the same when core retries a charge. Pass it as the provider’s idempotency key so a retry can never charge twice.
A decline is ChargeResult::failed($code, $message). Set methodUnusable when the method is dead (expired, closed, detached) so core stops trying it. If you couldn’t reach the provider, return ChargeResult::unknown(): core holds the attempt and checks again later rather than assuming it failed.
It’s false for automatic renewals and true when the customer is paying at the keyboard. Tell the provider which kind of charge it is, because off-session charges are treated differently.
Implementing LooksUpCharges lets core’s sweeper settle attempts left pending or unknown by asking the provider. Return null when the provider has no record of the charge, and core releases the attempt as never submitted. Without lookup(), an unresolved attempt is released after a timeout with a critical log asking an admin to check the provider’s dashboard.
Return ChargeResult::requiresAction() with what the browser needs, implement HandlesCustomerAction, and register a browser handler for it with registerPaymentActionHandlers() in register.ts, the way StripeGateway does.
Implement ImportsExistingPaymentMethods too if the provider already holds methods for your customers, so they can be listed without being added again.

Subscriptions the gateway bills

Optional.
If your provider runs the recurring schedule itself, add Services\{Module}SubscriptionProvider implementing App\Services\Payment\Subscriptions\GatewaySubscriptionProvider, so core knows the subscription exists: This pairs with the checkout and renewal handlers in Native subscriptions, which start the subscription. PayPalGateway is the reference.

Test it

Then run every flow in sandbox, because they all go through the same type switch:
1

New purchase

2

Renewal

3

Invoice payment

4

Upgrade

5

Balance top-up

6

Cancel halfway

Confirm no service is created.
7

Pay, then close the browser before the redirect

The webhook should still finish the order.
8

Replay the same webhook twice

Nothing should double-charge or double-provision.
The last two are where real gateways break.

Worth reading

BankWireGateway

Offline payment, no API at all.

CashfreeGateway

Clean HMAC webhook verification.

VippsGateway

Straightforward redirect flow.

StripeGateway

Cards plus native subscriptions.

PayPalGateway

Everything, including variants and disputes.
Two rough edges. PaymentGatewayInterface assumes one flow (create, approve, execute), so refunds and partial captures have no contract and gateways that support them expose their own methods. And BankWireGateway mixes root and app/ layouts, which makes it a good behavioural reference and a poor structural one.