Search by

asdrubalp9 / payment-gateway

Drup9

Laravel multi-driver payment gateway package (dLocal + Mercado Pago) with multitenant credential support

Package info

gitlab.com/asdrubalp9/laravel-payment-gateway

Issues

pkg:composer/asdrubalp9/payment-gateway

Statistics

Installs: 48

Dependents: 0

Suggesters: 0

Stars: 0

v2.4.0 2026-09-26 18:01 UTC

This package is auto-updated.

Last update: 2026-09-26 21:01:29 UTC


README

Laravel multi-driver payment gateway package with a unified API for dLocal Direct, dLocal Go, and Mercado Pago, multitenant credential support, and normalized webhook events.

Features

  • Three drivers out of the box: dLocal Direct, dLocal Go, and Mercado Pago, selectable per request. dLocal Direct and dLocal Go are two different dLocal products with separate APIs — see dLocal Direct vs dLocal Go before picking one
  • Common contract: checkout(), payments(), subscriptions() work the same regardless of driver — except dLocal Go, which has no subscriptions() (dLocal Go has no server-side "subscribe this customer" call; see plans() for what it offers instead)
  • Multitenant credentials: plug in a CredentialResolver to load per-tenant keys at runtime
  • Normalized webhook events: PaymentSucceeded, PaymentFailed, PaymentRefunded, SubscriptionRenewed, SubscriptionCancelled — fire for any driver
  • Raw webhook events: DlocalWebhookReceived, MercadoPagoWebhookReceived for driver-specific payloads
  • Signature verification: HMAC-based, handled before events are dispatched — for dlocal and mercadopago on the package's own webhook route; dlocalgo verifies the same way but needs its own route (see Webhooks)
  • Structured exceptions: typed hierarchy with a retryable flag on GatewayUnavailableException

Requirements

  • PHP 8.1+
  • Laravel 10, 11, or 12

Installation

composer require asdrubalp9/payment-gateway

Publish the config:

php artisan vendor:publish --tag=payment-gateway-config

Configuration

# Default driver (dlocal, dlocalgo, or mercadopago)
PAYMENT_GATEWAY_DRIVER=mercadopago

# dLocal Direct credentials (driver: dlocal)
DLOCAL_BASE_URL=https://api-mc.dlocal.com
DLOCAL_SANDBOX=false
DLOCAL_X_LOGIN=your-x-login
DLOCAL_X_TRANS_KEY=your-x-trans-key
DLOCAL_SECRET=your-secret

# dLocal Go credentials (driver: dlocalgo)
DLOCALGO_BASE_URL=https://api.dlocalgo.com
DLOCALGO_API_KEY=your-api-key
DLOCALGO_SECRET_KEY=your-secret-key
DLOCALGO_TIMEOUT=20

# Mercado Pago credentials
MERCADOPAGO_SANDBOX=false
MERCADOPAGO_ACCESS_TOKEN=your-access-token
MERCADOPAGO_WEBHOOK_SECRET=your-webhook-secret

The published config/payment-gateway.php also lets you set a custom credential_resolver class and tenant_context class (see Multitenant section).

Single-tenant usage

The Payments facade proxies to the configured default driver. Use driver() to target a specific one.

use Asdrubalp9\PaymentGateway\Facades\Payments;
use Asdrubalp9\PaymentGateway\Data\CheckoutRequest;
use Asdrubalp9\PaymentGateway\Data\Customer;
use Money\Money;
use Money\Currency;

$request = new CheckoutRequest(
    amount: Money::of(1500, new Currency('BRL')),
    customer: new Customer(
        id: 'usr_123',
        name: 'Jane Doe',
        email: 'jane@example.com',
    ),
    successUrl: 'https://example.com/success',
    failureUrl: 'https://example.com/failure',
    notificationUrl: 'https://example.com/payment-gateway/webhook/mercadopago',
    description: 'Order #1042',
    externalReference: 'order_1042',
);

$response = Payments::driver('mercadopago')->checkout()->create($request);

return redirect($response->redirectUrl);

Use Payments::driver('dlocal') to switch to dLocal Direct, or Payments::driver('dlocalgo') to switch to dLocal Go, without changing any other code. Both return a driver with checkout() and payments(); only dlocal and mercadopago also have subscriptions().

Multitenant usage

1. Implement CredentialResolver

use Asdrubalp9\PaymentGateway\Contracts\CredentialResolver;
use Asdrubalp9\PaymentGateway\Contracts\Credentials;
use Asdrubalp9\PaymentGateway\Drivers\MercadoPago\MercadoPagoCredentials;

