puntodev/mercadopago
A Laravel/PHP integration for MercadoPago that simplifies creating payment preferences, handling checkout flows, and processing webhooks, giving developers a convenient wrapper for adding MercadoPago payments to their applications.
Check the platform constraint before anything else. composer.json requires PHP ^8.5 and illuminate/support ^13.0. On a typical Laravel 10/11/12 app Composer will simply refuse to install this. Confirm your runtime matches before you plan any work around it.
Then:
composer require puntodev/mercadopago — package discovery registers MercadoPagoServiceProvider and the MercadoPago facade alias.php artisan vendor:publish and pick the package's tag/provider — check MercadoPagoServiceProvider::publishes()), then set in .env:
MERCADOPAGO_API_CLIENT_IDMERCADOPAGO_API_CLIENT_SECRETSANDBOX_GATEWAYS=true locally
These are OAuth2 client credentials, not an access token. There is no MERCADOPAGO_ACCESS_TOKEN in this package.src/MercadoPagoApi.php — five methods: createPaymentPreference, findMerchantOrders, findMerchantOrderById, findPayments, findPaymentById.src/PaymentPreferenceBuilder.php + PaymentPreferenceItemBuilder.php — the only payload shapes you have to learn.src/MercadoPagoClient.php — token caching and the usingSandbox() flag.First use case: create a Checkout Pro preference and redirect the buyer.
use Puntodev\MercadoPago\Facades\MercadoPago;
$client = MercadoPago::defaultClient(); // reads client_id/secret from config
$preference = $client->createPaymentPreference(
// PaymentPreferenceBuilder fluent setters: items, payer, back_urls,
// notification_url, external_reference, payment_methods, expiration, binary_mode.
// Confirm exact method names in the builder before writing the call.
$builder->toArray()
);
$order->update(['mp_preference_id' => $preference['id']]);
$url = MercadoPago::usingSandbox()
? $preference['sandbox_init_point']
: $preference['init_point'];
return redirect($url);
Note what is absent: there is no payment()->create(), no preference get(), no webhook controller, no signature validator. The description's "webhooks" and "payment handling" bullets describe MercadoPago's capabilities generally — you implement those pieces yourself, using findPaymentById to read state.
Keep the facade out of controllers. Bind a MercadoPagoGateway in a service provider that takes the MercadoPago factory and exposes your domain methods (createCheckout(Order $order): string, syncPayment(string $id): ?Payment). Your controllers and jobs then speak in Order, not in SDK arrays, and you can swap the gateway for a fake in tests.
Persist the preference immediately — you can never fetch it back. createPaymentPreference is the only preference method. Store id, init_point, sandbox_init_point, and the payload you sent on the order row before redirecting. That stored payload is your only record if a dispute arises.
Use findPayments as your reconciliation workhorse. This is the pattern you will use daily:
$payments = $client->findPayments([
'external_reference' => (string) $order->id,
'sort' => 'date_created',
'criteria' => 'desc',
]);
foreach ($payments['results'] ?? [] as $p) {
if ($p['status'] === 'approved'
&& (float) $p['transaction_amount'] === (float) $order->total
&& $p['currency_id'] === $order->currency) {
$order->markAsPaid($p['id']);
}
}
findMerchantOrders / findMerchantOrderById complement this when one payment covers several order lines.
Webhook is yours to build. A route that is withoutMiddleware(VerifyCsrfToken::class), public but rate-limited:
x-signature (HMAC-SHA256 over the id:...;request-id:...;ts:...; manifest) with your MERCADOPAGO_WEBHOOK_SECRET — the package does not do this.mercadopago_notifications table.findPaymentById($id) and verify status, external_reference, amount, and currency before touching the order.200 fast — MercadoPago times out and retries.Idempotency everywhere. Unique index on the MercadoPago payment id plus firstOrCreate (or DB::transaction + lockForUpdate) inside the job. Retried approved notifications are normal, and so are duplicate deliveries to your queue.
Never fulfil from back_urls. The success redirect is a UI hint only — the user can close the tab. Render "processing / confirmed" there based on your own order state.
Multi-tenant / multi-account. MercadoPagoClient is readonly; use MercadoPago::withCredentials($id, $secret) to get a new client per tenant instead of mutating config. That is Octane-safe and keeps tokens separate. Wrap it in a ClientResolver::forTenant($tenant).
Testing. The client uses Laravel's Http facade, so Http::fake() covers you — but remember to fake both POST /oauth/token and the API call, and flush the mercadopago-token-* cache key between tests (or use the array cache driver). Do not copy the package's own MercadoPagoApiTest approach: those tests make real HTTP calls against a real test account.
A nightly sweep pays for itself. Orders stuck in pending for >30 min: call findPayments(['external_reference' => $id]) and reconcile. Webhooks get lost; this catches them.
Http::withToken($token)->post('https://api.mercadopago.com/v1/payments', ...) yourself — the package won't get in your way, but it won't help either.api.mercadopago.com; the environment is decided by which credentials you use. SANDBOX_GATEWAYS only flips the usingSandbox() flag so you can pick sandbox_init_point vs init_point. Mixing a production URL with test credentials produces the classic "payment not found".getToken() does Cache::remember("mercadopago-token-{$clientId}", 1000, ...). After rotating credentials in production you have up to ~17 minutes of 401s. Run Cache::forget("mercadopago-token-{$oldClientId}") in your rotation path. If staging and production share a Redis/cache namespace and a client id, they will share tokens — namespace your cache store or prefix.->throw(). Failures surface as Illuminate\Http\Client\RequestException. Catch it at the gateway boundary and log $e->response->body() — MercadoPago's error body explains the validation failure far better than the exception message.auto_return is hardcoded to all. MercadoPago rejects a preference with auto_return but no valid back_urls. Also, pending and failure silently fall back to the success URL — a typo'd failure route won't error, it will just send everyone to the success page. Assert your back URLs in a test.ARS. Selling in USD/BRL without overriding the currency on the item gets you rejections or mismatched totals. Check the item builder for the currency setter and always set it explicitly.unit_price is rounded to two decimals by the builder. Pass 99.90, never integer cents. This is the single most common cause of a 100× charge.excluded_payment_types is omitted entirely when empty, and the expiration block only appears when an expiration is set (formatted with MP_DATE_TIME_FORMAT). Passing [] to "reset" exclusions does nothing — build a fresh builder instead.defaultClient() is a container singleton. It captures config at resolution. If you mutate config at runtime (Config::set()), the already-resolved singleton keeps the old credentials — the reason the withCredentials() per-call pattern is preferable, especially under Octane.MercadoPagoApi for your own decorator, or call the REST API directly with the same OAuth token. The interfaces are the public contract, so a fake implementation also makes a clean test double.^7.0 is safe and low-churn. Mirror the same approach in your own integration tests: look up or create fixtures dynamically rather than hardcoding MercadoPago IDs.How can I help you explore Laravel packages today?