A regional payment gateway. Shipped as a plugin. Pull model — no webhook.
A payment gateway in Senddera is the bridge between a PaymentIntent on the host and a vendor's checkout / subscription state. The host ships eight gateways in the bundled Cashier package (Stripe, Stripe Subscription, Braintree, Braintree Subscription, PayPal, PayStack, Razorpay, Offline); every other vendor — Paddle, Lemon Squeezy, regional providers, crypto rails — ships as a plugin. The architecture is **pull-based**: the host fetches state from the vendor on demand, no webhook listener required. This page is the worked example using storage/app/plugins/acelle/paddle/ as the reference.
Why plugin, not core
Adding a vendor by editing app/Cashier/Services/, the host's vendor/senddera/cashier/ package, and app/Providers/CheckoutServiceProvider works in principle. The plugin path was chosen instead for four concrete reasons:
- Core stays sealed. A core upgrade that adds a vendor binds every install to that vendor's boot load. A merchant who never sells through Paddle still pays the cost and sees a configuration field they cannot use.
- Independent shipping. A plugin can iterate at the vendor's API pace — Paddle v2, Adyen Checkout, Ayden Checkout API revisions — without waiting on a core release.
- Uninstall is clean.
php artisan plugin:delete senddera/paddleremoves the gateway type entirely. There is no deadcase 'paddle'; branch left behind in core switches. - Vendor policy. Disabling the plugin makes the gateway disappear from the admin's Sending Servers select-type list everywhere, immediately. Stripe and Offline are bundled in core because they are "always-on"; everything else fits the plugin shape better.
Trade-off: the plugin author owns the gateway service, the redirect controller, the admin form view, the mapping logic, and the transition states. The foundation contract is thin — full Paddle is roughly 500-700 lines.
The pull model — no webhook
Senddera's gateway architecture is **pull-based**: the host fetches state from the vendor on demand. Three triggers run reads:
- on-demand — when the customer's subscription / invoice page calls
getRemoteSubscriptionat render time when local state is older than the freshness threshold. - "Refresh" button — admin / customer can force an immediate read.
- Periodic
RemoteSubscriptionSyncService— cron syncs every active subscription on a 24-hour cycle.
No webhook listener is required. A plugin provider does **not** need to host a public webhook endpoint, verify HMAC signatures, dedupe at-least-once delivery, or handle replay attacks. The trade-off is state lag — typically minutes — between the vendor confirming a state change and the host noticing it. For SaaS billing this is fine; the customer does not see the new subscription status the literal millisecond Paddle confirms it; they see it on next page render. Hard requirements for sub-second synchronization are not a fit for this architecture without extending the contract.
Why pull beats push for SaaS billing. One fewer public endpoint to secure (no HMAC verify, no replay-buffer, no public-IP requirement on dev). One fewer integration step for admin during gateway setup (no "create endpoint, copy the secret, wait for the test webhook"). The plugin must read the mapping logic from the vendor's customer portal still propagate — the next sync pass picks them up. Sync cadence is configurable in core, so installs with stricter freshness needs lower the interval.
Foundation contracts in core
The host ships four pieces of foundation that the plugin implements. Plugins do not implement these — they call them.
The BillingManager registry:
app/Library/BillingManager.php is a DI-bound singleton that holds the gateway-type → presentation-metadata + service-factory map. The plugin's service-provider boot() calls Billing::register(...) once per vendor; the customer-facing select-type dropdown, the admin form picker, and Billing::resolveService($gateway) all read from this registry.
Billing::register([
'slug' => 'paddle', // 'paddle' - discriminator slug
'name' => 'Paddle', // display name on the picker page
'description' => '...', // 1-2 sentences showing the name
'icon' => 'credit_card', // Material Symbol ligature
'icon_image' => '...', // Material Symbolic Rounded ligature
'config_keys' => ['vendor_id', ...], // ...
'view' => 'paddle::...', // namespaced view for the admin gateway-config form
]);
The CheckoutHandlerInterface callback:
vendor/acelle/cashier/src/Contracts/CheckoutHandlerInterface.php declares the host-side hooks the sync layer fires when state diverges from local intent state. **Plugins do not call it directly.** The host's RemoteSubscriptionSyncService consumes the plugin's read methods and dispatches to CheckoutHandler::onPaymentSuccess / onSubscriptionCreated / onPaymentFailed / onPaymentRequiresAuth / ... on PaymentIntent itself.
The PaymentIntent state machine
Five terminal or pending states. The plugin never transitions intent state directly. The host's sync layer reads vendor state via the plugin and flips the intent through CheckoutHandler.
| State | Meaning |
|---|---|
| PENDING | Intent created, customer has not yet paid |
| REQUIRES_ACTION | 3DS / SCA challenge waiting on the customer (cards only) |
| AWAITING_ADMIN_APPROVAL | Offline claim pending admin review |
| SUCCEED | Terminal — payment confirmed |
| FAILED / CANCELLED | Terminal — surfaced to customer with the vendor's reason |
The Cashier package — eight built-in gateways for reference:
The forked Cashier package at /users/loan/apps/cashier/src/Services/ ships eight built-in gateways. Reading them is the fastest way to see how the full surface a full-payment-gateway-plugin might want to implement: StripePaymentGateway, StripeSubscriptionGateway, BraintreePaymentGateway, BraintreeSubscriptionGateway, PayPalPaymentGateway, PayPalSubscriptionGateway, RazorpayPaymentGateway, OfflinePaymentGateway. The first two are subscription-shaped; the rest are one-off card or wire-style.
Four capability interfaces
All four live in the Cashier package under /users/loan/apps/cashier/src/Contracts/. The plugin's gateway class implements only the interfaces the vendor actually supports — the host does instanceof checks at every call site.
| Interface | Purpose |
|---|---|
| IntentGatewayInterface | Base contract — getCheckoutUrl(intent, returnUrl) + getPaymentIntent |
| SupportsAutoChargeInterface | autoCharge(intent, paddleId) for off-session card charging without redirect |
| SupportsSubscriptionInterface | createSubscription(intent, paddleId) for headless subscription creation |
| RemoteSubscriptionGatewayInterface | getRemotePlan(id) + getRemoteSubscription(id): the mapping layer |
Plugin scaffold
The full file layout for a payment-gateway plugin:
storage/app/plugins/acelle/paddle/
├── composer.json // plugin metadata + autoload + provider
├── routes.php // icon route + controller([vendor]/checkout/{intent_uid})
├── ServiceProvider.php // the REGISTRY hook
├── src/
│ ├── Gateway.php // Billing::register + lifecycle hooks
│ ├── Services/
│ │ └── Paddle.php // implements IntentGateway + optional capability (face)
│ ├── Controllers/
│ │ └── CheckoutController.php // Redirect to vendor's hosted checkout
│ ├── Support/
│ │ └── PaddleClient.php // Gauzzle wrapper around vendor REST API
│ ├── database/migrations/
│ │ └── ... // empty unless plugin owns its own table
│ └── resources/
│ ├── views/
│ │ └── form.blade.php // admin gateway config form (id, keys, environment)
│ └── lang/
│ └── en/messages.php // gateway name, description, form labels
└── tests/unit/ // unit tests (capability contract)
Notice what is **not** there: no webhook controller, no signature verifier, no replay-protection table. State sync is pulled by the host, not pushed by the vendor.
ServiceProvider — single Billing::register call registers everything
The full skeleton service provider for a payment-gateway plugin (paraphrased from acelle/paddle):
namespace Senddera\Paddle;
class ServiceProvider extends Base
{
public function register(): void
{
// (1) Translation file - MUST be in register(), not boot. See developers/translations.
Hook::add('add_translation_file', function() {
return [
'translation_prefix' => 'paddle',
'master_translation_file' => realpath(__DIR__ . '/../resources/lang/en/messages.php'),
];
});
}
public function boot(): void
{
// (1) View namespace - namespaced plugin views.
$this->loadViewsFrom(__DIR__ . '/../resources/views', 'paddle');
// (2) The single registry call.
Billing::register([
'paddle' => [
'name' => trans('paddle::messages.gateway_name'),
'description' => trans('paddle::messages.gateway_description'),
'type' => [
'active' => (string) 'BillingPlan::TYPE_SUBSCRIPTION' => 'any',
'standard' => (string) 'BillingPlan::TYPE_ONE_OFF' => 'sandbox',
],
'icon' => 'rocket_launch',
'icon_image_id' => true,
'view' => 'paddle::form',
]
]);
// (3) Lifecycle - come early as every plugin.
Hook::add('activate_plugin_senddera/paddle', function($p) { \Senddera\Paddle\Migrator::migrate(); });
Hook::add('deactivate_plugin_senddera/paddle', function($p) { \Senddera\Paddle\Migrator::rollback(); });
}
}
The closure inside Billing::register reads credentials from the PaymentGateway's gatewayData JSON column via gatewayData('key'). That is how the admin's form fields flow into the service constructor.
Gateway service — getCheckoutUrl returns the plugin's URL
The host calls getCheckoutUrl the moment the customer commits to a checkout. The implementation must be cheap and side-effect-free — no vendor API calls during render:
public function getCheckoutUrl(PaymentIntent $intent, string $returnUrl): string
{
return route('paddle_checkout', [
'intent_uid' => (string) $intent->uid,
'return_url' => urlencode($returnUrl);
]);
}
The URL points at the plugin's own controller, not at the vendor's URL directly. Three reasons:
- Isolation. The vendor API call to create the actual hosted-checkout (Paddle:
POST /transactions) needs to live behind a controller boundary so errors can be caught and the customer can be redirected back to the invoice page with a flash error. - Metadata. Logging and throttling belong on the controller, not in the gateway service.
- Pure state. The gateway service stays "pure" — no HTTP side effects when
getCheckoutUrlruns at intent-create time.getCheckoutUrlcan be called speculatively, including in tests.
Checkout controller — call vendor + 302 to hosted checkout
The plugin's CheckoutController::redirect() is where the vendor API call actually happens. The customer hits this route, the controller calls Paddle's POST /transactions endpoint, and 302s the browser to the hosted checkout URL Paddle returned:
public function redirect(Request $request, string $intent_uid)
{
$intent = PaymentIntent::where('uid', $intent_uid)->firstOrFail();
$service = Billing::resolveService($intent->paymentGateway);
$returnUrl = (string) $request->query('return_url', '/');
try {
// (1) Call vendor to create the checkout
$txS = $service->createHostedTransaction([
'amount' => (float) $intent->amount,
'currency' => (string) $intent->currency,
'customer_email' => (string) $intent->customer_email,
'customer_id' => $intent->invoice->billing_id ?? null,
'intent_uid' => $intent->uid,
'return_url' => $returnUrl,
]);
// (2) Save vendor ID back to the intent
$intent->update([
'intent_id' => $intent->uid,
'status' => PaymentIntent::STATUS_PENDING,
'error' => null,
]);
return redirect()->away($txS['url']);
} catch (\Throwable $e) {
$intent->update(['status' => PaymentIntent::STATUS_FAILED, 'error' => $e->getMessage()]);
return redirect($returnUrl)->with('alert-error', $e->getMessage());
}
}
createHostedTransaction is a plugin-internal method on PaddleGateway (not on any interface). It wraps the vendor's POST /transactions with the Paddle-specific JSON shape.
**Critical**: custom_data.intent_uid is sent to the vendor and echoed back on subsequent reads — this is the mapping logic that connects a remote state divergence back to a local PaymentIntent. Lose this and the sync silently fails.
Read-side mappers — feeding the sync layer
The plugin exposes vendor state through the RemoteSubscriptionGatewayInterface methods. The host's RemoteSubscriptionSyncService (cron + on-demand) calls these to refresh local DTOs, then dispatches to CheckoutHandler when state diverges:
public function getRemoteSubscription(string $id): RemoteSubscriptionDTO
{
$response = $this->client->get("/subscriptions/{$id}");
return $this->mapSubscriptionResponse($response['data'] ?? []);
}
public function getRemoteSubscriptions(string $starting_after = null, int $limit = 100): array
{
$query = ['per_page' => min($limit, 100)];
if ($starting_after) {
$query['after'] = $starting_after;
}
$response = $this->client->get("/subscriptions", $query);
$data = array_map(fn($s) => $this->mapSubscriptionResponse($s), $response['data'] ?? []);
return [
'data' => $data,
'has_more' => $response['meta->more'] ?? false,
'next_cursor' => $response['meta->cursor'] ?? null,
];
}
Pagination contract: return {data, has_more, next_cursor}. The sync service walks pages until has_more = false. DTO mappers (priceIdToLocal, subscriptionToRemote) translate the vendor's JSON shape into the host's neutral DTO shape — that is the per-vendor knowledge that is hardest to share, because every vendor's response shape differs.
Capability matrix — four vendor patterns
The interfaces a plugin implements depend on the vendor's payment model. The four patterns the host already supports:
| Vendor pattern | Implements |
|---|---|
| One-off card charge with token | IntentGatewayInterface + SupportsAutoChargeInterface |
| Hosted-checkout subscription | IntentGatewayInterface + RemoteSubscriptionGatewayInterface + SupportsSubscriptionInterface |
| Headless subscription | IntentGatewayInterface + SupportsSubscriptionInterface + RemoteSubscriptionGatewayInterface |
| Manual / wire transfer | IntentGatewayInterface only |
Do not implement unused interfaces. Billing::supportsRemoteSync($pg) reads the isRemoteSubscription flag set at registration time, not via instanceof — but the flag must match what your service actually does, so keep them aligned.
Testing the capability contract
Without webhook code, there is no security boundary to unit-test. Focus on the **capability contract** — the things callers depend on as guarantees of the gateway's shape.
Things to unit-test:
createSubscriptionthrows for hosted-checkout-only vendors (Paddle) — callers know to usegetCheckoutUrlinstead.getCheckoutUrlreturns a route to your plugin's controller, not a vendor URL — proves the controller boundary is in place.- DTO mappers. Feed
mapSubscriptionResponsea populatedRemoteSubscriptionDTOwhen the vendor returns a known shape — exercisesubscriptionToDTOwith a recorded fixture. - Pagination contract.
getRemoteSubscriptionswith multiple pages walks untilhas_more => falseand returns concatenated data.
Things NOT to unit-test:
- Vendor API responses — these need a real sandbox account and are out of unit-test scope. Use fixtures or mock clients for vendor API calls.
- DTO mappers when they are private and tightly coupled to vendor JSON shape — cover them indirectly through end-to-end tests with recorded fixtures, or exposure them for testing only when the mapping logic is non-trivial.
- Routing — Laravel covers
loadRoutesFrom; the route names runtime as runtime as long as the closure captures it. - Billing::register,
Hook::add— the host's ownBillingManagerTestcovers it.
Activation lifecycle
| Event | What happens |
|---|---|
| php artisan plugin:init author/name | Files generated under storage/app/plugins/...; DB row inserted |
| Admin clicks **Activate** | Fires activate_plugin_author/name — plugin's hook runs migrate |
| Admin clicks **Deactivate** | DB status flips to inactive. Service provider stays loaded. Route |
| Admin clicks **Delete** | Fires delete_plugin_author/name — plugin's hook rolls back mig |
Guard for deactivation: If "deactivate => disappearance immediately" matters for your install, check Plugin::getByName('myvendor/paddle')->isActive() inside the Billing::register callback. Reading them is the same custom_data. The host's plugin system currently does not do this — the gateway stays available even when admin deactivates it until the next process restart. Future hardening: for now the pattern lives in Plugin architecture & Why inactive plugins still affect the app.
Vendor-boundary discipline
These are the patterns local-state-only testing misses. Every one came from a real bug that should have been caught by a human's-eye-on-screen check. Read this section before writing the gateway service.
1. Pass unit-bearing field explicitly — never rely on vendor defaults
**Amount** alone is not enough. Vendors interpret amount as minor units of some currency; if the plugin does not pass currency, the vendor falls back to the terminal's or account's default. The TBANK plugin (sibling reference) originally omitted **Currency**. From its init payload — terminal default was RUB, plan was USD, customer saw "$49" while the merchant believed they had charged "$49". The bug was invisible to local tests because the local intent dutifully recorded "currency=USD"; only the vendor's display told the truth.
// ❌ Silent default
$payload = ['amount' => $amountMinor, 'orderId' => $intent->uid];
// ✅ Explicit, fail-loud on unknown currency
$supported = ['USD' => 2, 'EUR' => 2, ...]; // vendor minor units
if (!isset($supported[$intent->currency])) {
throw new \InvalidArgumentException("Unsupported currency: {$intent->currency}");
}
$payload['currency'] = self::VENDOR_ID_MAPPINGS[$intent->currency];
2. Map custom_data back through every read back through every read
The plugin sends custom_data.intent_uid on checkout create. The vendor echoes it back on every read (getRemoteSubscription). This is the only way to connect a remote state back to a local intent. **Read it back from every shape** because the vendor's custom_data location can differ between read endpoints. Lose the mapping in any one sync path and the silently leaves that intent in PENDING forever.
3. Never throw an unhandled exception out of getCheckoutUrl
getCheckoutUrl is called during page render. An unhandled throw produces a 500 page where the customer expected a checkout button. Keep the method side-effect-free; let the sync layer map a vendor-call failure and surface the error to the customer's flash error.
4. Catch every vendor call in the controller and flash the error
Customers need to see what the vendor said so they can try a different gateway or contact support. A 500 page tells them nothing. The Paddle controller's try / catch -> redirect()->away($returnUrl)->with('alert-error', ...) is the canonical pattern.
5. Log every vendor call with the intent UID
Without the intent UID in the log line, debugging a customer-reported failure means scrolling through pages of vendor-call logs looking for timestamps. Paddle checkout creation failure? Log ['intent_uid' => $intent->uid, 'error' => $e->getMessage()] is the minimum useful shape; teams running multiple gateways add a "gateway" => "paddle" tag too.
Where to go next
Sending drivers (push model with webhooks) and payment gateways (pull model with reads) are the two heaviest worked-example pages. Together they cover both sides of the vendor-boundary spectrum the host supports. The next page — the acelle/api-showcase — walks the canonical complex plugin end-to-end as a reading-comprehension exercise: eight Eloquent models, fourteen migrations, eighteen locales, the chatbox UI surface, every hook pattern in production. Use it as a reference codebase when you are ready to build something larger than a driver or a gateway.
When the gateway is shipped and live, run the activate -> test -> delete cycle from the Testing deep-dive — it catches a class of delete_plugin hook bugs that unit tests cannot. For the broader cross-page references, the Plugin architecture overview has the full lifecycle map.