Para integrar vários gateways de pagamento no Laravel sem prender o sistema a um deles, o domínio precisa falar só com um contrato seu, e não com o SDK ou o JSON do provedor. Cada gateway (Pagar.me, Asaas, Stripe) vira um adapter que traduz para esse contrato: valores em centavos, status num enum próprio e o identificador do provedor gravado na cobrança. A parte difícil não é o padrão Adapter em si. São três decisões: o que entra no contrato, como normalizar status e quando não fazer fallback automático.
Por que chamar o gateway direto do controller vira problema?
No começo de qualquer produto com cobrança, a primeira integração costuma nascer dentro de um service que monta o payload do provedor e salva o retorno do jeito que veio. Funciona até o dia em que acontece uma destas coisas:
- Você precisa de um segundo provedor, porque a taxa de outro é melhor para Pix, porque um cliente PJ exige outro meio de pagamento ou porque um provedor ficou instável.
- O provedor muda a versão da API e o formato do status muda junto.
- Um relatório precisa somar cobranças pagas e você descobre que
paid,RECEIVED,CONFIRMEDesucceededestão espalhados emifpelo código.
O sintoma clássico é ver regra de negócio decidindo coisa com base em string do provedor: if ($charge->status === 'RECEIVED'). A partir desse ponto, trocar de gateway deixa de ser uma tarefa de integração e vira uma refatoração do sistema inteiro.
O que é uma camada anticorrupção e onde ela fica no Laravel?
Camada anticorrupção (anti-corruption layer) é a fronteira que traduz o modelo de um sistema externo para o modelo do seu domínio, para que os conceitos de fora não vazem para dentro. Na prática, no Laravel, ela é composta por:
- uma interface
PaymentGateway, que é o único tipo que o domínio conhece; - DTOs de entrada e saída, ou seja, objetos imutáveis que só carregam dados;
- um enum
ChargeStatuscom os estados que o seu negócio entende; - um adapter por provedor, que é o único lugar onde aparece
billingType,payment_intentou qualquer outro nome de campo deles.
Eu deixo isso numa pasta app/Payments/Gateways, com cada provedor numa subpasta. Nenhuma classe fora dessa pasta importa nada de dentro de Asaas/ ou Pagarme/.
Como desenhar o contrato do gateway sem virar o menor denominador comum?
O erro mais comum é tentar criar uma interface que cubra tudo o que todos os provedores fazem. Você acaba com métodos que metade dos adapters implementa com throw new NotSupported. Eu prefiro o caminho inverso: o contrato descreve o que o meu produto precisa, e cada adapter declara o que consegue entregar.
interface PaymentGateway { public function name(): string; public function supports(Capability $capability): bool; public function createPixCharge(CreatePixCharge $data): GatewayCharge; public function fetchCharge(string $providerChargeId): GatewayCharge; public function refund(RefundRequest $data): GatewayRefund; } enum Capability { case Pix; case CardCharge; case PartialRefund; case Split; }
Os DTOs carregam valor sempre em centavos, como inteiro. A conversão para reais com casas decimais, que alguns provedores exigem, acontece só dentro do adapter.
final readonly class CreatePixCharge { public function __construct( public string $reference, // id interno da cobrança, usado como idempotência public int $amountInCents, public Payer $payer, public CarbonImmutable $expiresAt, public ?string $description = null, ) {} } final readonly class GatewayCharge { public function __construct( public string $provider, public string $providerChargeId, public ChargeStatus $status, public int $amountInCents, public ?string $pixCopyPaste, public array $raw, ) {} }
O campo raw é uma decisão consciente. Ele guarda o payload original para auditoria e suporte, mas nenhuma regra de negócio pode ler dele. Se alguma regra precisar de um dado que só existe no raw, isso é sinal de que o DTO está incompleto, e o certo é promover o campo para o contrato.
Como fica um adapter na prática?
Um adapter do Asaas para Pix, simplificado, fica assim. Repare que tudo o que é específico do provedor (nome de campo, formato de valor, a chamada separada para obter o QR Code) fica confinado aqui.
final class AsaasGateway implements PaymentGateway { public function __construct( private PendingRequest $http, private AsaasCustomerResolver $customers, ) {} public function name(): string { return 'asaas'; } public function createPixCharge(CreatePixCharge $data): GatewayCharge { $payment = $this->http->post('/v3/payments', [ 'customer' => $this->customers->resolve($data->payer), 'billingType' => 'PIX', 'value' => round($data->amountInCents / 100, 2), 'dueDate' => $data->expiresAt->toDateString(), 'externalReference' => $data->reference, 'description' => $data->description, ])->throw()->json(); $qr = $this->http->get("/v3/payments/{$payment['id']}/pixQrCode") ->throw()->json(); return new GatewayCharge( provider: $this->name(), providerChargeId: $payment['id'], status: $this->mapStatus($payment['status']), amountInCents: (int) round($payment['value'] * 100), pixCopyPaste: $qr['payload'] ?? null, raw: $payment, ); } private function mapStatus(string $status): ChargeStatus { return match ($status) { 'PENDING', 'AWAITING_RISK_ANALYSIS' => ChargeStatus::Pending, 'RECEIVED', 'CONFIRMED', 'RECEIVED_IN_CASH' => ChargeStatus::Paid, 'OVERDUE' => ChargeStatus::Expired, 'REFUNDED' => ChargeStatus::Refunded, 'REFUND_REQUESTED', 'REFUND_IN_PROGRESS' => ChargeStatus::RefundPending, 'CHARGEBACK_REQUESTED', 'CHARGEBACK_DISPUTE', 'AWAITING_CHARGEBACK_REVERSAL' => ChargeStatus::Disputed, default => throw new UnknownProviderStatus($this->name(), $status), }; } // fetchCharge, refund, supports... }
O default lançando exceção é proposital. Mapear status desconhecido para Pending parece seguro, mas esconde mudanças do provedor. Prefiro que o job falhe, apareça no Horizon e alguém olhe, a ter cobrança presa num estado errado sem ninguém saber.
Como normalizar status de pagamento entre Pagar.me, Asaas e Stripe?
O enum de domínio precisa ser pequeno e refletir o que o negócio decide, não o que o provedor reporta. Uma tabela de mapeamento como esta vira documentação viva do projeto. Ela é aproximada: confira sempre a documentação da versão de API que você usa.
| Domínio | Pagar.me (charge) | Asaas (payment) | Stripe (PaymentIntent / eventos) |
|---|---|---|---|
| Pending | pending, processing |
PENDING |
requires_payment_method, requires_action, processing |
| Paid | paid |
RECEIVED, CONFIRMED |
succeeded |
| Failed | failed |
— | payment_intent.payment_failed |
| Canceled/Expired | canceled |
OVERDUE |
canceled |
| Refunded | refunded |
REFUNDED |
charge.refunded |
| Disputed | chargedback |
CHARGEBACK_* |
charge.dispute.created |
Dois cuidados aparecem rápido:
- Pago não é o mesmo que liquidado. No Asaas,
CONFIRMEDem cartão significa que o pagamento foi aprovado, mas o dinheiro ainda não caiu. Para liberar um pedido isso basta; para o saldo disponível do lojista, não. Eu mantenhoPaidno status da cobrança e trato liquidação como outro eixo, que é responsabilidade da conciliação diária com o extrato do provedor. - Valores divergentes. Alguns provedores reportam pagamento a maior ou a menor (o Pagar.me tem
overpaideunderpaid). Não force isso paraPaid. Crie um estado explícito ou mande para revisão manual.
Como escolher qual provedor usar em cada cobrança?
A escolha do provedor é uma política de negócio, então merece uma classe própria, separada do registro que instancia os adapters.
final class GatewayRegistry { public function __construct(private Container $app) {} public function for(string $provider): PaymentGateway { return match ($provider) { 'asaas' => $this->app->make(AsaasGateway::class), 'pagarme' => $this->app->make(PagarmeGateway::class), 'stripe' => $this->app->make(StripeGateway::class), default => throw new InvalidArgumentException("Gateway desconhecido: {$provider}"), }; } } final class GatewayRouter { public function __construct(private GatewayRegistry $registry) {} public function forNewCharge(Merchant $merchant, Capability $method): PaymentGateway { $provider = $merchant->preferredGatewayFor($method) ?? config("payments.default.{$method->name}"); $gateway = $this->registry->for($provider); if (! $gateway->supports($method)) { throw new UnsupportedCapability($provider, $method); } return $gateway; } }
A regra mais importante desta seção é: o router só decide para cobranças novas. Depois de criada, a cobrança grava provider e provider_charge_id na tabela, e toda operação seguinte (consulta, estorno, webhook) usa $registry->for($charge->provider). Se amanhã o padrão mudar de Asaas para Pagar.me, as cobranças antigas continuam sendo estornadas no Asaas. Parece óbvio, mas é exatamente o bug que aparece quando alguém usa config('payments.default') no fluxo de estorno.
Fallback automático entre gateways vale a pena?
É a pergunta que todo mundo faz depois de montar a abstração: se o provedor A falhar, por que não tentar o B automaticamente? Porque falha em pagamento quase nunca é binária. Um timeout não diz se a cobrança foi criada ou não. Se você tenta no B após um timeout do A, pode acabar com dois QR Codes Pix para o mesmo pedido ou, pior, duas autorizações no cartão do cliente.
Os critérios que eu uso para permitir fallback são:
- A requisição comprovadamente não chegou ao provedor. Erro de DNS, conexão recusada ou circuit breaker aberto (o mecanismo que para de chamar um serviço depois de falhas seguidas). Nesses casos não existe efeito colateral do outro lado.
- O provedor respondeu de forma explícita que não criou nada. Erro de validação ou indisponibilidade declarada, documentada como sem efeito.
- A cobrança ainda não foi mostrada ao pagador. Trocar de provedor depois que o cliente já copiou o Pix gera confusão e cobrança duplicada.
- Timeout ou 5xx genérico nunca disparam fallback direto. Primeiro consulto o provedor original pela referência externa. Só se ele confirmar que a cobrança não existe é que a tentativa no outro provedor fica liberada.
Para o item 4 funcionar, a referência interna (reference no DTO) é enviada como chave de idempotência. A Stripe aceita o header Idempotency-Key. Em provedores que não oferecem algo equivalente, uso o campo de referência externa e faço uma busca antes de recriar. Cada adapter implementa isso do jeito que o provedor permite, mas o domínio só vê uma garantia: chamar createPixCharge duas vezes com a mesma referência não cria duas cobranças.
O trade-off é claro: menos disponibilidade automática em troca de nunca cobrar duas vezes. Em sistema financeiro, eu aceito essa troca sem pensar muito.
Como tratar webhooks de vários provedores no mesmo fluxo?
A mesma lógica de tradução vale para a entrada. Cada provedor tem sua rota (/webhooks/asaas, /webhooks/pagarme), porque assinatura, formato e forma de autenticação mudam. Mas, depois de validar, cada parser converte o payload num único evento de domínio:
final readonly class ChargeStatusReported { public function __construct( public string $provider, public string $providerChargeId, public string $providerEventId, public ChargeStatus $status, public CarbonImmutable $occurredAt, ) {} }
Daí para frente existe um único handler, que localiza a cobrança por provider + provider_charge_id (o índice único é composto, porque ids de provedores diferentes podem colidir) e aplica a transição. Deduplicação por provider + providerEventId e proteção contra eventos fora de ordem ficam nesse handler único. Detalhei essa parte em como processar webhooks duplicados e fora de ordem uma única vez, e ela fica bem mais simples quando só existe um formato de evento para tratar.
Um detalhe que ajuda muito: o handler valida a transição no enum, não no adapter. Um método canTransitionTo() em ChargeStatus impede que um webhook atrasado de Pending sobrescreva um Paid, qualquer que seja o provedor de origem.
O que eu não colocaria atrás da abstração?
Nem tudo merece um contrato comum. Algumas coisas são tão diferentes entre provedores que forçar uma interface única produz um contrato cheio de parâmetros opcionais e significados ambíguos:
- Onboarding e KYC de subcontas. Os campos, documentos e fluxos de aprovação variam demais. Prefiro um módulo de onboarding específico por provedor e uma interface mínima só para consultar se a conta está apta a receber.
- Regras de split. O cálculo de quanto vai para cada recebedor é do domínio e deve viver fora do gateway, como mostrei em split de pagamento sem perder centavos. O adapter só traduz o resultado para o formato do provedor e declara
Capability::Splitquando suporta. - Funcionalidades exclusivas. Se só um provedor tem determinado recurso e o produto precisa dele, uso direto, numa classe identificada com o nome do provedor. Abstrair algo com uma única implementação é especulação.
Como testar os adapters sem depender do sandbox?
Trabalho em duas camadas. Nos testes de domínio, uso um FakeGateway que implementa a interface e permite programar respostas, inclusive timeouts. É ele que cobre as regras de fallback e de transição de status. Nos testes de cada adapter, uso Http::fake com payloads reais copiados do sandbox, e escrevo um teste para cada status mapeado. Quando o provedor inventa um status novo, o UnknownProviderStatus em produção me avisa, e o payload vira mais um caso de teste.
Também mantenho um teste de contrato compartilhado, uma trait executada contra todos os adapters, verificando coisas como: valor sempre volta em centavos, provider sempre preenchido e a mesma referência não gerando duas cobranças.
Conclusão prática
Se você já tem uma integração acoplada e quer chegar nesse desenho sem reescrever tudo, a ordem que funciona é:
- Adicione
providereprovider_charge_idna tabela de cobranças e preencha o histórico. Sem isso, nada do resto é seguro. - Crie o enum
ChargeStatuse substitua toda comparação com string do provedor por ele. - Extraia a integração atual para um adapter que implemente a interface, mantendo o comportamento idêntico.
- Unifique os webhooks num evento de domínio com um handler único.
- Só então adicione o segundo provedor, e deixe o fallback automático restrito aos casos em que a chamada comprovadamente não teve efeito.
O ganho real não é poder trocar de gateway em um dia. É que o restante do sistema para de saber que gateways existem, e qualquer mudança do lado deles fica contida num único arquivo.