Pourquoi plugin, pas core
Ajouter un vendor en éditant <code>app/Cashier/Services/</code>, le package <code>vendor/acelle/cashier/</code> inclus et <code>app/Providers/CheckoutServiceProvider.php</code> fonctionne en principe. Le chemin plugin a été choisi à la place pour quatre raisons concrètes :
- Le core reste scellé. Un upgrade core qui ajoute un vendor lie chaque install au coût de boot de ce vendor. Un marchand qui ne vend jamais via Paddle paie tout de même le coût et voit un champ de configuration qu'il ne peut pas utiliser.
- Livraison indépendante. Un plugin peut itérer au rythme de l'API du vendor — Paddle Billing v2, Razorpay Routes, révisions Adyen Checkout API — sans attendre une release du core.
- La désinstallation est propre.
php artisan plugin:delete acelle/paddle retire le type de passerelle entièrement. Il ne reste pas de branche morte case 'paddle': dans les switchs du core.
- Politique par tenant. Désactiver le plugin fait disparaître la passerelle de la liste select-type Sending Servers de l'admin partout, immédiatement. Stripe et Offline sont inclus dans le core parce qu'ils sont « toujours actifs » ; tout le reste rentre mieux dans la forme plugin.
Compromis : l'auteur du plugin possède le service de passerelle, le contrôleur de redirect, la vue de formulaire admin, et les mappers côté lecture (getRemotePlan / getRemoteSubscription / getRemotePaymentMethod). Les contrats fondateurs rendent cela petit — Paddle complet fait environ 500-700 lignes.
Le modèle pull — pas de webhook
L'architecture de passerelle de AcelleMail est pull-based : l'hôte récupère l'état depuis le vendor à la demande. Trois déclencheurs lancent des lectures :
- Récupération paresseuse au chargement de page — la page abonnement / facture du client appelle
getRemoteSubscription au moment du rendu lorsque l'état local est plus ancien que le seuil de fraîcheur.
- Bouton « Refresh » — admin / client peut forcer une lecture immédiate.
- Cron périodique
RemoteSubscriptionSyncService — balaie chaque abonnement distant actif à une cadence configurable.
Aucun écouteur webhook n'est requis. Les auteurs de plugins n'ont pas besoin d'héberger un endpoint webhook public, de vérifier les signatures HMAC, de dédupliquer la livraison at-least-once, ou de gérer les replay attacks. Le compromis est le décalage d'état — typiquement quelques minutes — entre le vendor confirmant un changement d'état et l'hôte le remarquant. Pour la facturation SaaS, c'est acceptable : le client ne voit pas le nouveau statut d'abonnement à la microseconde près où Paddle le confirme ; il le voit au prochain rendu de page. Les exigences strictes de propagation infra-seconde ne sont pas adaptées à cette architecture sans étendre le contrat.
Pourquoi pull bat push pour la facturation SaaS. Un endpoint public en moins à sécuriser (pas de vérif HMAC, pas de fenêtre de replay, pas d'exigence d'IP publique en dev). Une étape d'intégration en moins pour l'admin lors du setup de la passerelle (pas de « créer l'endpoint, copier le secret, attendre le webhook de test »). Les annulations et changements de plan venant du portail client du vendor se propagent toujours — la prochaine passe de sync les capte. La cadence de sync est configurable dans le core, donc les installs avec des besoins de fraîcheur plus stricts baissent l'intervalle.
Contrats fondateurs dans le core
L'hôte livre quatre pièces de fondation que le plugin consomme. Les plugins ne les implémentent pas — ils les appellent.
Le registre BillingManager
app/Library/BillingManager.php est un singleton lié au DI qui contient la map type-de-passerelle → métadonnées-de-présentation + factory-de-service. Le boot() du ServiceProvider du plugin appelle Billing::register(...) une fois par vendor ; la dropdown select-type côté client, le picker de formulaire admin et Billing::resolveService($gateway) lisent tous depuis ce registre.
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
);
Le callback CheckoutHandlerInterface
vendor/acelle/cashier/src/Contracts/CheckoutHandlerInterface.php déclare les callbacks côté hôte que la couche de sync déclenche quand l'état distant diverge de l'état intent local. Les plugins ne l'appellent pas directement. Le RemoteSubscriptionSyncService de l'hôte consomme les méthodes de lecture du plugin et dispatche lui-même vers CheckoutHandler::onPaymentSuccess / onSubscriptionCreated / onPaymentFailed / onPaymentRequiresAuth / onOfflineClaimReceived.
La machine à états PaymentIntent
Cinq états terminaux ou en attente ; le plugin ne transitionne jamais directement l'état d'intent. La couche de sync de l'hôte lit l'état vendor via le plugin et bascule l'intent via CheckoutHandler.
| État | Signification |
PENDING | Intent créé, le client n'a pas encore payé |
REQUIRES_ACTION | Challenge 3DS / SCA en attente du client (cartes uniquement) |
AWAITING_ADMIN_APPROVAL | Réclamation offline en attente de revue admin |
SUCCEEDED | Terminal — paiement confirmé |
FAILED / CANCELLED | Terminal — affiché au client avec la raison du vendor |
Le package Cashier — huit passerelles intégrées en référence
Le package Cashier forké à /Users/luan/apps/cashier/src/Services/ livre huit implémentations de passerelle intégrées. Les lire est le moyen le plus rapide de voir la surface complète qu'un plugin de passerelle de paiement pourrait vouloir implémenter : StripePaymentGateway, StripeSubscriptionGateway, BraintreePaymentGateway, BraintreeSubscriptionGateway, PaypalPaymentGateway, PaystackPaymentGateway, RazorpayPaymentGateway, OfflinePaymentGateway. Les deux premiers sont en forme d'abonnement ; les autres sont des paiements ponctuels par carte ou de type virement.
Quatre interfaces de capacité
Les quatre vivent dans le package Cashier sous /Users/luan/apps/cashier/src/Contracts/. La classe de passerelle du plugin n'implémente que les interfaces que son vendor supporte réellement — l'hôte fait des vérifications instanceof à chaque site d'appel.
| Interface | Rôle | Requise pour |
IntentGatewayInterface |
Contrat de base — getCheckoutUrl(intent, returnUrl) + getMethodTitle/getMethodInfo pour l'affichage de méthode enregistrée |
Chaque passerelle |
SupportsAutoChargeInterface |
autoCharge(intent, pmData) pour le débit off-session de carte sans redirect |
Passerelles avec re-débit one-tap (Stripe one-off ; Paddle ne le fait pas) |
SupportsSubscriptionInterface |
createSubscription(intent, pmData) pour la création d'abonnement headless |
Passerelles supportant un flux d'abonnement headless (Stripe Subscription) ; le createSubscription de Paddle lève parce que Paddle est hosted-checkout uniquement |
RemoteSubscriptionGatewayInterface |
getRemotePlans / getRemoteSubscription / getRemoteSubscriptions / cancelRemoteSubscription / updateRemoteSubscriptionPlan / getRemotePaymentMethod — le côté lecture/sync |
Passerelles où le vendor possède l'état d'abonnement (Paddle, Stripe Subscription) |
Scaffold du plugin
La disposition complète des fichiers pour un plugin de passerelle de paiement :
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)
Remarquez ce qui n'est pas là : pas de contrôleur webhook, pas de vérificateur de signature, pas de table de protection contre les replays. La sync d'état est pull-ée par l'hôte, pas push-ée par le vendor.
ServiceProvider — un seul appel Billing::register enregistre tout
Le ServiceProvider squelette complet pour un plugin de passerelle de paiement (paraphrasé depuis 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 dans Billing::register lit les credentials depuis la colonne JSON gatewayData du PaymentGateway via getGatewayData('key'). C'est ainsi que les champs de formulaire de l'admin s'écoulent dans le constructeur du service.
Service passerelle — getCheckoutUrl renvoie l'URL du plugin
L'hôte appelle getCheckoutUrl au moment où le client s'engage sur un checkout. L'implémentation doit être bon marché et sans effet de bord — pas d'appels API vendor pendant le rendu :
public function getCheckoutUrl(PaymentIntent $intent, string $returnUrl): string
{
return route('paddle.checkout', ['intent_uid' => $intent->uid])
. '?return_url=' . urlencode($returnUrl);
}
L'URL pointe vers le propre contrôleur du plugin, pas directement vers l'URL du vendor. Trois raisons :
- L'appel API vendor pour créer le hosted-checkout réel (Paddle :
POST /transactions) doit vivre derrière une frontière de contrôleur pour que les erreurs puissent être attrapées et que le client puisse être redirigé vers la page de facture avec un flash d'erreur.
- Le logging et le throttling appartiennent au contrôleur, pas au service de passerelle.
- Le service de passerelle reste « pur » — pas d'effets de bord HTTP lorsque
getCheckoutUrl s'exécute au moment de la création de l'intent. getCheckoutUrl peut être appelé de manière spéculative, y compris dans les tests.
Contrôleur de checkout — appel vendor + 302 vers hosted checkout
Le CheckoutController::redirect() du plugin est l'endroit où l'appel API vendor se produit réellement. Le client tape /cashier/paddle/checkout/{uid}, le contrôleur appelle l'endpoint POST /transactions de Paddle, et fait un 302 du navigateur vers l'URL de hosted checkout renvoyée par Paddle :
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 est une méthode interne au plugin sur PaddleGateway (pas sur une quelconque interface). Elle enveloppe le POST /transactions du vendor avec la forme JSON spécifique à Paddle.
Critique : custom_data.intent_uid est envoyé au vendor et renvoyé en écho sur les lectures suivantes — GET /subscriptions/{id} renvoie le même custom_data. C'est ainsi que la couche de sync mappe un abonnement Paddle à un PaymentIntent local. Perdez ceci et la sync échoue silencieusement.
Mappers côté lecture — alimenter la couche de sync
Le plugin expose l'état vendor via les méthodes RemoteSubscriptionGatewayInterface. Le RemoteSubscriptionSyncService de l'hôte (cron + à la demande) les appelle pour rafraîchir les DTOs locaux, puis dispatche vers CheckoutHandler quand l'état 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,
];
}
Contrat de pagination : renvoyer {data, has_more, next_cursor}. Le service de sync parcourt les pages jusqu'à has_more => false. Les mappers DTO (priceToDto, subscriptionToDto) traduisent la forme JSON du vendor dans la forme DTO neutre de l'hôte — c'est le savoir par vendor qui est le plus difficile à partager, parce que la forme de réponse de chaque vendor diffère.
Matrice de capacités — quatre patterns de vendor
Les interfaces qu'un plugin implémente dépendent du modèle de paiement du vendor. Les quatre patterns que l'hôte supporte déjà :
| Pattern de vendor | Implémente | Exemples |
| Débit carte ponctuel avec token |
IntentGatewayInterface + SupportsAutoChargeInterface |
Stripe one-off, Square, Razorpay one-off |
| Abonnement hosted-checkout |
IntentGatewayInterface + RemoteSubscriptionGatewayInterface + SupportsSubscriptionInterface (createSubscription lève) |
Paddle, Lemon Squeezy |
| Abonnement headless |
IntentGatewayInterface + SupportsSubscriptionInterface + RemoteSubscriptionGatewayInterface |
Stripe Subscription, Braintree Subscription |
| Manuel / virement |
IntentGatewayInterface uniquement |
Offline (virement bancaire, espèces) |
N'implémentez pas les interfaces inutilisées. Billing::supportsRemoteSubscription($gw) lit le flag isRemoteSubscription positionné au moment de l'enregistrement, pas via instanceof — mais le flag doit correspondre à ce que votre service fait réellement, alors gardez-les alignés.
Tester le contrat de capacité
Sans code de webhook, il n'y a pas de frontière de sécurité à tester unitairement. Concentrez-vous sur le contrat de capacité — les choses dont les appelants dépendent comme garanties de la forme de la passerelle.
Choses à tester unitairement
createSubscription lève pour les vendors hosted-checkout uniquement (Paddle) — les appelants savent qu'il faut utiliser getCheckoutUrl à la place.
getCheckoutUrl renvoie une route vers le contrôleur de votre plugin, pas une URL vendor — prouve que la frontière de contrôleur est en place.
getRemoteSubscription renvoie un RemoteSubscriptionDTO rempli lorsque le vendor renvoie une forme connue — exercez subscriptionToDto avec une fixture enregistrée.
- Contrat de pagination —
getRemoteSubscriptions avec plusieurs pages parcourt jusqu'à has_more => false et renvoie les données concaténées.
Choses à ne pas tester unitairement
- Réponses API vendor en live — celles-ci nécessitent un véritable compte sandbox et sont hors du périmètre des tests unitaires. Vérifiez en live avant de livrer (curl-er les endpoints avec une clé sandbox).
- Mappers DTO lorsqu'ils sont privés et étroitement couplés à la forme JSON vendor — couvrez-les indirectement via des tests de bout en bout avec des fixtures enregistrées, ou exposez-les uniquement pour les tests lorsque la logique de mapping est non triviale.
- Routing — Laravel couvre
loadRoutesFrom ; le nom de route se résout au runtime tant que la closure le capture.
- Chemin nominal
Billing::register — le propre BillingManagerTest de l'hôte le couvre.
Enregistrez la testsuite du plugin dans le phpunit.xml de l'hôte selon le deep-dive Tests : <testsuite name="Plugin: acelle/paddle"> ... </testsuite>. Lancez la suite avec ./vendor/bin/pest --testsuite="Plugin: acelle/paddle".
Cycle de vie d'activation
| Événement | Ce qui se passe |
php artisan plugin:init author/name | Fichiers générés sous storage/app/plugins/.... Ligne en base insérée avec status=inactive. ServiceProvider autoloadé. |
| L'admin clique sur Activate | Déclenche activate_plugin_author/name → le hook du plugin exécute les migrations. Le statut en base bascule à active. La passerelle est désormais visible dans la dropdown select-type. |
| L'admin clique sur Deactivate | Le statut en base bascule à inactive. Le ServiceProvider reste chargé. Les routes se résolvent toujours. Le type de passerelle est toujours dans BillingManager jusqu'au prochain boot de processus. |
| L'admin clique sur Delete | Déclenche delete_plugin_author/name → le hook du plugin rollback les migrations. Fichiers retirés, ligne en base supprimée, entrée du fichier master effacée. |
Garde pour la sémantique de désactivation : si « désactiver = la passerelle disparaît immédiatement » importe pour votre install, vérifiez Plugin::getByName('myvendor/myplugin')->isActive() à l'intérieur de la closure factory-de-service de Billing::register et levez sinon. Le plugin acelle/paddle inclus ne le fait actuellement pas — la passerelle reste disponible même quand un admin la désactive (jusqu'au prochain redémarrage de processus). Hardening futur ; pour le moment le pattern vit dans Architecture des plugins § Pourquoi les plugins inactifs affectent quand même l'app.
Discipline de frontière vendor
Voici les patterns que les tests d'état local seuls ratent. Chacun est venu d'un véritable bug livré avant d'être attrapé par une vérification yeux humains sur écran. Lisez cette section avant d'écrire le service de passerelle.
1. Passez explicitement les champs portant des unités — ne comptez jamais sur les valeurs par défaut du vendor
Amount seul ne suffit pas. Les vendors interprètent le montant comme des unités mineures d'une devise ; si le plugin ne passe pas Currency, le vendor retombe sur la devise par défaut du terminal ou du compte. Le plugin TBANK (référence sœur) omettait à l'origine Currency de son payload Init — la devise par défaut du terminal était RUB, le plan était en USD, le client a vu « 49 ₽ » tandis que le marchand croyait avoir débité « 49 $ ». Le bug était invisible aux assertions DB locales parce que l'intent local enregistrait fidèlement currency='USD' ; seul l'affichage du vendor disait la vérité.
// ❌ 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. Re-mappez custom_data à travers chaque endpoint de lecture
Le plugin envoie custom_data.intent_uid à la création du checkout. Le vendor le renvoie en écho sur chaque lecture liée — détail d'abonnement, détail de transaction, détail de méthode de paiement. Le plugin doit le relire depuis chaque forme parce que l'emplacement custom_data du vendor peut différer entre endpoints de lecture. Perdez le mapping dans l'un d'entre eux et la sync laisse silencieusement cet intent en PENDING à jamais.
3. Ne levez jamais une exception non gérée hors de getCheckoutUrl
getCheckoutUrl est appelée durant le rendu de page. Une levée non gérée produit une page 500 là où le client attendait un bouton de checkout. Gardez la méthode sans effet de bord ; laissez le contrôleur gérer les échecs d'appel vendor et faire remonter l'erreur dans la session flash du client.
4. Attrapez chaque appel vendor dans le contrôleur et flashez l'erreur
Les clients ont besoin de voir ce que le vendor a dit pour pouvoir choisir une autre passerelle ou contacter le support. Une page 500 ne leur dit rien. Le try / catch + redirect()->away($returnUrl)->with('alert-error', ...) du contrôleur Paddle est le pattern canonique.
5. Loggez chaque appel vendor avec l'UID d'intent
Sans l'UID d'intent dans la ligne de log, déboguer un échec rapporté par un client signifie défiler à travers des centaines de lignes de log non liées. \Log::error('Paddle checkout: createTransaction failed', ['intent_uid' => $intent->uid, 'error' => $e->getMessage()]) est la forme minimale utile ; les équipes faisant tourner plusieurs passerelles ajoutent aussi un tag 'gateway' => 'paddle'.
Où aller ensuite
Les drivers d'envoi (modèle push avec webhooks) et les passerelles de paiement (modèle pull avec lectures) sont les deux pages d'exemple travaillé les plus lourdes. Ensemble, elles couvrent les deux côtés du spectre frontière-vendor que l'hôte supporte aujourd'hui. La page suivante — la vitrine acelle/ai — parcourt le plugin complexe canonique de bout en bout comme exercice de compréhension de lecture : huit modèles Eloquent, quatorze migrations, dix-huit locales, la surface UI chatbox, chaque pattern de hook en production. Utilisez-la comme base de code de référence quand vous êtes prêt à construire quelque chose de plus grand qu'un driver ou une passerelle.
Quand la passerelle est livrée et en live, exécutez le cycle activate → test → delete du deep-dive Tests — il attrape une classe de bugs de hook delete_plugin_* que les tests unitaires ne peuvent pas. Pour les références cross-page plus larges, l'aperçu Architecture des plugins a la carte complète du cycle de vie.