class DatabaseCredentialResolver implements CredentialResolver
{
    public function resolve(string $tenantId): Credentials
    {
        $tenant = Tenant::findOrFail($tenantId);

        return new MercadoPagoCredentials(
            accessToken: $tenant->mp_access_token,
            sandbox: $tenant->mp_sandbox,
        );
    }
}

2. Implement TenantContext

use Asdrubalp9\PaymentGateway\Contracts\TenantContext;

class CurrentTenantContext implements TenantContext
{
    public function currentTenantId(): string
    {
        return auth()->user()->tenant_id;
    }
}

3. Bind in a service provider

use Asdrubalp9\PaymentGateway\Contracts\CredentialResolver;
use Asdrubalp9\PaymentGateway\Contracts\TenantContext;

public function register(): void
{
    $this->app->bind(CredentialResolver::class, DatabaseCredentialResolver::class);
    $this->app->bind(TenantContext::class, CurrentTenantContext::class);
}

Alternatively, set the class names in config/payment-gateway.php:

'credential_resolver' => DatabaseCredentialResolver::class,
'tenant_context'      => CurrentTenantContext::class,

4. Explicit credential override with withCredentials()

When you need to bypass the resolver for a single call:

use Asdrubalp9\PaymentGateway\Drivers\MercadoPago\MercadoPagoCredentials;

$creds = new MercadoPagoCredentials(accessToken: $token, sandbox: false);

$response = Payments::driver('mercadopago')
    ->checkout()
    ->withCredentials($creds)
    ->create($request);

withCredentials() is available on checkout(), payments(), and subscriptions() — dLocal Go has no subscriptions(), so it only applies to checkout() and payments() there.

Operations

Checkout

// Create a checkout session and redirect
$response = Payments::driver('mercadopago')->checkout()->create($request);
// $response->redirectUrl — send the customer here
// $response->paymentId  — store to reconcile later

Payment query

$payment = Payments::driver('mercadopago')->payments()->find('PAY_123');
// $payment->id, $payment->status, $payment->amount, $payment->currency

Raw response body (dLocal Go only)

CheckoutResponse::$rawBody and Payment::$rawBody carry the exact HTTP body the gateway sent, as a string, undecoded — $raw and $rawBody/json_encode($raw) are not interchangeable: re-encoding a decoded array does not reproduce the same bytes (a decimal amount like 3000.00 becomes 3000, spacing and escapes change).

$payment = Payments::driver('dlocalgo')->payments()->find('DP_123');
// $payment->raw     — decoded array, same as before 2.4.0
// $payment->rawBody — exact bytes dLocal Go returned, or null

On error, PaymentGatewayException::getRawResponseBody() is the same idea for a failed response — including one that is not JSON at all, such as HTML from a proxy on a 502, where getResponseBody() still returns [] because there was nothing to decode:

use Asdrubalp9\PaymentGateway\Support\LogSanitizer;

try {
    Payments::driver('dlocalgo')->checkout()->create($request);
} catch (GatewayUnavailableException $e) {
    Log::warning('dlocalgo checkout failed', LogSanitizer::sanitize([
        'response_body' => $e->getResponseBody(),        // [] if the body was not JSON
        'raw_response_body' => $e->getRawResponseBody(),  // the body as sent, JSON or not — masked below
    ]));
}

Only the dlocalgo driver fills these two fields. dlocal (Direct) and mercadopago leave rawBody / getRawResponseBody() as null — see the Changelog.

Personal data warning. A dLocal Go payment query response carries the payer's name, email, national ID (document_type + document), card issuer, BIN, and last four digits. Treat rawBody and getRawResponseBody() like any other personal data: never log them without LogSanitizer::sanitize() (it masks the keys raw_body and raw_response_body), and encrypt them if your application persists them.

Subscriptions

use Asdrubalp9\PaymentGateway\Data\SubscriptionPlanRequest;
use Asdrubalp9\PaymentGateway\Data\Customer;
use Asdrubalp9\PaymentGateway\Data\Frequency;

$subs = Payments::driver('mercadopago')->subscriptions();

// Create a plan
$plan = $subs->createPlan(new SubscriptionPlanRequest(
    name: 'Pro Monthly',
    amount: Money::of(2990, new Currency('BRL')),
    frequency: new Frequency(interval: 1, unit: 'months'),
    description: 'Pro plan billed monthly',
));

