omnipay/mollie
Omnipay Mollie is a Mollie gateway driver for the Omnipay PHP payment library. It lets Laravel and PHP apps create payments, handle redirects and webhooks, and process refunds through a consistent API, simplifying Mollie integration into existing Omnipay checkout flows.
Install
composer require omnipay/mollie
# if you don't already have a PSR-18/PSR-7 implementation registered:
composer require guzzlehttp/guzzle php-http/guzzle7-adapter
The driver sits on top of omnipay/common (v3 recommended) and, in current versions, the official mollie/mollie-api-php client. Check composer.json in the package once to confirm which HTTP stack it pulls in — it determines whether you debug via Omnipay's getHttpClient() or via $gateway->getApi().
Configure
// config/services.php
'mollie' => [
'key' => env('MOLLIE_API_KEY'), // test_xxxxxxxx or live_xxxxxxxx
],
// app/Providers/AppServiceProvider.php
$this->app->singleton(\Omnipay\Mollie\Gateway::class, function () {
return \Omnipay\Omnipay::create('Mollie')
->setApiKey(config('services.mollie.key'));
});
First use case — a redirect (iDEAL / card / Bancontact) payment
$gateway = app(\Omnipay\Mollie\Gateway::class);
$response = $gateway->purchase([
'amount' => '10.00', // major units, string
'currency' => 'EUR',
'description' => 'Order #1042',
'returnUrl' => route('checkout.return', $order),
'notifyUrl' => route('webhooks.mollie'),
'paymentMethod' => 'ideal',
'issuer' => $request->input('issuer'), // optional, from fetchIssuers
'metadata' => ['order_id' => $order->id],
])->send();
if ($response->isRedirect()) {
$order->update(['mollie_payment_id' => $response->getTransactionReference()]);
return $response->redirect(); // or redirect()->away($response->getRedirectUrl())
}
throw new \RuntimeException($response->getMessage());
Where to look first
src/Gateway.php — the supported operations (purchase, completePurchase, fetchTransaction, refund, and whether customer/mandate requests exist in your version).src/Message/*Request.php — the exact parameter names each request reads. This is the only reliable source of truth for paymentMethod, issuer, metadata, cardToken, etc.Never trust the return URL alone. The redirect back to your app only carries ?id=tr_xxxxx; the truth is on Mollie's side.
public function return(Request $request, Order $order)
{
$response = app(Gateway::class)->completePurchase([
'transactionReference' => $request->query('id'),
])->send();
$data = $response->getData();
abort_unless($data['id'] === $order->mollie_payment_id, 403);
if (($data['status'] ?? null) === 'paid'
&& $data['amount']['value'] === $order->amount
&& $data['amount']['currency'] === $order->currency) {
$order->markPaid();
}
return redirect()->route('orders.show', $order);
}
completePurchase is effectively fetchTransaction plus status interpretation — it is a safe, idempotent read; call it as often as you need.
// routes/web.php — outside the "web" middleware group, or exclude from CSRF
Route::post('/webhooks/mollie', MollieWebhookController::class)
->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);
public function __invoke(Request $request)
{
$id = $request->input('id'); // Mollie posts form-encoded `id`
$response = app(Gateway::class)
->fetchTransaction(['transactionReference' => $id])
->send();
if (! $response->isSuccessful()) {
return response('not found', 404);
}
$payment = $response->getData();
ProcessMolliePayment::dispatch($payment); // queued, idempotent
return response('ok', 200); // anything 2xx stops retries
}
Make ProcessMolliePayment idempotent (guard on status transitions, key on $payment['id']). Mollie retries non-2xx responses, so a naive handler will double-credit orders.
$refund = app(Gateway::class)->refund([
'transactionReference' => $order->mollie_payment_id,
'amount' => '10.00', // omit for a full refund
'description' => 'Customer return',
])->send();
if (! $refund->isSuccessful()) {
Log::error('Mollie refund failed', ['msg' => $refund->getMessage(), 'data' => $refund->getData()]);
}
Store the refund's own ID ($refund->getTransactionReference()) — you'll need it if you later want to fetchRefund or reconcile.
Avoid scattering $gateway->purchase([...])->send() through controllers. A thin MollieService (or a single CreateMolliePayment action) keeps parameter names, amount formatting, and the metadata contract in one place and makes it trivial to swap to the official SDK one day.
final class StartMolliePayment
{
public function __construct(private Gateway $gateway) {}
public function __invoke(Order $order, string $method, ?string $issuer = null)
{
return $this->gateway->purchase(array_filter([
'amount' => number_format($order->total, 2, '.', ''),
'currency' => 'EUR',
'description' => "Order {$order->reference}",
'returnUrl' => route('checkout.return', $order),
'notifyUrl' => route('webhooks.mollie'),
'paymentMethod' => $method,
'issuer' => $issuer,
'metadata' => ['order_id' => $order->id],
]))->send();
}
}
iDEAL needs an issuer ID. If your version exposes fetchIssuers, use it and cache the result (the list changes rarely). If not, read GET /v2/methods?include=issuers from Mollie directly and feed the chosen ID back into purchase(['issuer' => ...]).
Omnipay's model is one-shot. If the driver in your version exposes createCustomer/createMandate, use them for SEPA direct debit; for Mollie's newer Subscriptions API you'll usually be better served going straight to mollie/mollie-api-php and keeping the Omnipay gateway only for the initial payment.
Schedule a nightly command that fetches any order in pending/open older than a few hours and re-runs fetchTransaction. Webhooks occasionally get lost during deploys or DNS blips; a sweep closes those gaps.
Amounts are strings in major units. Pass '10.00', not 1000 and not 10.0 or a float. Passing a float can produce 10.0 in the API payload and Mollie rejects malformed amounts. Format with number_format($total, 2, '.', '').
transactionReference is the Mollie payment ID (tr_...). getTransactionId() may return the same value or be empty depending on version — read getTransactionReference() and also keep $response->getData()['id'] as a sanity check.
No setTestMode(). Test vs. live is decided entirely by the API key prefix (test_ / live_). Don't try to toggle it; it silently does nothing.
Webhooks are unsigned. Mollie does not sign the callback. The only safe verification is fetching the payment and comparing id, amount.value, and amount.currency against your stored order. Treat the webhook body as a hint, nothing more.
status is not boolean. The lifecycle is open → pending/authorized → paid, with terminal failures failed, canceled, expired, refunded, charged_back. isSuccessful() on a fetch response generally means "the API call worked", not "the customer paid". Always branch on $data['status'] === 'paid'.
Currency is method-constrained. iDEAL, Bancontact, and several others are EUR-only. Sending USD with paymentMethod => 'ideal' fails at Mollie, not in the driver — you'll get a response error, not an exception.
notifyUrl requirements. Must be publicly reachable over HTTPS, must return 2xx, and should not sit behind auth or CSRF. Mollie appends nothing — the payment ID arrives as the form field id in the POST body.
Mollie retries webhooks. For up to ~a day, on any non-2xx or timeout. Your handler must be idempotent and fast; dispatch a queued job and return 200 immediately.
metadata round-trips. It comes back on the payment object, so you can recover the order in the webhook without a lookup table — but keep it small, JSON-serializable, and never put PII or secrets in it (it's visible in the dashboard).
Card payments need a token. You cannot post raw PAN data; Mollie requires a card token minted by Mollie Components. Look for cardToken/card handling in PurchaseRequest.php; if it's absent, the driver only supports redirect-style methods and you'll need Components on the frontend.
Refund limits. Partial refunds cannot exceed the remaining refundable amount on the payment. Track refunded totals yourself; a second refund attempt fails.
Settlement ≠ payment amount. For some methods the settlementAmount differs from amount due to fees. Don't reconcile payouts against the payment amount.
Error handling. send() may throw InvalidResponseException on transport/HTTP failures (depending on omnipay/common version and the HTTP client). Wrap calls in try/catch and check isSuccessful():
try {
$response = $gateway->purchase($params)->send();
} catch (\Omnipay\Common\Exception\OmnipayException $e) {
report($e);
return back()->withErrors('Payment provider unavailable.');
}
if (! $response->isRedirect() && ! $response->isSuccessful()) {
Log::warning('Mollie', ['message' => $response->getMessage(), 'data' => $response->getData()]);
}
Log getData() in non-production. It contains the full decoded Mollie response — the fastest way to debug a failing iDEAL or an unexpected status.
Test-mode payments don't auto-settle. On a test_ key, iDEAL/card payments sit at open until you flip the status in the dashboard (or Mollie's test controls). Integration tests should stub the gateway rather than await a webhook.
Version drift. The package has had breaking changes around customer/mandate support and around whether it proxies through the official SDK. Pin a major version in composer.json and read CHANGELOG.md before upgrading.
Extension point. For anything the driver doesn't cover (subscriptions, payouts, Idempotency-Key headers), reach for $gateway->getApi() if it exists, or inject \Mollie\Api\MollieApiClient alongside the Omnipay gateway. Mixing both in one service is normal and perfectly fine.
How can I help you explore Laravel packages today?