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:

  1. Valores: Pagar.me e Stripe trabalham em centavos inteiros, o Asaas recebe e devolve reais com casas decimais.
  2. Identificadores: cada um tem seu formato de ID de cobrança, de cliente e de evento.
  3. Erros: cartão recusado, requisição inválida e instabilidade chegam com códigos HTTP e corpos completamente diferentes.
  4. 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:

  1. Enum de status próprio, com o mapeamento num único lugar.
  2. Valores em centavos e colunas provider e provider_charge_id na 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.