// Subscribe a customer
$subscription = $subs->subscribe($plan->id, new Customer(
    id: 'usr_123',
    name: 'Jane Doe',
    email: 'jane@example.com',
));

// Cancel
$subs->cancelSubscription($plan->id, $subscription->id);

Other subscription methods: findPlan(), updatePlan(), findSubscription().

Driver-specific methods

Operations not in the common contract (like refunds) are accessed directly on the driver. dlocal and mercadopago expose a refunds() service; dlocalgo does not (no refund endpoint is wired for that product yet):

use Money\Money;
use Money\Currency;

// Full refund
Payments::driver('dlocal')->refunds()->refund('PAY_123');

// Partial refund
Payments::driver('mercadopago')->refunds()->refund('PAY_456', Money::of(500, new Currency('BRL')));

// Look up a refund
Payments::driver('dlocal')->refunds()->find('REF_789');

Since Payments::driver() returns the typed driver class (DlocalDriver / MercadoPagoDriver / DlocalGoDriver), no cast is required — refunds() is already part of the concrete class where it exists. DlocalGoDriver does not implement it, so calling Payments::driver('dlocalgo')->refunds() fails at analysis time, not at runtime.

dLocal Go plans (not subscriptions())

dLocal Go can create and look up a plan, but it has no server-side call to subscribe a customer to it — the person subscribes by visiting a URL. Because of that, DlocalGoPlanService does not implement Contracts\SubscriptionGateway (that contract requires subscribe(planId, customer, options): Subscription, a call Go does not have). It is its own type, reached through plans():

use Asdrubalp9\PaymentGateway\Data\Frequency;
use Asdrubalp9\PaymentGateway\Drivers\DlocalGo\Data\DlocalGoPlanRequest;
use Money\Money;
use Money\Currency;

$plan = Payments::driver('dlocalgo')->plans()->create(new DlocalGoPlanRequest(
    name: 'Pro Monthly',
    description: 'Pro plan billed monthly',
    amount: new Money(9990, new Currency('CLP')),
    frequency: Frequency::monthly(),
    successUrl: 'https://example.com/ok',
    backUrl: 'https://example.com/back',
    notificationUrl: 'https://example.com/webhook',
));

// Send the customer here — this is how they subscribe, there is no
// server-side subscribe() call for dLocal Go.
return redirect($plan->subscribeUrl);

// Look up a plan later
$same = Payments::driver('dlocalgo')->plans()->find($plan->id);

$plan->subscribeUrl always comes from the API response, never built by hand from $plan->planToken: in sandbox it points at checkout-sbx.dlocalgo.com, in production at checkout.dlocalgo.com, and the two are not interchangeable.

day_of_month on DlocalGoPlanRequest only applies to a Frequency::monthly() plan — passing it with any other frequency throws InvalidArgumentException when the request is built, before anything reaches the network.

Webhooks

The package registers a single route automatically:

POST /payment-gateway/webhook/{driver}

Examples: /payment-gateway/webhook/mercadopago, /payment-gateway/webhook/dlocal.

Set this URL as the notification endpoint in your gateway dashboard and in your CheckoutRequest::$notificationUrl.

Signature verification is performed automatically before any event is dispatched. Requests with an invalid signature receive a 401 response.

dLocal Go webhooks are not served by this route

/payment-gateway/webhook/dlocalgo responds 501 Not Implemented. This is deliberate, not a bug: this route verifies the signature before looking at which merchant the notification belongs to, using credentials resolved from config or from your CredentialResolver. dLocal Go's notification body is only {"payment_id": "DP-..."} — it does not identify the merchant, so there is no way to know which credentials to verify with until the payment has been looked up. That lookup has to happen first, which this generic route cannot do.

If you use dlocalgo, mount your own route instead:

use Asdrubalp9\PaymentGateway\Drivers\DlocalGo\DlocalGoWebhookVerifier;

Route::post('/webhooks/dlocalgo', function (Illuminate\Http\Request $request) {
    $paymentId = $request->json('payment_id');

    // 1. Look up which organization this payment belongs to (your own data),
    //    then resolve that organization's DlocalGoCredentials.
    $credentials = /* ... */;

    // 2. Only now can the signature be verified.
    $verified = (new DlocalGoWebhookVerifier())->verifica(
        $request->getContent(),
        $request->header('Authorization', ''),
        $credentials,
    );

    if (! $verified) {
        return response('Invalid signature', 401);
    }

    // 3. Confirm the payment and act on it, e.g. with Payments::driver('dlocalgo')->payments()->find($paymentId).
});

