Por que plugin, não core
Adicionar um fornecedor editando <code>app/Cashier/Services/</code>, o pacote embutido <code>vendor/acelle/cashier/</code> e <code>app/Providers/CheckoutServiceProvider.php</code> funciona em princípio. O caminho de plugin foi escolhido por quatro razões concretas:
- Core fica selado. Um upgrade do core que adiciona um fornecedor amarra toda instalação ao custo de boot daquele fornecedor. Um merchant que nunca vende pelo Paddle ainda paga o custo e vê um campo de configuração que não pode usar.
- Entrega independente. Um plugin pode iterar no ritmo da API do fornecedor — Paddle Billing v2, Razorpay Routes, revisões da Adyen Checkout API — sem esperar um release do core.
- Uninstall é limpo.
php artisan plugin:delete acelle/paddle remove o tipo de gateway inteiramente. Não fica um branch case 'paddle': morto nos switches do core.
- Política por tenant. Desabilitar o plugin faz o gateway sumir da lista select-type de Servidores de Envio do admin em todo lugar, imediatamente. Stripe e Offline são embutidos no core porque são "sempre-ligados"; todo o resto cabe melhor no formato de plugin.
Trade-off: o autor do plugin é dono do gateway service, do controller de redirect, da view de form admin e dos mappers do lado leitura (getRemotePlan / getRemoteSubscription / getRemotePaymentMethod). Os contratos de fundação tornam isso pequeno — Paddle inteiro são cerca de 500-700 linhas.
O modelo pull — sem webhook
A arquitetura de gateway do AcelleMail é pull-based: o host busca estado do fornecedor sob demanda. Três gatilhos rodam leituras:
- Fetch lazy de page-load — a página de assinatura / fatura do cliente chama
getRemoteSubscription em tempo de render quando o estado local é mais velho que o threshold de freshness.
- Botão "Atualizar" — admin / cliente pode forçar uma leitura imediata.
- Cron periódico
RemoteSubscriptionSyncService — varre cada assinatura remota ativa em uma cadência configurável.
Nenhum listener de webhook é exigido. Autores de plugin não precisam hospedar um endpoint público de webhook, verificar assinaturas HMAC, deduplicar entrega at-least-once ou tratar ataques de replay. O trade-off é lag de estado — tipicamente minutos — entre o fornecedor confirmar uma mudança de estado e o host notar. Para billing SaaS isso está bem: o cliente não vê o novo status da assinatura no microsegundo literal que o Paddle confirma; vê no próximo render de página. Requisitos duros para propagação sub-segundo não combinam com essa arquitetura sem estender o contrato.
Por que pull bate push para billing SaaS. Um endpoint público a menos para proteger (sem verificação HMAC, sem janela de replay, sem requisito de IP público em dev). Um passo de integração a menos para o admin durante setup do gateway (sem a dança "crie endpoint, copie o segredo, espere o webhook de teste"). Cancelamentos e mudanças de plano vindo do portal de cliente do fornecedor ainda propagam — o próximo passo de sync pega. A cadência de sync é configurável no core, então instalações com requisitos de freshness mais estritos abaixam o intervalo.
Contratos de fundação no core
O host entrega quatro peças de fundação que o plugin consome. Plugins não implementam essas — eles chamam.
O registry BillingManager
app/Library/BillingManager.php é um singleton DI-bound que segura o mapa tipo-de-gateway → metadata-de-apresentação + factory-de-serviço. O boot() do service provider do plugin chama Billing::register(...) uma vez por fornecedor; o dropdown select-type voltado ao cliente, o picker de form admin e Billing::resolveService($gateway) todos leem desse 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
);
O callback CheckoutHandlerInterface
vendor/acelle/cashier/src/Contracts/CheckoutHandlerInterface.php declara os callbacks do lado do host que a camada de sync dispara quando o estado remoto diverge do estado de intent local. Plugins não chamam diretamente. O RemoteSubscriptionSyncService do host consome os métodos de leitura do plugin e despacha para CheckoutHandler::onPaymentSuccess / onSubscriptionCreated / onPaymentFailed / onPaymentRequiresAuth / onOfflineClaimReceived por si só.
A máquina de estado PaymentIntent
Cinco estados terminais ou pendentes; o plugin nunca transita estado de intent diretamente. A camada de sync do host lê estado de fornecedor pelo plugin e vira o intent pelo CheckoutHandler.
| Estado | Significado |
PENDING | Intent criado, cliente ainda não pagou |
REQUIRES_ACTION | Desafio 3DS / SCA esperando pelo cliente (só cartões) |
AWAITING_ADMIN_APPROVAL | Claim offline pendente de revisão admin |
SUCCEEDED | Terminal — pagamento confirmado |
FAILED / CANCELLED | Terminal — aparece para o cliente com a razão do fornecedor |
O pacote Cashier — oito gateways nativos para referência
O pacote Cashier forkado em /Users/luan/apps/cashier/src/Services/ entrega oito implementações de gateway nativas. Lê-las é o jeito mais rápido de ver a superfície completa que um plugin de gateway de pagamento pode querer implementar: StripePaymentGateway, StripeSubscriptionGateway, BraintreePaymentGateway, BraintreeSubscriptionGateway, PaypalPaymentGateway, PaystackPaymentGateway, RazorpayPaymentGateway, OfflinePaymentGateway. Os dois primeiros são em formato de assinatura; o resto é cartão one-off ou estilo transferência.
Quatro interfaces de capacidade
Todas as quatro vivem no pacote Cashier em /Users/luan/apps/cashier/src/Contracts/. A classe de gateway do plugin implementa só as interfaces que o fornecedor de fato suporta — o host faz checks instanceof em cada call-site.
| Interface | Propósito | Exigida para |
IntentGatewayInterface |
Contrato base — getCheckoutUrl(intent, returnUrl) + getMethodTitle/getMethodInfo para display de método salvo |
Todo gateway |
SupportsAutoChargeInterface |
autoCharge(intent, pmData) para cobrança de cartão off-session sem redirect |
Gateways com re-bill one-tap (Stripe one-off; Paddle não) |
SupportsSubscriptionInterface |
createSubscription(intent, pmData) para criação headless de assinatura |
Gateways que suportam um fluxo de assinatura headless (Stripe Subscription); o createSubscription do Paddle lança porque o Paddle é hosted-checkout-only |
RemoteSubscriptionGatewayInterface |
getRemotePlans / getRemoteSubscription / getRemoteSubscriptions / cancelRemoteSubscription / updateRemoteSubscriptionPlan / getRemotePaymentMethod — o lado read/sync |
Gateways onde o fornecedor é dono do estado de assinatura (Paddle, Stripe Subscription) |
Scaffold do plugin
O layout completo de arquivos para um plugin de gateway de pagamento:
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)
Repare no que não está ali: sem controller de webhook, sem verificador de assinatura, sem tabela de proteção contra replay. Sync de estado é puxado pelo host, não empurrado pelo fornecedor.
ServiceProvider — uma única chamada Billing::register registra tudo
O service provider esqueleto completo para um plugin de gateway de pagamento (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,
]));
}
}
A closure dentro de Billing::register lê credenciais da coluna JSON gatewayData do PaymentGateway via getGatewayData('key'). É assim que os campos de form do admin fluem para o constructor do service.
Serviço do gateway — getCheckoutUrl retorna a URL do plugin
O host chama getCheckoutUrl no momento em que o cliente se compromete com um checkout. A implementação precisa ser barata e sem efeitos colaterais — sem chamadas de API de fornecedor durante o render:
public function getCheckoutUrl(PaymentIntent $intent, string $returnUrl): string
{
return route('paddle.checkout', ['intent_uid' => $intent->uid])
. '?return_url=' . urlencode($returnUrl);
}
A URL aponta para o próprio controller do plugin, não para a URL do fornecedor diretamente. Três razões:
- A chamada de API do fornecedor para criar o hosted-checkout real (Paddle:
POST /transactions) precisa viver atrás de uma fronteira de controller para que erros possam ser pegos e o cliente possa ser redirecionado de volta para a página de fatura com um flash de erro.
- Logging e throttling pertencem ao controller, não ao gateway service.
- O gateway service permanece "puro" — sem efeitos colaterais HTTP quando
getCheckoutUrl roda em tempo de intent-create. getCheckoutUrl pode ser chamado especulativamente, incluindo em testes.
Controller de checkout — chama fornecedor + 302 para hosted checkout
O CheckoutController::redirect() do plugin é onde a chamada de API do fornecedor de fato acontece. O cliente bate em /cashier/paddle/checkout/{uid}, o controller chama o endpoint POST /transactions do Paddle e dá 302 para a URL de hosted checkout que o Paddle retornou:
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 é um método interno do plugin no PaddleGateway (não em nenhuma interface). Envelopa o POST /transactions do fornecedor com a forma JSON específica do Paddle.
Crítico: custom_data.intent_uid é enviado ao fornecedor e ecoado de volta em leituras subsequentes — GET /subscriptions/{id} retorna o mesmo custom_data. É assim que a camada de sync mapeia uma assinatura Paddle de volta a um PaymentIntent local. Perca isso e o sync falha silenciosamente.
Mappers do lado leitura — alimentando a camada de sync
O plugin expõe o estado do fornecedor pelos métodos da RemoteSubscriptionGatewayInterface. O RemoteSubscriptionSyncService do host (cron + on-demand) chama essas para atualizar DTOs locais, depois despacha para CheckoutHandler quando o 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 paginação: retorne {data, has_more, next_cursor}. O service de sync caminha pelas páginas até has_more => false. Mappers DTO (priceToDto, subscriptionToDto) traduzem a forma JSON do fornecedor para a forma DTO neutra do host — esse é o conhecimento por-fornecedor mais difícil de compartilhar, porque a forma de resposta de cada fornecedor difere.
Matriz de capacidade — quatro padrões de fornecedor
As interfaces que um plugin implementa dependem do modelo de pagamento do fornecedor. Os quatro padrões que o host já suporta:
| Padrão de fornecedor | Implementa | Exemplos |
| Cobrança one-off com token de cartão |
IntentGatewayInterface + SupportsAutoChargeInterface |
Stripe one-off, Square, Razorpay one-off |
| Assinatura hosted-checkout |
IntentGatewayInterface + RemoteSubscriptionGatewayInterface + SupportsSubscriptionInterface (createSubscription lança) |
Paddle, Lemon Squeezy |
| Assinatura headless |
IntentGatewayInterface + SupportsSubscriptionInterface + RemoteSubscriptionGatewayInterface |
Stripe Subscription, Braintree Subscription |
| Manual / transferência bancária |
Só IntentGatewayInterface |
Offline (transferência bancária, dinheiro) |
Não implemente interfaces não usadas. Billing::supportsRemoteSubscription($gw) lê a flag isRemoteSubscription setada no momento de registro, não via instanceof — mas a flag precisa bater com o que o seu service de fato faz, então mantenha alinhado.
Testando o contrato de capacidade
Sem código de webhook, não há fronteira de segurança para unit-test. Foque no contrato de capacidade — as coisas das quais callers dependem como garantias da forma do gateway.
Coisas para unit-testar
createSubscription lança para fornecedores hosted-checkout-only (Paddle) — callers sabem usar getCheckoutUrl em vez.
getCheckoutUrl retorna uma rota para o controller do seu plugin, não uma URL de fornecedor — prova que a fronteira de controller está no lugar.
getRemoteSubscription retorna um RemoteSubscriptionDTO populado quando o fornecedor retorna uma forma conhecida — exercite subscriptionToDto com uma fixture gravada.
- Contrato de paginação —
getRemoteSubscriptions com múltiplas páginas caminha até has_more => false e retorna data concatenada.
Coisas para não unit-testar
- Respostas de API de fornecedor ao vivo — essas precisam de uma conta sandbox real e estão fora do escopo de unit test. Verifique ao vivo antes de entregar (curl os endpoints com uma chave sandbox).
- Mappers DTO quando são privados e fortemente acoplados à forma JSON do fornecedor — cubra indiretamente por testes end-to-end com fixtures gravadas, ou exponha para teste apenas quando a lógica de mapeamento for não-trivial.
- Roteamento — o Laravel cobre
loadRoutesFrom; o nome da rota resolve em runtime enquanto a closure capturar.
- Caminho feliz de
Billing::register — o próprio BillingManagerTest do host cobre.
Registre a testsuite do plugin no phpunit.xml do host conforme o deep-dive de Testes: <testsuite name="Plugin: acelle/paddle"> ... </testsuite>. Rode a suite com ./vendor/bin/pest --testsuite="Plugin: acelle/paddle".
Ciclo de vida de ativação
| Evento | O que acontece |
php artisan plugin:init author/name | Arquivos gerados em storage/app/plugins/.... Linha do banco inserida com status=inactive. Service provider auto-carregado. |
| Admin clica em Ativar | Dispara activate_plugin_author/name → hook do plugin roda migrations. Status do banco vira active. Gateway agora visível no dropdown select-type. |
| Admin clica em Desativar | Status do banco vira inactive. Service provider continua carregado. Rotas ainda resolvem. O tipo de gateway ainda está no BillingManager até o próximo boot de processo. |
| Admin clica em Deletar | Dispara delete_plugin_author/name → hook do plugin faz rollback das migrations. Arquivos removidos, linha do banco deletada, entrada do master file limpa. |
Proteção para semânticas de desativação: se "desativar = gateway some imediatamente" importa para sua instalação, cheque Plugin::getByName('myvendor/myplugin')->isActive() dentro da closure factory de service do Billing::register e lance se não estiver. O plugin embutido acelle/paddle atualmente não faz isso — o gateway continua disponível mesmo quando um admin desativa (até o próximo restart de processo). Hardening futuro; por ora o padrão vive em Arquitetura de plugins § Por que plugins inativos ainda afetam o app.
Disciplina de fronteira de fornecedor
Esses são os padrões que teste só-de-estado-local perde. Cada um veio de um bug real que entregou antes de ser pego por um humano-olhando-na-tela. Leia esta seção antes de escrever o gateway service.
1. Passe campos com unidade explicitamente — nunca dependa de defaults do fornecedor
Amount sozinho não é suficiente. Fornecedores interpretam amount como unidades menores de alguma moeda; se o plugin não passar Currency, o fornecedor cai no default do terminal ou da conta. O plugin TBANK (referência irmã) originalmente omitia Currency do seu payload Init — default do terminal era RUB, plano era USD, cliente via "₽49" enquanto o merchant acreditava ter cobrado "$49". O bug era invisível para assertions de banco local porque o intent local dutifully gravava currency='USD'; só o display do fornecedor contava a verdade.
// ❌ 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. Mapeie custom_data de volta por cada endpoint de leitura
O plugin envia custom_data.intent_uid no checkout create. O fornecedor ecoa de volta em toda leitura relacionada — detalhe de assinatura, detalhe de transação, detalhe de método de pagamento. O plugin precisa ler de cada forma porque a localização de custom_data do fornecedor pode diferir entre endpoints de leitura. Perca o mapeamento em qualquer um e o sync silenciosamente deixa aquele intent em PENDING para sempre.
3. Nunca lance uma exceção não tratada de dentro de getCheckoutUrl
getCheckoutUrl é chamado durante render de página. Um throw não tratado produz uma página 500 onde o cliente esperava um botão de checkout. Mantenha o método sem efeitos colaterais; deixe o controller tratar falhas de chamada do fornecedor e exibir o erro ao flash do cliente.
4. Pegue toda chamada de fornecedor no controller e faça flash do erro
Clientes precisam ver o que o fornecedor disse para poderem escolher um gateway diferente ou contatar suporte. Uma página 500 não diz nada. O try / catch + redirect()->away($returnUrl)->with('alert-error', ...) do controller Paddle é o padrão canônico.
5. Logue toda chamada de fornecedor com o UID do intent
Sem o UID do intent na linha de log, debugar uma falha reportada por cliente significa scrollar centenas de linhas de log não relacionadas. \Log::error('Paddle checkout: createTransaction failed', ['intent_uid' => $intent->uid, 'error' => $e->getMessage()]) é a forma útil mínima; times rodando múltiplos gateways adicionam uma tag 'gateway' => 'paddle' também.
Para onde ir em seguida
Drivers de envio (modelo push com webhooks) e gateways de pagamento (modelo pull com leituras) são as duas páginas mais pesadas de exemplo trabalhado. Juntos cobrem os dois lados do espectro de fronteira de fornecedor que o host suporta hoje. A próxima página — a vitrine acelle/ai — percorre o plugin complexo canônico de ponta a ponta como exercício de leitura-compreensão: oito models Eloquent, quatorze migrations, dezoito locales, a superfície UI do chatbox, cada padrão de hook em produção. Use como codebase de referência quando estiver pronto para construir algo maior que um driver ou um gateway.
Quando o gateway estiver entregue e ao vivo, rode o ciclo activate → test → delete do deep-dive de Testes — pega uma classe de bugs de hook delete_plugin_* que unit tests não pegam. Para as referências cross-page mais amplas, a visão geral de Arquitetura de plugins tem o mapa de ciclo de vida completo.