Skip to content

Powered by Grav

Coming soon — KahunaCart is in final testing. Join the list and be first to know. Join the list

Writing a KahunaCart Payment Provider

This page walks a PHP developer through building a KahunaCart payment provider plugin. Work through the sections in order: scaffold, implement, test, package.

Before you begin

  • You have written a Grav plugin before.
  • You have read the Provider Contract. That page is the specification; this one is the walkthrough. Where they disagree, the contract wins.
  • PHP 8.3 or later, matching the base plugin's floor.
  • A checkout of grav-plugin-kahunacart sitting beside your plugin directory. The test suite autoloads the base plugin's classes from there.

How a provider fits together

A provider is an ordinary Grav plugin that hands KahunaCart one object: a subclass of Grav\Plugin\KahunaCart\Provider\AbstractPaymentProvider. Its job is translation.

  • You translate. Build the API request, verify the signature, decode the event, map it to a result object.
  • The base plugin owns state. It opens the transaction row, completes the order, grants downloads, deducts stock, sends mail, updates payment status, and dedupes redeliveries.

A provider never writes order state, never touches the database, and never fires order events. Four events are available to it:

Event Payload Use it to
onKahunaCartRegisterProviders $event['registry'] Register your provider instance
onKahunaCartAdminSections $event['sections'] Add a screen to the KahunaCart admin
onKahunaCartOrderCompleted $event['order'] React once an order is placed
onKahunaCartOrderPaid $event['order_id'] React once money has landed

Tip

To do something after a payment succeeds, listen to onKahunaCartOrderPaid rather than working inside handleWebhook().

Every registered provider appears as a radio button at checkout, in registration order. Registering is enabling; there is no separate checkout setting. The template loops over ProviderRegistry::options(), which is slug() => label().

