mercadopago/dx-php
Official Mercado Pago SDK for PHP, making it easy to add payments to Laravel or any PHP app. Create checkout preferences, process payments, manage subscriptions, and handle webhooks and notifications through a clean, object-oriented API.
Install and configure
composer require mercadopago/dx-php
The package ships no Laravel service provider, so wire it yourself. Put credentials in config/services.php (never call env() from a controller — config:cache will break it):
// config/services.php
'mercadopago' => [
'access_token' => env('MP_ACCESS_TOKEN'),
'public_key' => env('MP_PUBLIC_KEY'),
'webhook_secret' => env('MP_WEBHOOK_SECRET'),
'sandbox' => env('MP_SANDBOX', true),
],
// app/Providers/MercadoPagoServiceProvider.php (register())
$this->app->singleton(PreferenceClient::class, function () {
MercadoPagoConfig::setAccessToken(config('services.mercadopago.access_token'));
MercadoPagoConfig::setRuntimeEnviroment(
config('services.mercadopago.sandbox')
? MercadoPagoConfig::LOCAL
: MercadoPagoConfig::SERVER
);
return new PreferenceClient();
});
Register clients as singletons (PreferenceClient, PaymentClient, CustomerClient, …). The SDK keeps config in static state, so configuring it once at container resolution is the cleanest fit.
Where to look first
src/Client/** — one class per resource; method names (create, get, update, search, cancel, refund) map almost 1:1 to API endpoints.src/Resources/** — the typed response objects. $payment->status, $preference->init_point.src/Net/MPSearchRequest.php and src/Net/MPRequestOptions.php — the two escape hatches you will actually use.src/Exceptions/MPApiException.php — always caught, always logged.First useful flow: Checkout Pro
$preference = app(PreferenceClient::class)->create([
'items' => [[
'id' => (string) $order->id,
'title' => "Order #{$order->id}",
'quantity' => 1,
'unit_price' => (float) $order->total,
'currency_id' => 'BRL', // or ARS, MXN, CLP, COP, PEN, UYU
]],
'external_reference' => (string) $order->id,
'notification_url' => route('webhooks.mercadopago'),
'back_urls' => [
'success' => route('checkout.success'),
'failure' => route('checkout.failure'),
'pending' => route('checkout.pending'),
],
'auto_return' => 'approved',
]);
$order->update(['mp_preference_id' => $preference->id]);
return redirect(config('services.mercadopago.sandbox')
? $preference->sandbox_init_point
: $preference->init_point);
init_point and sandbox_init_point both exist on the resource — picking the wrong one is the single most common "it charges real money in dev" bug.
Webhook controller: verify, enqueue, return fast
public function __invoke(Request $request)
{
abort_unless($this->signatureIsValid($request), 403);
if ($request->input('type') === 'payment') {
SyncMercadoPagoPayment::dispatch($request->input('data.id'))
->onQueue('payments');
}
return response()->noContent(); // 204 — do not do work inline
}
private function signatureIsValid(Request $request): bool
{
$header = $request->header('x-signature'); // "ts=1700000000,v1=<hex>"
if (! $header) {
return false;
}
parse_str(str_replace(',', '&', $header), $parts);
$manifest = "id:{$request->input('data.id')};"
. "request-id:{$request->header('x-request-id')};"
. "ts:{$parts['ts']};";
return hash_equals(
hash_hmac('sha256', $manifest, config('services.mercadopago.webhook_secret')),
$parts['v1'] ?? ''
);
}
Job: re-fetch, then reconcile under a lock
public function handle(PaymentClient $client): void
{
$payment = $client->get($this->paymentId); // authoritative source of truth
DB::transaction(function () use ($payment) {
$order = Order::where('id', $payment->external_reference)
->lockForUpdate()
->firstOrFail();
if ($order->mp_payment_id === $payment->id && $order->status === $payment->status) {
return; // idempotent
}
$order->update([
'mp_payment_id' => $payment->id,
'status' => $payment->status, // approved|pending|in_process|rejected|cancelled|refunded|charged_back
'paid_at' => $payment->status === 'approved' ? now() : null,
]);
});
}
Idempotency and per-call options
$options = (new MPRequestOptions())
->setCustomHeaders(['X-Idempotency-Key' => (string) Str::uuid()])
->setTimeout(10);
$client->create($payload, $options);
Reuse a stable key derived from your order (e.g. order-{$order->id}) rather than a fresh UUID, otherwise a retried request after a network timeout can double-charge. MPRequestOptions also carries setAccessToken(), which is how you support marketplaces / multi-tenant sellers without touching the global config.
Reconciliation cron
Webhooks are at-least-once but not guaranteed. Add a scheduled command that searches payments for orders stuck in pending for more than N minutes:
$results = $client->search(new MPSearchRequest(50, 0, [
'external_reference' => (string) $order->id,
'sort' => 'date_created',
'criteria' => 'desc',
]));
foreach ($results->getResults() as $payment) { /* same reconcile logic */ }
Customers and saved cards — create a CustomerClient record keyed to your users.id, then let Checkout/Bricks attach cards. Card tokens must be generated client-side by the MercadoPago JS SDK; never post raw PANs to your server.
Newer Orders API — MercadoPago\Client\Order\OrderClient consolidates preference + payment into one flow and is the direction the platform is moving. Check country availability before committing; Checkout Pro via PreferenceClient remains the safe default.
MercadoPago\SDK::setAccessToken(), new MercadoPago\Preference(), $pref->save(), Payment::find_by_id()) belongs to 2.x. In 3.x everything goes through client classes and typed resources. Don't mix tutorials from both.PaymentClient->get() before mutating an order — payloads can be replayed, or arrive out of order (approved before pending).auto_return requires HTTPS back_urls.success. Valet/Herd .test domains over HTTP will silently drop the redirect. Use a tunnel for local webhook + redirect testing.TEST- access token plus a real account email in the payer object produces confusing 401s and invalid_grant. Create buyer/seller accounts from the panel's Test users section and use those emails.currency_id yields invalid_currency; the error arrives in cause, not message.transaction_amount may come back with float rounding.MPApiException hides the useful part. $e->getMessage() is generic; the actionable detail is $e->getApiResponse()->getContent()['cause'], an array of field-level errors. Log the whole content array at error level.limit caps at 50, offset at 10 000, and date-range filters (begin_date/end_date with range => date_created) max out at 12 months. For backfills, page by id from your own table instead.setTimeout() per call, and only retry create-type calls when you attached an idempotency key.x-signature only after confirming your panel is configured to sign that notification type.notification_url in the panel too, as a fallback. Per-preference URLs override it, but payments created outside your flow (refunds, chargebacks initiated by the buyer) only reach the panel-level URL.$paymentClient->refund($id, $amount) for partial — refunded status lags behind by minutes; treat it asynchronously like any other status transition.Http::fake() here. The SDK uses its own HTTP layer; Laravel's HTTP fake won't intercept it. Swap the transport via MercadoPagoConfig::setHttpClient() (or bind your own thin gateway class) so tests can fake at your boundary.binary_mode removes in_process and makes digital-goods fulfilment simpler, but tends to lower approval rates for physical goods. Leave it off unless you have a reason.expires / expiration_date_to for time-boxed carts, otherwise stale checkouts remain payable indefinitely.get, search), never for un-keyed create.How can I help you explore Laravel packages today?