Generate it
--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.
What you get
What you get
Two naming rules
Both are enforced by how discovery works, so they aren’t optional.The module must be named {Something}Gateway
PaymentServiceProvider scans Modules/Gateways/* and derives a key from the name, so
AcmeGateway becomes acme.The payment service must be at Modules{Module}\Services{Module}PaymentService
getGatewayName() has to return that same key.What you implement
BasePaymentService handles every billing flow already. It looks at $paymentData['type'] and routes
to the right one.
The flows it covers
The flows it covers
enabled is the on/off switch
Keep itfalse until the gateway is fully configured. A half-set-up gateway appearing at checkout is
worse than it not appearing at all.
Verification: pick one
Never trust the redirect
Never trust the redirect
executePayment(). Query strings are attacker-controlled.Verify the webhook signature against the raw body, before parsing
Verify the webhook signature against the raw body, before parsing
Assume webhooks arrive twice
Assume webhooks arrive twice
handlePaymentSuccess() has to be safe to call again for the same payment, so
check the PaymentToken state first.Check the amount and the currency
Check the amount and the currency
Native subscriptions
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.
Declare it explicitly rather than relying on the class name:
Several checkout options from one module
GatewayVariantInterface to appear more than once, like “PayPal” and “PayPal Subscription”.
Saved payment methods
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:
- redirect
- element
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.Send the idempotency key to the provider
Send the idempotency key to the provider
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.Return a result, don't throw
Return a result, don't throw
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.customerPresent tells you who is paying
customerPresent tells you who is paying
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.Implement lookup() if charges can stay pending
Implement lookup() if charges can stay pending
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.3-D Secure and other confirmations
3-D Secure and other confirmations
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.ImportsExistingPaymentMethods too if the provider already holds methods for your
customers, so they can be listed without being added again.
Subscriptions the gateway bills
Services\{Module}SubscriptionProvider
implementing App\Services\Payment\Subscriptions\GatewaySubscriptionProvider, so core knows the
subscription exists:
PayPalGateway is the reference.
Test it
type switch:
New purchase
Renewal
Invoice payment
Upgrade
Balance top-up
Cancel halfway
Pay, then close the browser before the redirect
Replay the same webhook twice
Worth reading
BankWireGateway
CashfreeGateway
VippsGateway
StripeGateway
PayPalGateway
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.