TWIG
{# templates/kahunacart-checkout.html.twig #}
{% for slug, label in providers %}
<label class="kahunacart-provider"><input type="radio" name="provider" value="{{ slug }}"> {{ label }}</label>
{% endfor %}

Scaffold the plugin

  1. Copy kahunacart-dummy. It is MIT-licensed, free, and implements the whole contract with no API client in the way.
  2. Rename the plugin directory, the plugin class, blueprints.yaml, and the YAML config file to your own slug.
  3. Keep the layout.
TXT
grav-plugin-kahunacart-dummy/
├── .gitignore              .DS_Store, .phpunit.cache/ — vendor/ is NOT ignored
├── blueprints.yaml         GPM manifest + admin config form
├── composer.json           composer.lock, CHANGELOG.md alongside
├── kahunacart-dummy.php    the Grav plugin class
├── kahunacart-dummy.yaml   shipped config defaults
├── classes/
│   └── DummyProvider.php   the contract implementation
├── languages/en.yaml       blueprint strings
├── phpunit.xml
├── tests/                  bootstrap.php + Unit/
└── vendor/                 committed

The plugin class has two responsibilities: autoload, and register.

PHP
// kahunacart-dummy.php
namespace Grav\Plugin;

use Composer\Autoload\ClassLoader;
use Grav\Common\Plugin;
use Grav\Plugin\KahunaCartDummy\DummyProvider;
use RocketTheme\Toolbox\Event\Event;

class KahunaCartDummyPlugin extends Plugin
{
    public static function getSubscribedEvents(): array
    {
        return [
            'onPluginsInitialized' => [['autoload', 100001]],
            'onKahunaCartRegisterProviders' => ['onKahunaCartRegisterProviders', 0],
        ];
    }

    public function autoload(): ClassLoader
    {
        return require __DIR__ . '/vendor/autoload.php';
    }

    public function onKahunaCartRegisterProviders(Event $event): void
    {
        if (!(bool)$this->config->get('plugins.kahunacart-dummy.enabled', false)) {
            return;
        }

        $event['registry']->register(new DummyProvider(/**/));
    }
}

Use the 100001 priority on autoload. It has to run before anything else touches your classes, and every KahunaCart plugin uses that number.

Keep your settings under plugins.<your-slug>, never under plugins.kahunacart. The one exception is per-provider sync direction, in kahunacart.yaml keyed by slug.

Important

Pick a slug nobody else would pick, and never change it. It is the webhook route segment, the ledger's provider column, and the sync map's key, and it must match /^[a-z0-9][a-z0-9-]*$/. A malformed slug, a slug another plugin registered first, or a listener that throws is logged rather than registered, so your provider never appears at checkout. Read logs/grav.log first.

Register only when the provider is usable

Refuse to register while the active mode's key is missing. A half-configured provider that appears at checkout fails in front of a customer.

PHP
public function onKahunaCartRegisterProviders(Event $event): void
{
    if (!(bool)$this->config->get('plugins.kahunacart-stripe.enabled', false)) {
        return;
    }

    $mode = (string)$this->config->get('plugins.kahunacart-stripe.mode', 'test') === 'live' ? 'live' : 'test';

    $secretKey = trim((string)$this->config->get("plugins.kahunacart-stripe.{$mode}_secret_key", ''));
    if ($secretKey === '') {
        return;
    }
    //
}

Make the label configurable too. Take it as a constructor argument with a class-constant default, so a merchant can rename "Polar (cards, global tax handled)" to "Pay by card" without a code change.

PHP
$event['registry']->register(new PolarProvider(
    //
    label: $label !== '' ? $label : PolarProvider::DEFAULT_LABEL,
));

Implement initiatePayment()

By the time you are called, the base plugin has verified the order is a payable cart, recalculated totals, opened a pending purchase transaction, and resolved the {transaction} placeholder in the return URL. You receive a PaymentContext.

amountMinor is minor units in currency. 1999 is $19.99, and 500 is ¥500 because JPY has no minor unit. Never divide by 100 to talk to an API that wants decimals; convert through the currency's exponent. See Money conventions.

customerName, items and billingAddress are prefill only. The item sum differs from amountMinor on any order carrying a discount, shipping, tax, or a plugin's fee.

Return one of three results. PaymentService::initiate() acts on each one:

Result What the base plugin does
PaymentResult::complete($ref, $paid) Completes the order with payment status paid or, when $paid is false, pending, then marks the ledger row successful. No stock hold, because completion deducts stock outright.
PaymentResult::redirect($url, $ref) Stores $ref on the ledger row, places a stock hold for the customer's trip (stock.hold_minutes, default 15), and sends a 302 to your URL.
PaymentResult::failed($message) Marks the ledger row failed, releases any hold, and throws your message as a KahunaCartException onto the checkout page with the cart intact.

Warning

Never throw for an ordinary API refusal. A thrown exception marks the transaction failed and rethrows as a PaymentException, putting your message in front of a customer. Separate the two the way Stripe's transport does: only network failures throw, and API-level errors become a PaymentResult::failed().

Never let a secret escape into a message. Both Stripe and Polar assert exactly that:

PHP
$this->assertStringNotContainsString('sk_test_supersecret', $result->message);

Hosted redirect

Create a session and return its URL.

PHP
public function initiatePayment(PaymentContext $context): PaymentResult
{
    try {
        $session = $this->request('POST', '/v1/checkout/sessions', $this->buildSessionParams($context));
    } catch (RuntimeException $e) {
        return PaymentResult::failed($e->getMessage());
    }

    if (isset($session['error'])) {
        return PaymentResult::failed($this->errorMessage($session));
    }

    $url = $session['url'] ?? null;
    if (!is_string($url) || $url === '') {
        return PaymentResult::failed('Stripe did not return a checkout URL.');
    }

    return PaymentResult::redirect($url, isset($session['id']) ? (string)$session['id'] : null);
}

Keep buildSessionParams() a separate public method, so the parameter mapping is testable without an HTTP round trip.

The customer comes back to {route}/payment/return/{transactionHash}, and that return is advisory. KahunaCartFrontend::showPaymentReturn() hands over the receipt if the order has already left the cart state, and otherwise renders a self-refreshing processing page. Completion always comes from the webhook.

On-site tokenized

The checkout page renders the provider's JS, payment details are tokenized in the browser, and initiatePayment() confirms server-side with the token, returning complete or redirect for a 3DS step.

Important

Card data must never reach the Grav server. The base plugin ships no hook for injecting your JS into the checkout template, so this style currently needs a theme override.

Synchronous, with no provider at all

OfflineProvider in the base plugin is the reference.

PHP
public function initiatePayment(PaymentContext $context): PaymentResult
{
    return PaymentResult::complete(null, false);
}

public function handleWebhook(ServerRequestInterface $request): WebhookResult
{
    return WebhookResult::ignored();
}

Declare capability flags

ProviderCapability is a backed enum. Declare only what you have implemented: the flags are a promise, and there is no runtime check that you kept it.

Flag What consults it
Refunds PaymentService::refund() refuses to call you without it
PartialRefunds PaymentService::refund() refuses an amount below the refundable balance without it
SyncProducts SyncService skips you for push and pull without it
SyncOrders Printed by sync --status; the order-sync path lands in a later phase
HostedCheckout Declarative. Declare it if the customer pays on the provider's own page
Tokenization Declarative. Declare it for on-site tokenized flows
MerchantOfRecord The calculator drops every local tax adjustment for an order paid through you
Subscriptions, LicenseKeys Reserved for the subscriptions and licensing add-ons

MerchantOfRecord changes the amount you are handed. The base plugin stamps your slug on the cart and recalculates with the tax stage contributing nothing, so amountMinor is a total with no local tax in it. Declare it only if you genuinely remit tax as merchant of record.

Note

MerchantOfRecord does not change the cart page, which is priced before any payment method is chosen and keeps showing the local estimate. Say so in your README.

Refunds gates the service, not the admin control. The control appears whenever an order's payment status is paid or partially_refunded, and refunding through a provider without Refunds returns a 422 saying "This payment method cannot refund automatically — record a manual refund instead". State in your README whether you support refunds.

Handle webhooks

The base plugin exposes POST {route}/webhook/{slug}, which with the default route is https://example.com/shop/webhook/stripe. KahunaCartFrontend::respondWebhook() hands your handler the PSR-7 request untouched, converts your WebhookResult to a status code, and answers {"ok": true|false}. The contract lists every action and its status.

The route is matched on path only, so a GET reaches your handler too, with an empty body. Verify-first handlers reject that naturally.

Verify before you trust anything

Read the body, verify it, and do nothing else until that passes.

PHP
public function handleWebhook(ServerRequestInterface $request): WebhookResult
{
    $payload = (string)$request->getBody();
    $header = $request->getHeaderLine('Stripe-Signature');

    if (!$this->verifySignature($payload, $header, time())) {
        return WebhookResult::invalid();
    }

    $event = json_decode($payload, true);
    if (!is_array($event)) {
        return WebhookResult::invalid();
    }

    return $this->mapEvent($event);
}

Verify on the raw bytes, before decoding. Re-encoding JSON changes the bytes and breaks every HMAC. Three things Stripe's verifySignature() gets right:

  • An unset webhook secret fails closed rather than skipping the check.
  • A timestamp outside the replay window (300 seconds) is rejected before the HMAC matters.
  • The comparison is hash_equals with no early return, so timing reveals nothing.

For certificate-chain signing, PayPal rejects a request missing a required header, or naming a certificate URL off a provider host, before making the verification call out.

Return WebhookResult::ignored() generously. Providers retry on non-2xx, so a 400 for an event you do not care about buys you a retry storm.

Round-trip both hashes

PaymentContext gives you two hashes. Get them onto the provider's side so the eventual webhook can name them back. Stripe attaches both to the session and to the payment intent, because the events it later receives are about different objects.

PHP
'metadata' => [
    'order_hash' => $context->orderHash,
    'transaction_hash' => $context->transactionHash,
],
'payment_intent_data' => [
    'metadata' => [
        'order_hash' => $context->orderHash,
        'transaction_hash' => $context->transactionHash,
    ],
],

Coming back, put the transaction hash in the payload and the order hash in its own argument.

PHP
return new WebhookResult(
    WebhookResult::PAYMENT_COMPLETED,
    $transactionRef,                        // the provider's reference
    $orderHash,                             // from your metadata
    ['transaction_hash' => $transactionHash] // from your metadata
);

PaymentService::locateTransaction() tries payload['transaction_hash'] (checking the row belongs to your slug), then findByReference(slug, transactionRef), then the order's latest pending purchase via orderHash. If all three miss, the event is answered 200 and dropped. Refund actions match on (slug, transactionRef) alone, against refund rows.

Two payload keys are read by name:

  • payload['reason'] — stored when a PAYMENT_FAILED or REFUND_FAILED marks the ledger row failed. Without it the ledger records "payment failed" or "refund failed".
  • payload['amount_minor'] — required for a REFUND_COMPLETED with no matching local row, which is the provider-initiated refund case. Without it, nothing is recorded.

Make handlers idempotent

Providers redeliver. On the payment path the base plugin gates on the order's own state, not on the ledger row: CheckoutService::complete() returns the order untouched once it has left the cart state, and markPaid() no-ops on an order already paid. Completion is retried until it works, so a redelivery after a 500 finishes the job.

That protects order state, not yours. Make your own side effects idempotent. Deriving references deterministically from the hashes makes redeliveries land on the same ledger row by construction.

PHP
public function referenceFor(string $transactionHash): string
{
    return 'dummy_' . substr(hash('sha256', 'txn:' . $transactionHash), 0, 20);
}

Test a webhook end to end

  1. Point your provider's tooling at the webhook route. Stripe's CLI does it with stripe listen --forward-to https://your-site.test/shop/webhook/stripe, which prints the whsec_… to paste in. PayPal has a simulator plus any HTTPS tunnel; Polar needs a public URL.

  2. Walk a real cart through checkout to the processing page.

  3. Drive the webhook by hand. The dummy provider needs nothing but curl.

    BASH
    curl -k -X POST https://your-site.test/shop/webhook/dummy \
      -H 'Content-Type: application/json' \
      -d '{"action":"payment_completed","transaction_hash":"TXN_HASH","order_hash":"ORDER_HASH"}'
    
  4. Read back what arrived with bin/plugin kahunacart webhooks. Every delivery leaves one row, and the command prints recent deliveries, per-provider totals, and failure streaks. Rows are swept after payments.webhook_log_days days (default 30).

Tip

Installing kahunacart-dummy helps even while building a different provider. Set it to redirect, walk a cart to the processing page, and you have a live order in exactly the state your own handler will meet.

Implement refunds

PHP
public function refund(string $transactionRef, int $amountMinor, string $currency): RefundResult

The base class default returns RefundResult::failed(...), so a provider without refunds implements nothing.

$transactionRef is the reference column of the order's successful purchase transaction: whatever initiatePayment() returned, possibly overwritten by the transactionRef your webhook later reported. Make sure it is a reference you can refund against.

$amountMinor is already validated: greater than zero, no more than the captured amount minus what has been refunded, and equal to the full remaining balance unless you declare PartialRefunds. $currency is the order's currency; most APIs derive it from the original charge.

Return succeeded($refundRef), pending($refundRef), or failed($message). The contract sets out what each does to the ledger.

Finish an asynchronous refund

Return pending() whenever your provider has not moved the money yet: queued awaiting balance, waiting on approval, or able to fail after submission. Reporting any of those as settled makes the ledger claim money went back that may never go back.

Finish the refund from your webhook, quoting the same reference you returned.

PHP
// Settled.
return new WebhookResult(WebhookResult::REFUND_COMPLETED, $refundRef, $orderHash, [
    'amount_minor' => $amountMinor,
]);

// Rejected, canceled, or failed after submission.
return new WebhookResult(WebhookResult::REFUND_FAILED, $refundRef, $orderHash, [
    'amount_minor' => $amountMinor,
    'reason'       => $failureReason,
]);

Handle refunds you did not initiate. A merchant refunding inside the provider's dashboard produces a REFUND_COMPLETED with no pending row, and the base plugin falls back to opening a refund transaction from orderHash plus payload['amount_minor'] — but only if you supplied both. Read the amount from the individual refund rather than a running total.

Add catalog sync

This step is optional, and only for providers that keep their own catalog. Declare SyncProducts and implement ProviderSyncInterface. SyncService checks the capability and instanceof, and skips a provider with one but not the other.

PHP
class PolarProvider extends AbstractPaymentProvider implements ProviderSyncInterface

pushProduct(array $product): string receives the local product row plus a variants key, default variant first, and returns the remote id, which the base plugin records in kahunacart_provider_map. Throw on failure: the base plugin wraps the throwable with your slug and the job queue retries with backoff.

You are given local data only, never the remote id you were previously mapped to. Polar solves this by stamping the local slug into provider-side metadata under a kahunacart_slug key and looking products up by it.

pullProducts(): iterable yields normalized product arrays, whose structure the contract specifies. Return a generator for a large catalog.

A SYNC_EVENT webhook lets a provider announce a change instead of waiting for a poll.

PHP
return new WebhookResult(WebhookResult::SYNC_EVENT, $id, null, [
    'entity_type' => 'product',
    'remote_id'   => $id,
    'data'        => $this->normalizeProduct($polarProduct),  // or null
]);

SyncService::ingest() drops a non-product entity type or an empty remote_id. If the site has not configured your provider as remote-master, the change only marks the mapping stale; if it has, data is applied directly, and data => null queues a full catalog pull.

Direction is the site's choice, not yours. It lives in kahunacart.yaml keyed by slug.

YAML
sync:
  polar:
    direction: remote   # local | remote | off (default)

Direction governs automatic work only. bin/plugin kahunacart sync --provider=<slug> --push|--pull always does what the operator asked, and running it with no action flag prints mapping counts, capabilities, interface support, and direction. Pulls never delete local products.

Note

A pull applies to the product and its default variant only. Extra variants are parked verbatim in the mapping row's data_json under extra_variants and are not created locally yet.

Add an admin section

This step is also optional. To add a screen — credentials with a connection test, a webhook status panel, a rate card — subscribe to onKahunaCartAdminSections and append an array.

PHP
public function onKahunaCartAdminSections(Event $event): void
{
    $sections = $event['sections'];
    $sections[] = [
        'id' => 'paypal',
        'label' => 'PayPal',
        'icon' => 'CreditCard',
        'position' => 200,
        'script_path' => 'plugin://kahunacart-paypal/admin-next/sections/paypal.js',
    ];
    $event['sections'] = $sections;
}

The script defines one custom element named kahunacart-section-<id> and guards its own registration, because the host may load it more than once.

JS
if (!customElements.get('kahunacart-section-paypal')) {
    customElements.define('kahunacart-section-paypal', KahunaCartPayPalSection);
}

Both section endpoints require kahunacart.orders.view, which is a viewing permission. If your screen writes settings, check kahunacart.settings on your own endpoints. See Admin sections for the full specification.

Write the tests

Every first-party provider runs a standalone PHPUnit suite that never boots Grav.

Wire the sibling autoload. The base plugin is not on Packagist, so autoload-dev maps its namespace at a relative path.

JSON
"autoload-dev": {
    "psr-4": {
        "Grav\\Plugin\\KahunaCartDummy\\Tests\\": "tests/",
        "Grav\\Plugin\\KahunaCart\\": "../grav-plugin-kahunacart/classes/"
    }
}

tests/bootstrap.php is then one line, require __DIR__ . '/../vendor/autoload.php';, and phpunit.xml declares a single Unit suite over tests/Unit with failOnWarning="true".

Keep the translation pure. Public, side-effect-free methods for parameter building, event mapping, and signature verification make the suite possible, including the clock, so you can test the replay window without waiting five minutes.

PHP
public function verifySignature(string $payload, string $header, int $now): bool

Make the API base injectable, so tests can point it at a stub or a dead port.

PHP
private readonly string $apiBase = 'https://api.stripe.com',

Cover these cases.

Test What it asserts
BehaviorMatrixTest One case per outcome, what an unrecognized config value does, and that capabilities() says what you mean
WebhookMappingTest Every event type you handle, both hashes surviving the round trip, unknown types ignored, unparsable bodies invalid
WebhookGatingTest That the check in front of your handler rejects for every action, and that rejection is the default
RefundTest Full and partial refunds, and that two different refunds do not collide on one reference
SignatureVerificationTest A good signature passes; a tampered body, wrong secret, stale timestamp, malformed header, and unconfigured secret all fail
TransportFailureTest With the API base at http://127.0.0.1:1, initiatePayment() and refund() degrade to failed results without throwing, and neither message contains the secret key

PayPal adds one more pattern: it injects an HttpTransport and asserts with a recording stub which calls happen, and which do not when a webhook arrives unsigned.

Then test against a real store. Install your plugin plus kahunacart-dummy on a sandbox site and walk a cart through checkout. bin/plugin kahunacart status reports what the base plugin thinks is registered.

Package for GPM

blueprints.yaml is both the GPM manifest and the admin form. Declare the dependencies explicitly, because GPM will not install the base plugin for you otherwise.

YAML
dependencies:
  - { name: grav, version: '>=2.0.0' }
  - { name: api, version: '>=1.0.0' }
  - { name: kahunacart, version: '>=0.1.0' }

compatibility:
  grav: ['2.0']

Set premium: true for a commercial plugin, premium: false for a free one like kahunacart-dummy (whose license field is then the license name, MIT, rather than a URL), and testing: true to keep a pre-1.0 release out of the default GPM channel.

Provider plugins vendor Composer: .gitignore lists only .DS_Store and .phpunit.cache/, and autoload requires vendor/autoload.php at runtime. Keep production dependencies tiny — the first-party providers vendor nothing but psr/http-message. Put test-only packages such as phpunit/phpunit and nyholm/psr7 in require-dev, set "platform-check": false, and match the base plugin's floor with "php": ">=8.3".

Mirror the base plugin's tab structure in your blueprint, translate through PLUGIN_<YOURPLUGIN>.* keys in languages/en.yaml, and ship a test/live toggle. Stripe uses test/live; Polar uses sandbox/production, because those are separate accounts with separate catalogs.

Warning

API keys and webhook secrets are password-type fields in your own config namespace. They support environment-variable interpolation so a merchant can keep them out of version control. Never log them, never echo them into rendered HTML, and never interpolate them into an error message.

Release checklist

  • Slug is lowercase, unique, and final; the provider does not register while unconfigured.
  • capabilities() lists exactly what is implemented.
  • initiatePayment() returns failed for API refusals and reserves throwing for the genuinely exceptional.
  • Amounts are minor units end to end, with no / 100 anywhere; JPY and KWD both work.
  • orderHash and transactionHash are on provider-side metadata, for every object your webhooks are about.
  • handleWebhook() verifies before it decodes, on raw bytes, with a replay window and a constant-time comparison, and fails closed when the secret is missing.
  • Unhandled event types return ignored(), not invalid().
  • Payloads carry transaction_hash, plus reason on failures and amount_minor on refunds.
  • Handlers are idempotent, including any side effect of your own.
  • The reference you report is one refund() can refund against, and an unsettled refund returns pending(), finished later by a webhook quoting that same reference.
  • Provider-initiated refunds issued in the provider's dashboard are handled.
  • No secret appears in any message, log line, or rendered field, with a test proving it.
  • composer install && vendor/bin/phpunit is green, and vendor/ is committed.
  • blueprints.yaml declares the kahunacart dependency and a Grav 2.0 compatibility range.
  • The README states the webhook URL, which events to subscribe to, how to test locally, and whether refunds are supported.
  • CHANGELOG has a dated entry and the version matches blueprints.yaml.

Copy the reference implementation whose flow style matches yours. The contract lists what each one demonstrates.