Por qué plugin, no core
Añadir un proveedor editando <code>app/Cashier/Services/</code>, el paquete <code>vendor/acelle/cashier/</code> incluido, y <code>app/Providers/CheckoutServiceProvider.php</code>, funciona en principio. En su lugar se eligió la vía del plugin por cuatro razones concretas:
- El core se mantiene sellado. Una actualización del core que añade un proveedor ata a cada instalación al coste de arranque de ese proveedor. Un comerciante que nunca vende a través de Paddle paga igualmente el coste y ve un campo de configuración que no puede usar.
- Entrega independiente. Un plugin puede iterar al ritmo de la API del proveedor (Paddle Billing v2, Razorpay Routes, las revisiones de Adyen Checkout API) sin esperar a una release del core.
- La desinstalación es limpia.
php artisan plugin:delete acelle/paddle elimina por completo el tipo de pasarela. No queda ninguna rama case 'paddle': muerta en los switches del core.
- Política por tenant. Desactivar el plugin hace desaparecer la pasarela de la lista de selección de tipos de Servidores de envío de la administración, en todos lados, al instante. Stripe y Offline vienen integrados en el core porque están «siempre activos»; todo lo demás encaja mejor con la forma de plugin.
Contrapartida: el autor del plugin es dueño del servicio de la pasarela, del controlador de redirección, de la vista del formulario de administración y de los mappers del lado de lectura (getRemotePlan / getRemoteSubscription / getRemotePaymentMethod). Los contratos base hacen que esto sea pequeño: un Paddle completo son aproximadamente 500-700 líneas.
El modelo pull — sin webhook
La arquitectura de pasarelas de AcelleMail es basada en pull: el host obtiene el estado del proveedor bajo demanda. Tres disparadores ejecutan lecturas:
- Lectura perezosa en la carga de página: la página de suscripción / factura del cliente llama a
getRemoteSubscription en el momento del render cuando el estado local es más antiguo que el umbral de frescura.
- Botón «Refrescar»: el admin / cliente puede forzar una lectura inmediata.
- Cron periódico de
RemoteSubscriptionSyncService: barre cada suscripción remota activa a una cadencia configurable.
No se requiere ningún listener de webhook. Los autores de plugin no necesitan alojar un endpoint público de webhook, verificar firmas HMAC, deduplicar la entrega «al menos una vez» ni gestionar ataques de replay. La contrapartida es el retardo de estado (normalmente minutos) entre que el proveedor confirma un cambio de estado y el host lo nota. Para la facturación SaaS esto es aceptable: el cliente no ve el nuevo estado de suscripción en el microsegundo exacto en que Paddle lo confirma; lo ve en el siguiente render de página. Los requisitos rígidos de propagación inferior al segundo no encajan en esta arquitectura sin extender el contrato.
Por qué el pull gana al push para la facturación SaaS. Un endpoint público menos que asegurar (sin verificación HMAC, sin ventana de replay, sin requisito de IP pública en dev). Un paso de integración menos para el admin durante la configuración de la pasarela (sin la danza de «crea endpoint, copia el secreto, espera el webhook de prueba»). Las cancelaciones y los cambios de plan que vienen del portal del cliente del proveedor siguen propagándose: la siguiente pasada de sync las recoge. La cadencia de sync es configurable en el core, así que las instalaciones con necesidades de frescura más estrictas bajan el intervalo.
Contratos base en el core
El host entrega cuatro piezas base que el plugin consume. Los plugins no las implementan: las llaman.
El registro BillingManager
app/Library/BillingManager.php es un singleton vinculado por DI que mantiene el mapa de tipo-de-pasarela → metadatos-de-presentación + factoría-de-servicio. El boot() del service provider del plugin llama a Billing::register(...) una vez por proveedor; el dropdown de selección de tipos visible al cliente, el picker del formulario de administración y Billing::resolveService($gateway) leen todos de este registro.
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
);
El callback CheckoutHandlerInterface
vendor/acelle/cashier/src/Contracts/CheckoutHandlerInterface.php declara los callbacks del lado del host que la capa de sync dispara cuando el estado remoto diverge del estado de intent local. Los plugins no lo llaman directamente. El RemoteSubscriptionSyncService del host consume los métodos de lectura del plugin y despacha él mismo a CheckoutHandler::onPaymentSuccess / onSubscriptionCreated / onPaymentFailed / onPaymentRequiresAuth / onOfflineClaimReceived.
La máquina de estados PaymentIntent
Cinco estados terminales o pendientes; el plugin nunca transiciona el estado del intent directamente. La capa de sync del host lee el estado del proveedor a través del plugin y voltea el intent mediante CheckoutHandler.
| Estado | Significado |
PENDING | Intent creado, el cliente aún no ha pagado |
REQUIRES_ACTION | Reto 3DS / SCA pendiente del cliente (solo tarjetas) |
AWAITING_ADMIN_APPROVAL | Reclamación offline pendiente de revisión del admin |
SUCCEEDED | Terminal — pago confirmado |
FAILED / CANCELLED | Terminal — mostrado al cliente con la razón del proveedor |
El paquete Cashier — ocho pasarelas integradas como referencia
El paquete Cashier bifurcado en /Users/luan/apps/cashier/src/Services/ entrega ocho implementaciones de pasarela integradas. Leerlas es la forma más rápida de ver la superficie completa que podría querer implementar un plugin de pasarela de pago: StripePaymentGateway, StripeSubscriptionGateway, BraintreePaymentGateway, BraintreeSubscriptionGateway, PaypalPaymentGateway, PaystackPaymentGateway, RazorpayPaymentGateway, OfflinePaymentGateway. Las dos primeras tienen forma de suscripción; el resto son de cargo único con tarjeta o de tipo transferencia.
Cuatro interfaces de capability
Las cuatro viven en el paquete Cashier bajo /Users/luan/apps/cashier/src/Contracts/. La clase de pasarela del plugin implementa solo las interfaces que su proveedor admite realmente: el host hace comprobaciones instanceof en cada call-site.
| Interfaz | Propósito | Obligatoria para |
IntentGatewayInterface |
Contrato base — getCheckoutUrl(intent, returnUrl) + getMethodTitle/getMethodInfo para mostrar el método guardado |
Toda pasarela |
SupportsAutoChargeInterface |
autoCharge(intent, pmData) para el cargo con tarjeta off-session sin redirección |
Pasarelas con re-cobro de un toque (Stripe one-off; Paddle no) |
SupportsSubscriptionInterface |
createSubscription(intent, pmData) para crear suscripciones de forma headless |
Pasarelas que admiten un flujo de suscripción headless (Stripe Subscription); createSubscription de Paddle lanza una excepción porque Paddle es solo hosted-checkout |
RemoteSubscriptionGatewayInterface |
getRemotePlans / getRemoteSubscription / getRemoteSubscriptions / cancelRemoteSubscription / updateRemoteSubscriptionPlan / getRemotePaymentMethod — el lado de lectura/sync |
Pasarelas en las que el proveedor es dueño del estado de la suscripción (Paddle, Stripe Subscription) |
Scaffold del plugin
La estructura completa de archivos para un plugin de pasarela de pago:
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)
Fíjese en lo que no está: ni controlador de webhook, ni verificador de firma, ni tabla de protección contra replay. El estado lo extrae el host; no lo empuja el proveedor.
ServiceProvider — una única llamada a Billing::register lo registra todo
El esqueleto completo del service provider para un plugin de pasarela de pago (parafraseado de 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,
]));
}
}
El closure dentro de Billing::register lee las credenciales de la columna JSON gatewayData del PaymentGateway vía getGatewayData('key'). Así es como los campos del formulario del admin fluyen al constructor del servicio.
Servicio de pasarela — getCheckoutUrl devuelve la URL del propio plugin
El host llama a getCheckoutUrl en el momento en que el cliente confirma un checkout. La implementación debe ser barata y libre de efectos secundarios: nada de llamadas a la API del proveedor durante el render:
public function getCheckoutUrl(PaymentIntent $intent, string $returnUrl): string
{
return route('paddle.checkout', ['intent_uid' => $intent->uid])
. '?return_url=' . urlencode($returnUrl);
}
La URL apunta al propio controlador del plugin, no directamente a la URL del proveedor. Tres razones:
- La llamada a la API del proveedor para crear el checkout alojado real (Paddle:
POST /transactions) necesita vivir tras la frontera de un controlador para que los errores puedan capturarse y el cliente pueda ser redirigido a la página de la factura con un mensaje flash de error.
- El logging y el throttling pertenecen al controlador, no al servicio de la pasarela.
- El servicio de la pasarela se mantiene «puro»: sin efectos secundarios HTTP cuando
getCheckoutUrl se ejecuta en el momento de crear el intent. getCheckoutUrl puede llamarse de forma especulativa, también en tests.
Controlador de checkout — llame al proveedor y haga 302 al checkout alojado
El CheckoutController::redirect() del plugin es donde realmente ocurre la llamada a la API del proveedor. El cliente entra en /cashier/paddle/checkout/{uid}, el controlador llama al endpoint POST /transactions de Paddle y hace 302 al navegador hacia la URL de checkout alojado que devolvió 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 es un método interno del plugin en PaddleGateway (no en ninguna interfaz). Envuelve el POST /transactions del proveedor con la forma JSON específica de Paddle.
Crítico: custom_data.intent_uid se envía al proveedor y se devuelve en las lecturas posteriores — GET /subscriptions/{id} devuelve el mismo custom_data. Así es como la capa de sync mapea una suscripción de Paddle de vuelta a un PaymentIntent local. Pierda esto y el sync falla silenciosamente.
Mappers del lado de lectura — alimentando la capa de sync
El plugin expone el estado del proveedor a través de los métodos de RemoteSubscriptionGatewayInterface. El RemoteSubscriptionSyncService del host (cron + bajo demanda) los llama para refrescar los DTOs locales y luego despacha a CheckoutHandler cuando el estado 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,
];
}
Contrato de paginación: devuelva {data, has_more, next_cursor}. El servicio de sync recorre páginas hasta que has_more => false. Los mappers de DTO (priceToDto, subscriptionToDto) traducen la forma JSON del proveedor a la forma neutra de DTO del host: ese es el conocimiento por proveedor más difícil de compartir, porque la forma de respuesta de cada proveedor difiere.
Matriz de capabilities — cuatro patrones de proveedor
Las interfaces que implementa un plugin dependen del modelo de pago del proveedor. Los cuatro patrones que el host ya admite:
| Patrón de proveedor | Implementa | Ejemplos |
| Cargo único con tarjeta vía token |
IntentGatewayInterface + SupportsAutoChargeInterface |
Stripe one-off, Square, Razorpay one-off |
| Suscripción con checkout alojado |
IntentGatewayInterface + RemoteSubscriptionGatewayInterface + SupportsSubscriptionInterface (createSubscription lanza excepción) |
Paddle, Lemon Squeezy |
| Suscripción headless |
IntentGatewayInterface + SupportsSubscriptionInterface + RemoteSubscriptionGatewayInterface |
Stripe Subscription, Braintree Subscription |
| Manual / transferencia bancaria |
Solo IntentGatewayInterface |
Offline (transferencia bancaria, efectivo) |
No implemente interfaces que no usa. Billing::supportsRemoteSubscription($gw) lee el flag isRemoteSubscription establecido en el momento del registro, no vía instanceof, pero el flag debe coincidir con lo que su servicio hace realmente, así que manténgalos alineados.
Probar el contrato de capability
Sin código de webhook, no hay frontera de seguridad que testear. Céntrese en el contrato de capability: las cosas de las que los llamantes dependen como garantías de la forma de la pasarela.
Cosas para testear con tests unitarios
createSubscription lanza una excepción para proveedores solo de checkout alojado (Paddle): los llamantes saben que deben usar getCheckoutUrl en su lugar.
getCheckoutUrl devuelve una ruta al controlador del propio plugin, no a una URL del proveedor: demuestra que la frontera del controlador está en su sitio.
getRemoteSubscription devuelve un RemoteSubscriptionDTO poblado cuando el proveedor devuelve una forma conocida: ejercite subscriptionToDto con una fixture grabada.
- Contrato de paginación:
getRemoteSubscriptions con varias páginas recorre hasta has_more => false y devuelve los datos concatenados.
Cosas que no hay que testear con tests unitarios
- Respuestas reales de la API del proveedor: necesitan una cuenta sandbox real y quedan fuera del alcance de los tests unitarios. Verifique en vivo antes de entregar (haga curl a los endpoints con una clave de sandbox).
- Mappers de DTO cuando son privados y están fuertemente acoplados a la forma JSON del proveedor: cúbralos indirectamente con tests de extremo a extremo con fixtures grabadas, o expóngalos solo para tests cuando la lógica de mapeo no sea trivial.
- El enrutamiento: Laravel cubre
loadRoutesFrom; el nombre de la ruta resuelve en runtime mientras el closure lo capture.
- El happy path de
Billing::register: el propio BillingManagerTest del host lo cubre.
Registre el testsuite del plugin en el phpunit.xml del host según el análisis a fondo de Testing: <testsuite name="Plugin: acelle/paddle"> ... </testsuite>. Ejecute la suite con ./vendor/bin/pest --testsuite="Plugin: acelle/paddle".
Ciclo de vida de la activación
| Evento | Qué pasa |
php artisan plugin:init author/name | Se generan archivos bajo storage/app/plugins/.... Se inserta la fila de la BD con status=inactive. El service provider se autocarga. |
| El admin hace clic en Activar | Dispara activate_plugin_author/name → el hook del plugin ejecuta las migraciones. El estado en la BD pasa a active. La pasarela ahora es visible en el dropdown de selección de tipos. |
| El admin hace clic en Desactivar | El estado en la BD pasa a inactive. El service provider sigue cargado. Las rutas siguen resolviendo. El tipo de pasarela sigue en BillingManager hasta el próximo arranque del proceso. |
| El admin hace clic en Eliminar | Dispara delete_plugin_author/name → el hook del plugin revierte las migraciones. Se eliminan los archivos, se borra la fila de la BD y se limpia la entrada del archivo maestro. |
Protección para la semántica de desactivación: si en su instalación importa que «desactivar = la pasarela desaparece de inmediato», compruebe Plugin::getByName('myvendor/myplugin')->isActive() dentro del closure de la factoría de servicio en Billing::register y lance una excepción si no. El plugin acelle/paddle incluido actualmente no lo hace: la pasarela sigue disponible incluso cuando un admin la desactiva (hasta el próximo reinicio del proceso). Endurecimiento futuro; por ahora el patrón vive en Arquitectura de plugins § Por qué los plugins inactivos siguen afectando a la app.
Disciplina de frontera con el proveedor
Estos son los patrones que los tests de estado-solo-local pasan por alto. Cada uno vino de un bug real que se entregó antes de ser detectado por una revisión humana con los ojos en la pantalla. Lea esta sección antes de escribir el servicio de la pasarela.
1. Pase explícitamente los campos con unidades — nunca confíe en los valores por defecto del proveedor
Amount por sí solo no basta. Los proveedores interpretan amount como unidades menores de alguna moneda; si el plugin no pasa Currency, el proveedor cae al valor por defecto del terminal o de la cuenta. El plugin TBANK (referencia hermana) originalmente omitía Currency de su payload de Init: el valor por defecto del terminal era RUB, el plan era USD, el cliente veía «₽49» mientras el comerciante creía haber cobrado «$49». El bug era invisible para las aserciones locales en BD porque el intent local registraba diligentemente currency='USD'; solo la presentación del proveedor decía la verdad.
// ❌ 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. Mapee custom_data de vuelta a través de cada endpoint de lectura
El plugin envía custom_data.intent_uid al crear el checkout. El proveedor lo devuelve en cada lectura relacionada: detalle de suscripción, detalle de transacción, detalle del método de pago. El plugin debe leerlo de vuelta en cada forma porque la ubicación de custom_data en el proveedor puede diferir entre endpoints de lectura. Pierda el mapeo en cualquiera de ellos y el sync deja silenciosamente ese intent en PENDING para siempre.
3. Nunca deje escapar una excepción sin gestionar desde getCheckoutUrl
Se llama a getCheckoutUrl durante el render de la página. Un throw sin gestionar produce una página 500 donde el cliente esperaba un botón de checkout. Mantenga el método libre de efectos secundarios; deje que el controlador gestione los fallos de llamada al proveedor y muestre el error en la sesión flash del cliente.
4. Capture cada llamada al proveedor en el controlador y haga flash del error
Los clientes necesitan ver lo que dijo el proveedor para poder elegir otra pasarela o contactar con soporte. Una página 500 no les dice nada. El patrón canónico es el try / catch + redirect()->away($returnUrl)->with('alert-error', ...) del controlador de Paddle.
5. Registre cada llamada al proveedor con el UID del intent
Sin el UID del intent en la línea del log, depurar un fallo reportado por un cliente significa rascarse a través de cientos de líneas de log no relacionadas. \Log::error('Paddle checkout: createTransaction failed', ['intent_uid' => $intent->uid, 'error' => $e->getMessage()]) es la forma mínima útil; los equipos que ejecutan varias pasarelas añaden también una etiqueta 'gateway' => 'paddle'.
A dónde ir después
Los drivers de envío (modelo push con webhooks) y las pasarelas de pago (modelo pull con lecturas) son las dos páginas de ejemplo trabajado más pesadas. Juntas cubren ambos lados del espectro de frontera con el proveedor que el host admite hoy. La siguiente página, la muestra de acelle/ai, recorre el plugin complejo canónico de principio a fin como ejercicio de comprensión lectora: ocho modelos Eloquent, catorce migraciones, dieciocho locales, la superficie de UI del chatbox y todos los patrones de hook en producción. Úselo como código fuente de referencia cuando esté listo para construir algo más grande que un driver o una pasarela.
Cuando la pasarela esté entregada y en vivo, ejecute el ciclo de activar → testear → eliminar del análisis a fondo de Testing: detecta una clase de bugs del hook delete_plugin_* que los tests unitarios no pueden detectar. Para las referencias cruzadas más amplias, el resumen de Arquitectura de plugins tiene el mapa completo del ciclo de vida.