Para integrar vários gateways de pagamento no Laravel sem espalhar o formato de cada provedor pelo código, coloque uma anti-corruption layer entre o domínio e as APIs. Ela é composta por uma interface na língua do seu negócio, um adapter por gateway que traduz status, valores e erros, e um enum de status próprio. O domínio nunca vê paid, RECEIVED ou succeeded, só PaymentStatus::Paid. O custo é manter os mapeamentos e aceitar que nem todo recurso de cada gateway cabe na interface comum.
Escrevo isso depois de ver o mesmo padrão se repetir em mais de um projeto de pagamento. A primeira integração entra rápido, direto no controller ou num service com o nome do gateway. Meses depois, aparece um segundo provedor, por taxa, por um método que o primeiro não tem ou por redundância, e o código do primeiro já está em todo lugar.
Por que o código acoplado ao gateway vira problema?
O sintoma clássico é encontrar if ($charge->status === 'paid') em listeners, relatórios, jobs de conciliação e telas do painel. Esse 'paid' é o status do Pagar.me. Quando entra o Asaas, o equivalente é RECEIVED ou CONFIRMED. No Stripe, um PaymentIntent pago fica em succeeded. Cada if espalhado vira um ponto onde o segundo gateway pode quebrar sem ninguém perceber.
E não é só status:
- Valores: Pagar.me e Stripe trabalham em centavos inteiros, o Asaas recebe e devolve reais com casas decimais.
- Identificadores: cada um tem seu formato de ID de cobrança, de cliente e de evento.
- Erros: cartão recusado, requisição inválida e instabilidade chegam com códigos HTTP e corpos completamente diferentes.
- Webhooks: payload, nomes de evento e forma de autenticação mudam de um provedor para outro.
Se o domínio conhece esses detalhes, trocar ou adicionar um gateway exige mexer no domínio. É exatamente isso que a camada evita.
O que é uma anti-corruption layer em integração de pagamento?
Anti-corruption layer é um termo do Domain-Driven Design: uma camada de tradução que impede que o modelo de um sistema externo "contamine" o modelo do seu sistema. Na prática, em pagamentos, ela tem quatro peças:
| Peça | Responsabilidade |
|---|---|
Interface PaymentGateway |
Define as operações que o negócio precisa, com tipos do domínio |
| DTOs de entrada e saída | Carregam dados já normalizados (centavos, enums, IDs) |
| Adapters por provedor | Traduzem a chamada para a API do gateway e a resposta de volta |
| Exceções do domínio | Classificam falhas em categorias que o negócio sabe tratar |
A diferença para um simples "wrapper do SDK" é a direção: a interface é desenhada a partir do que o seu produto precisa, não a partir do que o gateway oferece.
Como desenhar a interface do gateway no Laravel?
Comece pelas operações que o produto realmente usa. Para uma maquininha com link de pagamento e Pix, normalmente são criar cobrança, consultar, estornar e interpretar webhook.
interface PaymentGateway { public function name(): string; public function createCharge(ChargeRequest $request): ChargeResult; public function fetchCharge(string $providerChargeId): ChargeResult; public function refund(string $providerChargeId, int $amountCents, string $idempotencyKey): RefundResult; public function parseWebhook(Request $request): GatewayEvent; }
Os DTOs são readonly e só aceitam tipos do domínio:
final readonly class ChargeRequest { public function __construct( public string $idempotencyKey, public int $amountCents, public PaymentMethod $method, public Customer $customer, public ?int $installments = null, public array $metadata = [], ) {} } final readonly class ChargeResult { public function __construct( public string $provider, public string $providerChargeId, public PaymentStatus $status, public int $amountCents, public ?string $pixCopyPaste = null, public ?string $failureReason = null, public array $raw = [], ) {} }
Duas decisões aqui merecem comentário. A idempotencyKey é obrigatória no request porque timeout ao criar cobrança é o cenário em que mais se duplica dinheiro; detalhei isso em como evitar cobrança duplicada com Idempotency-Key. E o campo raw guarda a resposta original: o domínio não lê, mas suporte e conciliação vão agradecer quando precisarem investigar um caso estranho.
Como mapear os status de cada gateway para um enum próprio?
O enum do domínio é o coração da camada. Ele precisa ter os estados que o seu negócio distingue, não a união de todos os estados de todos os gateways.
enum PaymentStatus: string { case Pending = 'pending'; case Processing = 'processing'; case RequiresAction = 'requires_action'; case Paid = 'paid'; case Failed = 'failed'; case Canceled = 'canceled'; case Refunded = 'refunded'; case Disputed = 'disputed'; public function isFinal(): bool { return in_array($this, [self::Failed, self::Canceled, self::Refunded], true); } }
Uma tabela de referência ajuda o time a revisar o mapeamento (simplificada, confira a documentação atual de cada provedor):
| Domínio | Pagar.me (charge) | Asaas (payment) | Stripe (PaymentIntent) |
|---|---|---|---|
| Pending | pending |
PENDING, OVERDUE |
requires_payment_method |
| Processing | processing |
— | processing |
| RequiresAction | — | — | requires_action |
| Paid | paid |
RECEIVED, CONFIRMED |
succeeded |
| Failed | failed |
— | — |
| Canceled | canceled |
— | canceled |
| Refunded | — (via evento) | REFUNDED |
— (via Charge/Refund) |
| Disputed | chargedback |
CHARGEBACK_REQUESTED |
— (via Dispute) |
No adapter, o mapeamento vira um match. O detalhe mais importante é o que acontece com status desconhecido:
private function mapStatus(string $status): PaymentStatus { return match ($status) { 'PENDING', 'OVERDUE' => PaymentStatus::Pending, 'RECEIVED', 'CONFIRMED', 'RECEIVED_IN_CASH' => PaymentStatus::Paid, 'REFUNDED' => PaymentStatus::Refunded, 'CHARGEBACK_REQUESTED', 'CHARGEBACK_DISPUTE' => PaymentStatus::Disputed, default => throw new UnknownProviderStatus('asaas', $status), }; }
Nunca coloque um default => PaymentStatus::Pending. Status novo do gateway que cai silenciosamente em "pendente" é cobrança paga que o lojista não vê. Falhar alto, com alerta, é muito melhor: o webhook volta para retry e alguém decide o mapeamento correto.
Repare também que CONFIRMED no Asaas, para cartão, significa pagamento confirmado com saldo ainda não disponível. Mapear para Paid é uma decisão de negócio, não técnica. Se o seu produto precisa diferenciar "pago" de "liquidado", isso pertence ao domínio (geralmente à agenda de recebíveis), e não deveria ser resolvido criando um status por gateway.
Como normalizar valores, IDs e erros?
Valores. Dentro do domínio, tudo é centavo em inteiro. O adapter do Asaas converte na entrada e na saída:
'value' => $request->amountCents / 100, // ... amountCents: (int) round($data['value'] * 100),
O round antes do cast não é enfeite: 19.99 * 100 em ponto flutuante dá 1998.9999..., e o cast direto truncaria um centavo.
IDs. A tabela de cobranças guarda provider e provider_charge_id com índice único composto. O ID do gateway nunca vira chave primária nem aparece em URL do seu produto. Isso permite que a mesma cobrança lógica tenha uma nova tentativa em outro provedor sem colisão.
Erros. Aqui está o ganho que mais aparece no dia a dia. Cada gateway falha de um jeito, mas o negócio só precisa saber três coisas: foi recusado (não adianta tentar de novo), a requisição está errada (bug nosso) ou o gateway está instável (retry depois).
abstract class GatewayException extends RuntimeException { public function __construct( public readonly string $provider, string $message = '', ?\Throwable $previous = null, ) { parent::__construct($message, 0, $previous); } } final class PaymentDeclined extends GatewayException {} final class GatewayRejectedRequest extends GatewayException {} final class GatewayUnavailable extends GatewayException {}
No adapter:
try { $response = $this->http ->withHeaders(['Idempotency-Key' => $request->idempotencyKey]) ->post('/payments', $payload) ->throw(); } catch (ConnectionException $e) { throw new GatewayUnavailable('asaas', 'timeout', $e); } catch (RequestException $e) { if ($e->response->serverError() || $e->response->status() === 429) { throw new GatewayUnavailable('asaas', 'server error', $e); } throw new GatewayRejectedRequest( 'asaas', (string) $e->response->json('errors.0.description'), $e, ); }
Com essa classificação, o job que cria a cobrança sabe que GatewayUnavailable merece release() com backoff, e o circuit breaker com Redis conta só essas falhas, sem abrir o circuito por causa de cartão recusado. Recusa de cartão, aliás, muitas vezes vem com HTTP 200 e status de falha no corpo; o adapter precisa olhar o corpo e lançar PaymentDeclined (ou devolver Failed com failureReason, escolha uma convenção e mantenha em todos os adapters).
Os webhooks passam pela mesma camada?
Passam, e é onde a camada mais se paga. Cada adapter implementa parseWebhook, que faz duas coisas: autentica a requisição do jeito daquele provedor e devolve um GatewayEvent normalizado.
final readonly class GatewayEvent { public function __construct( public string $provider, public string $eventId, public string $providerChargeId, public PaymentStatus $status, public CarbonImmutable $occurredAt, public array $raw, ) {} }
A autenticação é genuinamente diferente entre eles: o Stripe assina o corpo com HMAC no header Stripe-Signature, o Asaas envia um token que você configura no painel no header asaas-access-token. Se estiver depurando uma assinatura HMAC que não bate, o testador de webhook mostra no navegador se o segredo e o payload geram a assinatura recebida.
Depois do parseWebhook, o fluxo é único para todos os gateways: gravar o evento, deduplicar por (provider, eventId), processar em fila. O controller fica com uma rota por provedor, mas uma única implementação, e todo o cuidado com perda e duplicidade, que descrevi em como receber webhooks de gateway sem perder nem duplicar eventos, é escrito uma vez só.
Como escolher o gateway por cliente ou por método de pagamento?
Com a interface no lugar, o roteamento vira configuração. Um resolver decide qual adapter usar para criar uma cobrança nova:
final class GatewayResolver { public function __construct(private Container $container) {} public function forNewCharge(Merchant $merchant, PaymentMethod $method): PaymentGateway { $provider = $merchant->gatewayFor($method) ?? config("payments.default.{$method->value}"); return $this->byName($provider); } public function byName(string $provider): PaymentGateway { $class = config("payments.providers.{$provider}") ?? throw new InvalidArgumentException("Gateway desconhecido: {$provider}"); return $this->container->make($class); } }
A regra que evita o bug mais sutil: roteamento vale só para cobrança nova. Consulta, estorno e webhook sempre usam byName($charge->provider). Se o lojista migrou do Pagar.me para o Asaas ontem, o estorno de uma venda da semana passada precisa ir para o Pagar.me. Parece óbvio escrito assim, mas é fácil cair nisso quando o código de estorno chama forNewCharge por preguiça.
Como lidar com recursos que só um gateway tem?
Esse é o principal trade-off da abordagem. Uma interface única tende ao mínimo denominador comum: se você só expõe o que todos têm, perde split, pré-autorização ou parcelamento específico de quem oferece.
A saída que funciona melhor para mim são interfaces de capacidade separadas:
interface SupportsSplit { /** @param list<SplitRule> $rules */ public function createChargeWithSplit(ChargeRequest $request, array $rules): ChargeResult; } interface SupportsPreAuthorization { public function authorize(ChargeRequest $request): ChargeResult; public function capture(string $providerChargeId, int $amountCents): ChargeResult; }
O adapter implementa só o que o provedor suporta, e o código de negócio pergunta antes:
if (! $gateway instanceof SupportsSplit) { throw new CapabilityNotSupported($gateway->name(), 'split'); }
Isso deixa explícito, no tipo, que o split depende do gateway, em vez de um método que existe em todos e lança exceção em metade deles. E o roteamento pode levar a capacidade em conta: lojista com split configurado só pode ser roteado para gateway que implementa SupportsSplit.
O que não faço é tentar emular no adapter um recurso que o provedor não tem (por exemplo, simular split fazendo transferências depois). Isso é regra de negócio com risco financeiro, e pertence ao domínio, com saga e compensação próprias, não escondida dentro de um adapter.
Como testar que todos os adapters cumprem o contrato?
Com vários adapters, o risco é cada um interpretar a interface de um jeito. Um teste de contrato abstrato, herdado por um teste de cada provedor, resolve:
abstract class PaymentGatewayContractTest extends TestCase { abstract protected function gateway(): PaymentGateway; abstract protected function fakeApprovedPix(int $amountCents): void; abstract protected function fakeServerError(): void; public function test_cobranca_pix_volta_em_centavos_e_status_do_dominio(): void { $this->fakeApprovedPix(1999); $result = $this->gateway()->createCharge(ChargeRequestFactory::pix(1999)); $this->assertSame(1999, $result->amountCents); $this->assertSame($this->gateway()->name(), $result->provider); $this->assertNotEmpty($result->providerChargeId); $this->assertNotNull($result->pixCopyPaste); } public function test_erro_5xx_vira_gateway_unavailable(): void { $this->fakeServerError(); $this->expectException(GatewayUnavailable::class); $this->gateway()->createCharge(ChargeRequestFactory::pix(1000)); } }
Cada AsaasGatewayTest ou PagarmeGatewayTest só implementa os fakes com Http::fake e payloads reais gravados da sandbox. Além disso, mantenho um teste por adapter que percorre a lista de status documentada do provedor e garante que nenhum cai no UnknownProviderStatus sem querer. Quando o gateway anuncia um status novo, o primeiro passo é adicioná-lo a essa lista e ver o teste falhar.
Quando essa camada não vale a pena?
Se o produto tem um gateway só e nenhuma perspectiva real de um segundo, a interface completa com capacidades e resolver é custo antecipado. Mesmo nesse caso, eu faria duas coisas desde o início, porque são baratas e evitam a maior parte da dor depois:
- Enum de status próprio, com o mapeamento num único lugar.
- Valores em centavos e colunas
providereprovider_charge_idna tabela de cobranças.
Com isso, extrair a interface quando o segundo gateway chegar é refatoração localizada, não caça a 'paid' no código inteiro.
Conclusão prática
Se você está para adicionar o segundo gateway, a ordem que eu seguiria é: criar o enum PaymentStatus e migrar todos os if de status para ele; extrair a interface a partir do que o primeiro adapter já faz; classificar erros em recusado, requisição inválida e indisponível; mover a autenticação e o parse de webhook para dentro de cada adapter; e só então escrever o adapter novo, guiado pelo teste de contrato. Duas regras não são negociáveis: status desconhecido falha alto, e qualquer operação sobre uma cobrança existente vai para o gateway que a criou. O resto, incluindo quanto de capacidade específica expor, você ajusta conforme o produto pede.