Why plugin, not core
Adding a vendor by editing app/Cashier/Services/, the bundled vendor/acelle/cashier/ package, and app/Providers/CheckoutServiceProvider.php 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 cost. 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 Billing v2, Razorpay Routes, Adyen Checkout API revisions — without waiting on a core release.
- Uninstall is clean.
php artisan plugin:delete acelle/paddle removes the gateway type entirely. There is no dead case 'paddle': branch left behind in core switches.
- Per-tenant 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, and the read-side mappers (getRemotePlan / getRemoteSubscription / getRemotePaymentMethod). The foundation contracts make this small — full Paddle is roughly 500-700 lines.
The pull model — no webhook
AcelleMail's gateway architecture is pull-based: the host fetches state from the vendor on demand. Three triggers run reads:
- Page-load lazy fetch — the customer's subscription / invoice page calls
getRemoteSubscription at render time when local state is older than the freshness threshold.
- "Refresh" button — admin / customer can force an immediate read.
- Periodic
RemoteSubscriptionSyncService cron — sweeps every active remote subscription on a configurable cadence.
No webhook listener is required. Plugin authors do 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 microsecond Paddle confirms it; they see it on next page render. Hard requirements for sub-second propagation 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 window, 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" dance). Cancellations and plan changes coming 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 consumes. 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(
string $type, // 'paddle' — discriminator slug
string $name, // shown in admin select-type
string $description, // 1-2 sentences shown alongside the name
\Closure $serviceFactory, // fn(PaymentGateway $gw): IntentGatewayInterface
string $icon = 'payment', // Material Symbols Rounded ligature
bool $isRemoteSubscription = false,
string $formView = '', // namespaced blade view for the admin gateway-config form
);
The CheckoutHandlerInterface callback
vendor/acelle/cashier/src/Contracts/CheckoutHandlerInterface.php declares the host-side callbacks the sync layer fires when remote 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 / onOfflineClaimReceived 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 paid yet |
REQUIRES_ACTION | 3DS / SCA challenge waiting on the customer (cards only) |
AWAITING_ADMIN_APPROVAL | Offline claim pending admin review |
SUCCEEDED | 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/luan/apps/cashier/src/Services/ ships eight built-in gateway implementations. Reading them is the fastest way to see the full surface a payment-gateway plugin might want to implement: StripePaymentGateway, StripeSubscriptionGateway, BraintreePaymentGateway, BraintreeSubscriptionGateway, PaypalPaymentGateway, PaystackPaymentGateway, 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/luan/apps/cashier/src/Contracts/. The plugin's gateway class implements only the interfaces its vendor actually supports — the host does instanceof checks at every callsite.
| Interface | Purpose | Required for |
IntentGatewayInterface |
Base contract — getCheckoutUrl(intent, returnUrl) + getMethodTitle/getMethodInfo for saved-method display |
Every gateway |
SupportsAutoChargeInterface |
autoCharge(intent, pmData) for off-session card charging without redirect |
Gateways with one-tap re-bill (Stripe one-off; Paddle does not) |
SupportsSubscriptionInterface |
createSubscription(intent, pmData) for headless subscription create |
Gateways that support a headless subscription flow (Stripe Subscription); Paddle's createSubscription throws because Paddle is hosted-checkout-only |
RemoteSubscriptionGatewayInterface |
getRemotePlans / getRemoteSubscription / getRemoteSubscriptions / cancelRemoteSubscription / updateRemoteSubscriptionPlan / getRemotePaymentMethod — the read/sync side |
Gateways where the vendor owns subscription state (Paddle, Stripe Subscription) |
Plugin scaffold
The full file layout for a payment-gateway plugin:
storage/app/plugins/{author}/{name}/
├── composer.json ← plugin metadata + autoload + provider
├── routes.php ← icon route + /cashier/{vendor}/checkout/{intent_uid}
├── icon.svg ← admin Plugins page icon
├── src/
│ ├── ServiceProvider.php ← Billing::register + lifecycle hooks
│ ├── Services/
│ │ └── {Vendor}Gateway.php ← implements IntentGateway[+ optional capability ifaces]
│ ├── Controllers/
│ │ └── {Vendor}CheckoutController.php ← redirect to vendor's hosted checkout
│ └── Support/
│ └── {Vendor}Api.php ← Guzzle wrapper around vendor REST API
├── database/migrations/ ← empty unless plugin owns its own table
├── resources/
│ ├── views/
│ │ └── form.blade.php ← admin gateway-config form (api 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 Acelle\Paddle;
class ServiceProvider extends Base
{
public function register(): void
{
// Translation file — MUST be in register(), not boot. See /developers/translations.
Hook::add('add_translation_file', fn () => [
'translation_prefix' => 'paddle',
'master_translation_file' => realpath(__DIR__.'/../resources/lang/en/messages.php'),
// ...
]);
}
public function boot(): void
{
$this->loadViewsFrom(__DIR__.'/../resources/views', 'paddle');
$this->loadRoutesFrom(__DIR__.'/../routes.php');
// The single registry call.
Billing::register(
'paddle',
trans('paddle::messages.gateway.name'),
trans('paddle::messages.gateway.description'),
fn ($gw) => new Services\PaddleGateway(
apiKey: (string) $gw->getGatewayData('api_key'),
environment: (string) ($gw->getGatewayData('environment') ?: 'sandbox'),
),
icon: 'rocket_launch',
isRemoteSubscription: true,
formView: 'paddle::form',
);
// Lifecycle — same pattern as every plugin.
Hook::on('activate_plugin_acelle/paddle', fn () => \Artisan::call('migrate', [
'--path' => 'storage/app/plugins/acelle/paddle/database/migrations',
'--force' => true,
]));
Hook::on('delete_plugin_acelle/paddle', fn () => \Artisan::call('migrate:rollback', [
'--path' => 'storage/app/plugins/acelle/paddle/database/migrations',
'--force' => true,
]));
}
}
The closure inside Billing::register reads credentials from the PaymentGateway's gatewayData JSON column via getGatewayData('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' => $intent->uid])
. '?return_url=' . urlencode($returnUrl);
}
The URL points at the plugin's own controller, not at the vendor's URL directly. Three reasons:
- 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.
- Logging and throttling belong on the controller, not in the gateway service.
- The gateway service stays "pure" — no HTTP side effects when
getCheckoutUrl runs at intent-create time. getCheckoutUrl can 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 /cashier/paddle/checkout/{uid}, 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 $intentUid)
{
$intent = PaymentIntent::where('uid', $intentUid)->firstOrFail();
$service = Billing::resolveService($intent->paymentGateway);
$returnUrl = (string) $request->query('return_url', url('/'));
try {
$tx = $service->createCheckoutTransaction(
intentUid: $intent->uid,
currency: (string) $intent->currency,
amountMajor: (float) $intent->amount,
customerEmail: $intent->invoice->billing_email,
remotePriceId: $intent->metadata['remote_plan_id'] ?? null,
returnUrl: $returnUrl,
);
} catch (\Throwable $e) {
\Log::error('Paddle checkout: createTransaction failed', [
'intent_uid' => $intent->uid,
'error' => $e->getMessage(),
]);
return redirect()->away($returnUrl)
->with('alert-error', trans('paddle::messages.checkout.create_failed', [
'error' => $e->getMessage(),
]));
}
return redirect()->away($tx['data']['checkout']['url']);
}
createCheckoutTransaction 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 — GET /subscriptions/{id} returns the same custom_data. That is how the sync layer maps a Paddle subscription 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 $remoteSubId): RemoteSubscriptionDTO
{
$response = $this->api->get("/subscriptions/{$remoteSubId}");
return $this->subscriptionToDto($response['data'] ?? []);
}
public function getRemoteSubscriptions(?string $startingAfter = null, int $limit = 100): array
{
$query = ['per_page' => min($limit, 200)];
if ($startingAfter) {
$query['after'] = $startingAfter;
}
$response = $this->api->get('/subscriptions', $query);
return [
'data' => array_map([$this, 'subscriptionToDto'], $response['data'] ?? []),
'has_more' => $response['has_more'] ?? false,
'next_cursor' => $response['next_cursor'] ?? null,
];
}
Pagination contract: return {data, has_more, next_cursor}. The sync service walks pages until has_more => false. DTO mappers (priceToDto, subscriptionToDto) 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 | Examples |
| One-off card charge with token |
IntentGatewayInterface + SupportsAutoChargeInterface |
Stripe one-off, Square, Razorpay one-off |
| Hosted-checkout subscription |
IntentGatewayInterface + RemoteSubscriptionGatewayInterface + SupportsSubscriptionInterface (createSubscription throws) |
Paddle, Lemon Squeezy |
| Headless subscription |
IntentGatewayInterface + SupportsSubscriptionInterface + RemoteSubscriptionGatewayInterface |
Stripe Subscription, Braintree Subscription |
| Manual / wire transfer |
IntentGatewayInterface only |
Offline (bank transfer, cash) |
Do not implement unused interfaces. Billing::supportsRemoteSubscription($gw) 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
createSubscription throws for hosted-checkout-only vendors (Paddle) — callers know to use getCheckoutUrl instead.
getCheckoutUrl returns a route to your plugin's controller, not a vendor URL — proves the controller boundary is in place.
getRemoteSubscription returns a populated RemoteSubscriptionDTO when the vendor returns a known shape — exercise subscriptionToDto with a recorded fixture.
- Pagination contract —
getRemoteSubscriptions with multiple pages walks until has_more => false and returns concatenated data.
Things not to unit-test
- Vendor API responses live — these need a real sandbox account and are out of unit-test scope. Verify live before shipping (curl the endpoints with a sandbox key).
- 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 expose them for testing only when the mapping logic is non-trivial.
- Routing — Laravel covers
loadRoutesFrom; the route name resolves at runtime as long as the closure captures it.
Billing::register happy path — the host's own BillingManagerTest covers it.
Register the plugin's testsuite in the host's phpunit.xml per the Testing deep-dive: <testsuite name="Plugin: acelle/paddle"> ... </testsuite>. Run the suite with ./vendor/bin/pest --testsuite="Plugin: acelle/paddle".
Activation lifecycle
| Event | What happens |
php artisan plugin:init author/name | Files generated under storage/app/plugins/.... DB row inserted with status=inactive. Service provider auto-loaded. |
| Admin clicks Activate | Fires activate_plugin_author/name → plugin's hook runs migrations. DB status flips to active. Gateway is now visible in the select-type dropdown. |
| Admin clicks Deactivate | DB status flips to inactive. Service provider stays loaded. Routes still resolve. The gateway type is still in BillingManager until next process boot. |
| Admin clicks Delete | Fires delete_plugin_author/name → plugin's hook rolls back migrations. Files removed, DB row deleted, master-file entry cleared. |
Guard for deactivate semantics: if "deactivate = gateway disappears immediately" matters for your install, check Plugin::getByName('myvendor/myplugin')->isActive() inside the Billing::register service-factory closure and throw if not. The bundled acelle/paddle plugin currently does not do this — the gateway stays available even when an 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 shipped before being caught by a humans-eyes-on-screen check. Read this section before writing the gateway service.
1. Pass unit-bearing fields 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 DB assertions because the local intent dutifully recorded currency='USD'; only the vendor's display told the truth.
// ❌ Silent default
$payload = ['Amount' => $amountMinor, 'OrderId' => $intentUid];
// ✅ Explicit, fail-loud on unknown currency
private const ISO4217_NUMERIC = ['RUB' => '643', 'USD' => '840', 'EUR' => '978', /* ... */];
if (!isset(self::ISO4217_NUMERIC[$iso = strtoupper($currency)])) {
throw new \InvalidArgumentException(
"Unsupported currency '{$currency}'. Supported: " .
implode(', ', array_keys(self::ISO4217_NUMERIC))
);
}
$payload['Currency'] = self::ISO4217_NUMERIC[$iso];
2. Map custom_data back through every read endpoint
The plugin sends custom_data.intent_uid on checkout create. The vendor echoes it back on every related read — subscription detail, transaction detail, payment-method detail. The plugin must read it back from each shape because the vendor's custom_data location can differ between read endpoints. Lose the mapping in any one and the sync 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 controller handle vendor-call failures and surface the error to the customer's flash session.
4. Catch every vendor call in the controller and flash the error
Customers need to see what the vendor said so they can pick 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 hundreds of unrelated log lines. \Log::error('Paddle checkout: createTransaction failed', ['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 today. The next page — the acelle/ai 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.