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
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
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 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:
| 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
| Driver | Product | Common contract |
|---|---|---|
| dLocal | Checkout (Go) | checkout()->create() |
| dLocal | Payment query | payments()->find() |
| dLocal | Subscriptions | subscriptions()->createPlan() / subscribe() / cancelSubscription() |
| dLocal | Refunds | refunds()->refund() (driver-specific) |
| 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) |
Testing
composer test
For coverage:
composer test-coverage
License
MIT