Production-tested patterns for integrating the Mollie Payments API, covering payments, refunds, webhooks, and error handling.
Mollie is one of the most developer-friendly payment service providers in Europe. Their API is clean, their documentation is solid, and their PHP SDK is well-maintained. But moving from sandbox to production reveals subtleties that the quick start guide does not cover.
This article shares patterns refined over years of running Mollie integrations in production Laravel applications.
Always attach metadata to your payments. When a webhook arrives, you need to know which order or subscription triggered it:
$payment = $mollie->payments->create([
'amount' => [
'currency' => 'EUR',
'value' => '29.95',
],
'description' => "Order #{$order->number}",
'redirectUrl' => route('orders.thank-you', $order),
'webhookUrl' => route('webhooks.mollie'),
'metadata' => [
'order_id' => $order->id,
'type' => 'order_payment',
],
]);
Store the Mollie payment ID on your local record immediately after creation. You need this to correlate webhooks and API lookups:
$order->update([
'mollie_payment_id' => $payment->id,
'payment_status' => 'pending',
]);
Different payment methods accept different parameters. Structure your code to handle this:
$params = [
'amount' => $amount,
'description' => $description,
'redirectUrl' => $redirectUrl,
'webhookUrl' => $webhookUrl,
'metadata' => $metadata,
];
if ($method === 'ideal') {
$params['method'] = 'ideal';
$params['issuer'] = $request->input('issuer');
}
if ($method === 'creditcard') {
$params['method'] = 'creditcard';
// SCA is handled by Mollie's hosted checkout
}
if ($method === 'bancontact') {
$params['method'] = 'bancontact';
}
You can let Mollie's hosted payment page handle method selection, or pre-select a method. Pre-selection improves UX by reducing steps, but means you need to handle method-specific flows in your frontend.
Use Mollie's Methods API to fetch available methods with correct pricing for the transaction:
$methods = $mollie->methods->allActive([
'amount' => ['value' => '29.95', 'currency' => 'EUR'],
'resource' => 'payments',
]);
Cache this response (methods change infrequently) but invalidate the cache daily.
Mollie webhooks send only the payment ID. You must fetch the full payment details yourself:
class MollieWebhookController extends Controller
{
public function __invoke(Request $request)
{
// Mollie sends the payment ID in the request body
$paymentId = $request->input('id');
// Dispatch to a queued job for processing
ProcessMollieWebhook::dispatch($paymentId);
return response('', 200);
}
}
The queued job fetches the payment and updates your records:
class ProcessMollieWebhook implements ShouldQueue
{
use InteractsWithQueue, SerializesModels;
public int $tries = 5;
public array $backoff = [30, 60, 300, 900, 3600];
public function __construct(
private string $paymentId,
) {}
public function handle(MollieApiClient $mollie): void
{
$payment = $mollie->payments->get($this->paymentId);
$order = Order::where('mollie_payment_id', $payment->id)->first();
if (! $order) {
Log::warning("Webhook for unknown payment: {$payment->id}");
return;
}
match ($payment->status) {
'paid' => $this->handlePaid($order, $payment),
'failed' => $this->handleFailed($order),
'expired' => $this->handleExpired($order),
'canceled' => $this->handleCanceled($order),
default => null,
};
}
}
Why fetch instead of trusting the webhook payload? Mollie's webhook only contains the ID. Even if it contained full payment data, you should always fetch the authoritative state from Mollie's API. This prevents spoofed webhooks from corrupting your data.
Webhooks may arrive multiple times. Protect against duplicate processing:
private function handlePaid(Order $order, Payment $payment): void
{
if ($order->payment_status === 'paid') {
return; // Already processed
}
DB::transaction(function () use ($order, $payment) {
$order->update([
'payment_status' => 'paid',
'paid_at' => $payment->paidAt,
]);
$order->fulfill();
});
}
$refund = $mollie->paymentRefunds->createForId($paymentId, [
'amount' => [
'currency' => 'EUR',
'value' => '10.00',
],
'description' => "Partial refund for order #{$order->number}",
]);
Track refunds locally. Create a refund record linked to both the order and the original payment. You need this for reconciliation and customer support.
Mollie sends a webhook when a refund status changes. The same payment webhook endpoint receives these notifications. When you fetch the payment, check its refunds:
$payment = $mollie->payments->get($paymentId, ['include' => 'refunds']);
foreach ($payment->refunds() as $refund) {
// Update local refund records
}
Wrap Mollie API calls in try-catch blocks that distinguish between retryable and permanent errors:
try {
$payment = $mollie->payments->create($params);
} catch (ApiException $e) {
if ($e->getCode() >= 500) {
// Mollie server error, retry later
throw $e; // Let the queue retry
}
if ($e->getCode() === 422) {
// Validation error, do not retry
Log::error("Mollie validation error", [
'params' => $params,
'error' => $e->getMessage(),
]);
throw new PaymentCreationFailed($e->getMessage());
}
}
Mollie's API has rate limits. In high-volume scenarios:
Mollie's test mode uses separate API keys. Configure per environment:
// config/services.php
'mollie' => [
'key' => env('MOLLIE_KEY'),
],
Use test API keys in development and staging. Test mode supports simulated payment methods that complete instantly.
During development, Mollie cannot reach your localhost. Options:
ngrok or expose to tunnel your local serverMollie's API is well designed, but production usage demands attention to webhook reliability, idempotency, and error handling. Invest in these patterns early. They prevent the kind of payment discrepancies that erode customer trust and create accounting nightmares.
Whether you're modernizing your infrastructure, navigating compliance, or building new software - we can help.
Book a 30-min Call