asdrubalp9/payment-gateway

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

Maintainers

Package info

gitlab.com/asdrubalp9/laravel-payment-gateway

Issues

pkg:composer/asdrubalp9/payment-gateway

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

v2.0.0 2026-07-28 20:10 UTC

This package is auto-updated.

Last update: 2026-07-29 11:28:29 UTC


README

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

Features

  • Two drivers out of the box: dLocal (Go) and Mercado Pago, selectable per request
  • Common contract: checkout(), payments(), subscriptions() work the same regardless of driver
  • 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 for both drivers, handled before events are dispatched
  • 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 or mercadopago)
PAYMENT_GATEWAY_DRIVER=mercadopago

# dLocal credentials
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

# 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 without changing any other code.

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

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. Both drivers expose a refunds() service:

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), no cast is required — refunds() is already part of the concrete class.

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.

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

DriverProductCommon contract
dLocalCheckout (Go)checkout()->create()
dLocalPayment querypayments()->find()
dLocalSubscriptionssubscriptions()->createPlan() / subscribe() / cancelSubscription()
dLocalRefundsrefunds()->refund() (driver-specific)
Mercado PagoCheckout Pro (Preferences)checkout()->create()
Mercado PagoPayment querypayments()->find()
Mercado PagoPreapproval subscriptionssubscriptions()->createPlan() / subscribe() / cancelSubscription()
Mercado PagoRefundsrefunds()->refund() (driver-specific)

Testing

composer test

For coverage:

composer test-coverage

License

MIT