asdrubalp9 / payment-gateway
Laravel multi-driver payment gateway package (dLocal + Mercado Pago) with multitenant credential support
Requires
- php: ^8.1
- guzzlehttp/guzzle: ^7.0
- illuminate/events: ^10.0|^11.0|^12.0
- illuminate/http: ^10.0|^11.0|^12.0
- illuminate/routing: ^10.0|^11.0|^12.0
- illuminate/support: ^10.0|^11.0|^12.0
- mercadopago/dx-php: ^3.0
- moneyphp/money: ^4.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0|^10.0
- phpunit/phpunit: ^10.0|^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
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 nosubscriptions()(dLocal Go has no server-side "subscribe this customer" call; seeplans()for what it offers instead) - Multitenant credentials: plug in a
CredentialResolverto load per-tenant keys at runtime - Normalized webhook events:
PaymentSucceeded,PaymentFailed,PaymentRefunded,SubscriptionRenewed,SubscriptionCancelled— fire for any driver - Raw webhook events:
DlocalWebhookReceived,MercadoPagoWebhookReceivedfor driver-specific payloads - Signature verification: HMAC-based, handled before events are dispatched — for
dlocalandmercadopagoon the package's own webhook route;dlocalgoverifies 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. TreatrawBodyandgetRawResponseBody()like any other personal data: never log them withoutLogSanitizer::sanitize()(it masks the keysraw_bodyandraw_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:
| Exception | When it fires |
|---|---|
InvalidCredentialsException | Credentials rejected by the gateway (401/403) |
ValidationException | Request payload rejected by the gateway (422) |
GatewayUnavailableException | Gateway timeout or 5xx — isRetryable() returns true |
PaymentDeclinedException | Payment was declined by the issuer |
NotFoundException | Payment, 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:
dlocal | dlocalgo | |
|---|---|---|
| Product | dLocal Direct | dLocal Go |
| Onboarding | merchant integration | self-service |
| Base URL | api-mc.dlocal.com | api.dlocalgo.com |
| Auth | X-Login + X-Trans-Key + per-request signature | Bearer <api_key>:<secret_key> |
| Credentials | three | two |
| Driver | Product | Common contract |
|---|---|---|
| dLocal Direct | Checkout | checkout()->create() |
| dLocal Direct | Payment query | payments()->find() |
| dLocal Direct | Subscriptions | subscriptions()->createPlan() / subscribe() / cancelSubscription() |
| dLocal Direct | Refunds | refunds()->refund() (driver-specific) |
| dLocal Go | Checkout | checkout()->create() |
| dLocal Go | Payment query | payments()->find() |
| dLocal Go | Plans (self-service subscribe link) | plans()->create() / find() (driver-specific — see dLocal Go plans) |
| dLocal Go | Webhook verification | verifier()->verifica() (driver-specific — see Webhooks) |
| Mercado Pago | Checkout Pro (Preferences) | checkout()->create() |
| Mercado Pago | Payment query | payments()->find() |
| Mercado Pago | Preapproval subscriptions | subscriptions()->createPlan() / subscribe() / cancelSubscription() |
| Mercado Pago | Refunds | refunds()->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