Listening to normalized events

These events fire for any driver and carry a NormalizedEvent payload with common fields:

use Asdrubalp9\PaymentGateway\Events\PaymentSucceeded;
use Asdrubalp9\PaymentGateway\Events\PaymentFailed;
use Asdrubalp9\PaymentGateway\Events\PaymentRefunded;
use Asdrubalp9\PaymentGateway\Events\SubscriptionRenewed;
use Asdrubalp9\PaymentGateway\Events\SubscriptionCancelled;

Event::listen(PaymentSucceeded::class, function ($event) {
    // $event->normalizedEvent->paymentId
    // $event->normalizedEvent->externalReference
    // $event->normalizedEvent->status
    // $event->normalizedEvent->driver  ('dlocal' | 'mercadopago')
    Order::markPaid($event->normalizedEvent->externalReference);
});

Listening to raw driver events

For driver-specific payload access:

use Asdrubalp9\PaymentGateway\Drivers\Dlocal\Events\DlocalWebhookReceived;
use Asdrubalp9\PaymentGateway\Drivers\MercadoPago\Events\MercadoPagoWebhookReceived;

Event::listen(DlocalWebhookReceived::class, function ($event) {
    // $event->payload — raw array from dLocal
});

Event::listen(MercadoPagoWebhookReceived::class, function ($event) {
    // $event->payload — raw array from Mercado Pago
});

Both normalized and raw events are dispatched for every verified webhook.

Error handling

All exceptions extend PaymentGatewayException:

ExceptionWhen it fires
InvalidCredentialsExceptionCredentials rejected by the gateway (401/403)
ValidationExceptionRequest payload rejected by the gateway (422)
GatewayUnavailableExceptionGateway timeout or 5xx — isRetryable() returns true
PaymentDeclinedExceptionPayment was declined by the issuer
NotFoundExceptionPayment, plan, or subscription ID not found (404)
use Asdrubalp9\PaymentGateway\Exceptions\PaymentGatewayException;
use Asdrubalp9\PaymentGateway\Exceptions\GatewayUnavailableException;
use Asdrubalp9\PaymentGateway\Exceptions\PaymentDeclinedException;

try {
    $response = Payments::driver('dlocal')->checkout()->create($request);
} catch (GatewayUnavailableException $e) {
    // safe to retry
    dispatch(new RetryCheckoutJob($request))->delay(30);
} catch (PaymentDeclinedException $e) {
    return back()->withErrors('Payment was declined. Please try a different card.');
} catch (PaymentGatewayException $e) {
    report($e);
    return back()->withErrors('Payment could not be processed.');
}

Supported gateways and products

dLocal Direct vs dLocal Go

dLocal sells two separate products with two separate APIs. Picking the wrong one from a label like "dLocal (Go)" means authentication fails against an API that is not the one you meant to use. Check this table before choosing a driver:

dlocaldlocalgo
ProductdLocal DirectdLocal Go
Onboardingmerchant integrationself-service
Base URLapi-mc.dlocal.comapi.dlocalgo.com
AuthX-Login + X-Trans-Key + per-request signatureBearer <api_key>:<secret_key>
Credentialsthreetwo
DriverProductCommon contract
dLocal DirectCheckoutcheckout()->create()
dLocal DirectPayment querypayments()->find()
dLocal DirectSubscriptionssubscriptions()->createPlan() / subscribe() / cancelSubscription()
dLocal DirectRefundsrefunds()->refund() (driver-specific)
dLocal GoCheckoutcheckout()->create()
dLocal GoPayment querypayments()->find()
dLocal GoPlans (self-service subscribe link)plans()->create() / find() (driver-specific — see dLocal Go plans)
dLocal GoWebhook verificationverifier()->verifica() (driver-specific — see Webhooks)
Mercado PagoCheckout Pro (Preferences)checkout()->create()
Mercado PagoPayment querypayments()->find()
Mercado PagoPreapproval subscriptionssubscriptions()->createPlan() / subscribe() / cancelSubscription()
Mercado PagoRefundsrefunds()->refund() (driver-specific)

dLocal Go has no subscriptions() or refunds() — neither is wired up for that driver. It does have plans(), which is not subscriptions(): it can create and look up a plan, but subscribing a customer only happens by visiting the URL the plan returns, never by a server-side call (see dLocal Go plans).

Testing

composer test

For coverage:

composer test-coverage

License

MIT