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: 4

Dependents: 0

Suggesters: 0

Stars: 0

v2.2.0 2026-09-20 20:18 UTC

This package is auto-updated.

Last update: 2026-09-21 09:47:32 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 does not offer that product)
  • 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

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.

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 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.

Testing

composer test

For coverage:

composer test-coverage

License

MIT