Perché plugin, non core
Aggiungere un vendor modificando <code>app/Cashier/Services/</code>, il package in bundle <code>vendor/acelle/cashier/</code> e <code>app/Providers/CheckoutServiceProvider.php</code> funziona in linea di principio. La via plugin è stata scelta invece per quattro motivi concreti:
- Il core resta sigillato. Un upgrade core che aggiunge un vendor lega ogni install al costo di boot di quel vendor. Un merchant che non vende mai tramite Paddle paga comunque il costo e vede un campo di configurazione che non può usare.
- Rilascio indipendente. Un plugin può iterare al ritmo dell'API del vendor — Paddle Billing v2, Razorpay Routes, revisioni dell'Adyen Checkout API — senza aspettare una release del core.
- L'uninstall è pulito.
php artisan plugin:delete acelle/paddle rimuove completamente il tipo di gateway. Non resta alcun ramo case 'paddle': morto negli switch del core.
- Policy per-tenant. Disabilitare il plugin fa sparire il gateway dalla lista select-type Sending Servers dell'admin ovunque, immediatamente. Stripe e Offline sono in bundle nel core perché sono "always-on"; tutto il resto si adatta meglio alla forma plugin.
Trade-off: l'autore del plugin possiede il gateway service, il controller di redirect, la view del form admin e i mapper read-side (getRemotePlan / getRemoteSubscription / getRemotePaymentMethod). I contratti di fondazione lo rendono piccolo — Paddle completo è all'incirca 500-700 righe.
Il modello pull — no webhook
L'architettura gateway di AcelleMail è pull-based: l'host pesca lo stato dal vendor on demand. Tre trigger eseguono read:
- Lazy fetch al page-load — la pagina subscription / invoice del cliente chiama
getRemoteSubscription al momento del render quando lo stato locale è più vecchio della soglia di freschezza.
- Pulsante "Refresh" — admin / cliente possono forzare una lettura immediata.
- Cron periodico
RemoteSubscriptionSyncService — passa in rassegna ogni subscription remota attiva con una cadenza configurabile.
Nessun webhook listener è richiesto. Gli autori di plugin non devono ospitare un endpoint webhook pubblico, verificare firme HMAC, deduplicare consegne at-least-once o gestire replay attack. Il trade-off è il lag di stato — tipicamente minuti — tra il vendor che conferma un cambio di stato e l'host che se ne accorge. Per il billing SaaS questo va bene: il cliente non vede il nuovo status di subscription nel microsecondo letterale in cui Paddle lo conferma; lo vede al prossimo render di pagina. Requisiti hard per propagazione sub-secondo non sono adatti a questa architettura senza estendere il contratto.
Perché pull batte push per il billing SaaS. Un endpoint pubblico in meno da mettere in sicurezza (no verifica HMAC, nessuna finestra di replay, nessun requisito di IP pubblico in dev). Uno step di integrazione in meno per l'admin durante il setup del gateway (niente balletto "crea endpoint, copia il secret, aspetta il webhook di test"). Cancellazioni e cambi piano provenienti dal customer portal del vendor si propagano comunque — il prossimo sync pass li prende. La cadenza di sync è configurabile nel core, quindi gli install con requisiti di freschezza più stretti abbassano l'intervallo.
Contratti di fondazione nel core
L'host fornisce quattro pezzi di fondazione che il plugin consuma. I plugin non li implementano — li chiamano.
Il registry BillingManager
app/Library/BillingManager.php è un singleton bound via DI che mantiene la mappa gateway-type → presentation-metadata + service-factory. Il boot() del service provider del plugin chiama Billing::register(...) una volta per vendor; la dropdown select-type customer-facing, il picker del form admin e Billing::resolveService($gateway) leggono tutti da questo 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
);
Il callback CheckoutHandlerInterface
vendor/acelle/cashier/src/Contracts/CheckoutHandlerInterface.php dichiara i callback host-side che il sync layer lancia quando lo stato remoto diverge dallo stato di intent locale. I plugin non lo chiamano direttamente. Il RemoteSubscriptionSyncService dell'host consuma i metodi di lettura del plugin e fa dispatch lui stesso verso CheckoutHandler::onPaymentSuccess / onSubscriptionCreated / onPaymentFailed / onPaymentRequiresAuth / onOfflineClaimReceived.
La state machine PaymentIntent
Cinque stati terminal-or-pending; il plugin non transitiona mai lo stato di intent direttamente. Il sync layer dell'host legge lo stato del vendor tramite il plugin e ribalta l'intent tramite CheckoutHandler.
| Stato | Significato |
PENDING | Intent creato, il cliente non ha ancora pagato |
REQUIRES_ACTION | Sfida 3DS / SCA in attesa del cliente (solo card) |
AWAITING_ADMIN_APPROVAL | Claim offline in attesa di revisione admin |
SUCCEEDED | Terminale — pagamento confermato |
FAILED / CANCELLED | Terminale — mostrato al cliente con la ragione del vendor |
Il package Cashier — otto gateway built-in di riferimento
Il fork del package Cashier in /Users/luan/apps/cashier/src/Services/ fornisce otto implementazioni gateway built-in. Leggerle è il modo più rapido per vedere la superficie completa che un plugin payment-gateway potrebbe voler implementare: StripePaymentGateway, StripeSubscriptionGateway, BraintreePaymentGateway, BraintreeSubscriptionGateway, PaypalPaymentGateway, PaystackPaymentGateway, RazorpayPaymentGateway, OfflinePaymentGateway. I primi due sono subscription-shaped; il resto sono one-off card o wire-style.
Quattro capability interface
Tutte e quattro vivono nel package Cashier sotto /Users/luan/apps/cashier/src/Contracts/. La classe gateway del plugin implementa solo le interface che il suo vendor supporta davvero — l'host fa check instanceof in ogni call site.
| Interface | Scopo | Richiesto per |
IntentGatewayInterface |
Contratto base — getCheckoutUrl(intent, returnUrl) + getMethodTitle/getMethodInfo per la display del payment method salvato |
Ogni gateway |
SupportsAutoChargeInterface |
autoCharge(intent, pmData) per addebito card off-session senza redirect |
Gateway con re-bill one-tap (Stripe one-off; Paddle non) |
SupportsSubscriptionInterface |
createSubscription(intent, pmData) per la creazione di subscription headless |
Gateway che supportano un flow di subscription headless (Stripe Subscription); il createSubscription di Paddle lancia perché Paddle è solo hosted-checkout |
RemoteSubscriptionGatewayInterface |
getRemotePlans / getRemoteSubscription / getRemoteSubscriptions / cancelRemoteSubscription / updateRemoteSubscriptionPlan / getRemotePaymentMethod — il lato read/sync |
Gateway dove il vendor possiede lo stato di subscription (Paddle, Stripe Subscription) |
Scaffold del plugin
Il file layout completo per un plugin payment-gateway:
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)
Nota cosa non c'è: nessun controller webhook, nessun verificatore di signature, nessuna tabella di replay-protection. Il sync di stato è pulled dall'host, non pushed dal vendor.
ServiceProvider — una sola chiamata Billing::register registra tutto
Lo scheletro completo del service provider per un plugin payment-gateway (parafrasato da 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,
]));
}
}
La closure dentro Billing::register legge le credenziali dalla colonna JSON gatewayData del PaymentGateway tramite getGatewayData('key'). È così che i campi del form admin fluiscono nel costruttore del service.
Gateway service — getCheckoutUrl restituisce l'URL del plugin
L'host chiama getCheckoutUrl nel momento in cui il cliente si impegna a un checkout. L'implementazione deve essere economica e side-effect-free — nessuna chiamata API al vendor durante il render:
public function getCheckoutUrl(PaymentIntent $intent, string $returnUrl): string
{
return route('paddle.checkout', ['intent_uid' => $intent->uid])
. '?return_url=' . urlencode($returnUrl);
}
L'URL punta al controller del plugin, non direttamente all'URL del vendor. Tre motivi:
- La chiamata API al vendor per creare l'hosted-checkout effettivo (Paddle:
POST /transactions) deve vivere dietro un boundary di controller così che gli errori possano essere catturati e il cliente possa essere redirettato alla pagina invoice con un flash di errore.
- Logging e throttling stanno sul controller, non nel gateway service.
- Il gateway service resta "puro" — nessun side effect HTTP quando
getCheckoutUrl gira al momento di creazione dell'intent. getCheckoutUrl può essere chiamato speculativamente, inclusi i test.
Controller di checkout — chiamata vendor + 302 verso l'hosted checkout
Il CheckoutController::redirect() del plugin è dove la chiamata API al vendor avviene davvero. Il cliente colpisce /cashier/paddle/checkout/{uid}, il controller chiama l'endpoint Paddle POST /transactions e fa 302 del browser verso l'URL hosted checkout che Paddle ha restituito:
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 è un metodo interno al plugin su PaddleGateway (non su alcuna interface). Avvolge il POST /transactions del vendor con la forma JSON specifica di Paddle.
Critico: custom_data.intent_uid viene inviato al vendor e rispedito nelle letture successive — GET /subscriptions/{id} restituisce lo stesso custom_data. È così che il sync layer mappa una subscription Paddle su un PaymentIntent locale. Perdilo e il sync fallisce silenziosamente.
Mapper read-side — alimentare il sync layer
Il plugin espone lo stato del vendor tramite i metodi di RemoteSubscriptionGatewayInterface. Il RemoteSubscriptionSyncService dell'host (cron + on-demand) li chiama per refresh-are i DTO locali, poi fa dispatch verso CheckoutHandler quando lo stato diverge:
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,
];
}
Contratto di paginazione: restituisci {data, has_more, next_cursor}. Il sync service cammina le pagine fino a has_more => false. I mapper DTO (priceToDto, subscriptionToDto) traducono la forma JSON del vendor nella forma DTO neutrale dell'host — è la conoscenza per-vendor più difficile da condividere, perché la forma di risposta di ogni vendor differisce.
Capability matrix — quattro pattern di vendor
Le interface che un plugin implementa dipendono dal modello di pagamento del vendor. I quattro pattern che l'host supporta già:
| Pattern vendor | Implementa | Esempi |
| Addebito one-off con card token |
IntentGatewayInterface + SupportsAutoChargeInterface |
Stripe one-off, Square, Razorpay one-off |
| Subscription con hosted-checkout |
IntentGatewayInterface + RemoteSubscriptionGatewayInterface + SupportsSubscriptionInterface (createSubscription lancia) |
Paddle, Lemon Squeezy |
| Subscription headless |
IntentGatewayInterface + SupportsSubscriptionInterface + RemoteSubscriptionGatewayInterface |
Stripe Subscription, Braintree Subscription |
| Manuale / bonifico bancario |
Solo IntentGatewayInterface |
Offline (bonifico, contanti) |
Non implementare interface inutilizzate. Billing::supportsRemoteSubscription($gw) legge il flag isRemoteSubscription impostato al momento della registrazione, non tramite instanceof — ma il flag deve combaciare con ciò che il tuo service fa davvero, quindi tienili allineati.
Testare il contratto delle capability
Senza codice webhook, non c'è alcun boundary di sicurezza da unit-testare. Concentrati sul contratto delle capability — le cose che i caller danno per garantite sulla forma del gateway.
Cose da unit-testare
createSubscription lancia per vendor solo hosted-checkout (Paddle) — i caller sanno di usare getCheckoutUrl invece.
getCheckoutUrl restituisce una route verso il controller del tuo plugin, non un URL del vendor — prova che il boundary di controller è al suo posto.
getRemoteSubscription restituisce un RemoteSubscriptionDTO popolato quando il vendor restituisce una forma nota — esercita subscriptionToDto con una fixture registrata.
- Contratto di paginazione —
getRemoteSubscriptions con più pagine cammina fino a has_more => false e restituisce i dati concatenati.
Cose da non unit-testare
- Risposte live dell'API vendor — richiedono un account sandbox reale e sono fuori scope per i unit test. Verifica live prima del rilascio (curl degli endpoint con una chiave sandbox).
- Mapper DTO quando sono privati e tightly coupled alla forma JSON del vendor — coprili indirettamente tramite test end-to-end con fixture registrate, o esponili solo per il test quando la logica di mapping è non banale.
- Routing — Laravel copre
loadRoutesFrom; il route name si risolve a runtime finché la closure lo cattura.
- Happy path di
Billing::register — il BillingManagerTest dell'host stesso lo copre.
Registra la testsuite del plugin nel phpunit.xml dell'host come da deep-dive Testing: <testsuite name="Plugin: acelle/paddle"> ... </testsuite>. Lancia la suite con ./vendor/bin/pest --testsuite="Plugin: acelle/paddle".
Lifecycle di attivazione
| Evento | Cosa succede |
php artisan plugin:init author/name | File generati sotto storage/app/plugins/.... Riga DB inserita con status=inactive. Service provider auto-caricato. |
| L'admin clicca Activate | Lancia activate_plugin_author/name → l'hook del plugin esegue le migration. Lo status DB passa ad active. Il gateway è ora visibile nel dropdown select-type. |
| L'admin clicca Deactivate | Lo status DB passa a inactive. Il service provider resta caricato. Le route si risolvono comunque. Il tipo di gateway è ancora in BillingManager fino al prossimo boot di processo. |
| L'admin clicca Delete | Lancia delete_plugin_author/name → l'hook del plugin fa rollback delle migration. File rimossi, riga DB cancellata, entry del master file pulita. |
Gate per la semantica deactivate: se "deactivate = gateway sparisce immediatamente" conta per il tuo install, controlla Plugin::getByName('myvendor/myplugin')->isActive() dentro la closure service-factory di Billing::register e lancia se no. Il plugin in bundle acelle/paddle attualmente non lo fa — il gateway resta disponibile anche quando un admin lo deattiva (fino al prossimo restart di processo). Hardening futuro; per ora il pattern vive in Plugin architecture § Perché i plugin inattivi influenzano comunque l'app.
Disciplina del confine col vendor
Questi sono i pattern che i test solo-stato-locale mancano. Ognuno è arrivato da un bug reale rilasciato prima di essere intercettato da un check humans-eyes-on-screen. Leggi questa sezione prima di scrivere il gateway service.
1. Passa esplicitamente i campi con unità — non affidarti mai ai default del vendor
Amount da solo non basta. I vendor interpretano amount come minor units di qualche valuta; se il plugin non passa Currency, il vendor ricade sul default del terminale o dell'account. Il plugin TBANK (riferimento sibling) originariamente ometteva Currency dal suo payload Init — il default del terminale era RUB, il piano era USD, il cliente vedeva "₽49" mentre il merchant credeva di aver addebitato "$49". Il bug era invisibile alle asserzioni DB locali perché l'intent locale registrava diligentemente currency='USD'; solo la visualizzazione del vendor diceva la verità.
// ❌ 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. Mappa custom_data in tutti gli endpoint di lettura
Il plugin invia custom_data.intent_uid alla creazione del checkout. Il vendor lo rispedisce in ogni lettura correlata — dettaglio subscription, dettaglio transaction, dettaglio payment-method. Il plugin deve rileggerlo da ogni forma perché la posizione di custom_data del vendor può differire tra endpoint di lettura. Perdi il mapping in uno solo e il sync lascia silenziosamente quell'intent in PENDING per sempre.
3. Non lanciare mai un'eccezione non gestita fuori da getCheckoutUrl
getCheckoutUrl viene chiamato durante il render della pagina. Un throw non gestito produce una pagina 500 dove il cliente si aspettava un pulsante di checkout. Mantieni il metodo side-effect free; lascia che il controller gestisca i fallimenti delle chiamate al vendor e mostri l'errore nella flash session del cliente.
4. Cattura ogni chiamata al vendor nel controller e fai flash dell'errore
I clienti devono vedere ciò che ha detto il vendor così possono scegliere un gateway diverso o contattare il supporto. Una pagina 500 non dice nulla. Il try / catch + redirect()->away($returnUrl)->with('alert-error', ...) del controller Paddle è il pattern canonico.
5. Logga ogni chiamata al vendor con l'intent UID
Senza l'intent UID nella riga di log, fare debug su un fallimento riportato dal cliente significa scrollare centinaia di righe di log non correlate. \Log::error('Paddle checkout: createTransaction failed', ['intent_uid' => $intent->uid, 'error' => $e->getMessage()]) è la forma minima utile; i team che gestiscono più gateway aggiungono anche un tag 'gateway' => 'paddle'.
Dove andare ora
Sending driver (modello push con webhook) e payment gateway (modello pull con read) sono le due pagine di esempio pratico più pesanti. Insieme coprono entrambi i lati dello spettro del confine col vendor che l'host supporta oggi. La prossima pagina — lo showcase acelle/ai — percorre il plugin complesso canonico end-to-end come esercizio di lettura: otto model Eloquent, quattordici migration, diciotto locale, la superficie UI chatbox e ogni pattern di hook in produzione. Usalo come codebase di riferimento quando sei pronto a costruire qualcosa più grande di un driver o di un gateway.
Quando il gateway è rilasciato e live, esegui il ciclo activate → test → delete dal deep-dive Testing — intercetta una classe di bug degli hook delete_plugin_* che i unit test non possono. Per i riferimenti cross-page più ampi, l'overview Plugin architecture ha la mappa completa del lifecycle.