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 does not offer that product) - 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
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:
| 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 | 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.
Testing
composer test
For coverage:
composer test-coverage
License
MIT