omnipay/multisafepay
Omnipay MultiSafepay adds a MultiSafepay gateway driver to the Omnipay PHP payment library, letting PHP applications create payments, handle redirects and callbacks, and process refunds through MultiSafepay’s API using a consistent Omnipay interface.
Install the driver alongside the Omnipay core:
composer require omnipay/multisafepay omnipay/common
Create the gateway through the Omnipay factory (so it can be swapped/mocked):
use Omnipay\Omnipay;
$gateway = Omnipay::create('MultiSafepay');
$gateway->setApiKey(config('services.multisafepay.key'));
$gateway->setTestMode(app()->environment('local', 'testing'));
The first use case you should implement is the canonical redirect flow: purchase() → send the customer to MultiSafepay → customer returns → completePurchase(). That covers iDEAL, Bancontact, credit card, PayPal, and most local methods.
$response = $gateway->purchase([
'amount' => '10.00', // decimal string, NOT cents
'currency' => 'EUR',
'transactionId' => $order->id, // your merchant order reference
'returnUrl' => route('payments.return', $order),
'cancelUrl' => route('payments.cancel', $order),
'notifyUrl' => route('payments.webhook'),
'paymentMethod' => 'iDEAL',
'issuer' => $request->input('issuer'), // e.g. 'ING'
'description' => "Order #{$order->id}",
])->send();
if ($response->isRedirect()) {
return redirect($response->getRedirectUrl());
}
throw new \RuntimeException($response->getMessage());
The fastest place to learn the exact field names this driver accepts is the tests/ directory — it is better documentation than the README for method-specific quirks.
Separate "initiate" from "verify". Treat purchase() as fire-and-forget: persist the MultiSafepay transactionid (from $response->getTransactionReference()) against your order, then redirect. Never mark an order paid from the browser return URL alone — the user can hit returnUrl without paying.
Handle the webhook as the source of truth. MultiSafepay calls your notifyUrl with a transactionid query parameter. Do not trust it; re-fetch the status from the API before mutating state:
Route::get('/payments/webhook', function (Request $request, $gateway) {
$response = $gateway->completePurchase([
'transactionId' => $request->query('transactionid'),
])->send();
if ($response->isSuccessful()) {
// idempotent: check the order is not already marked paid
}
return response('OK', 200);
});
Fetch issuers for the payment-method selector. For iDEAL/Bancontact you need the bank list before purchase():
$issuers = $gateway->fetchIssuers(['paymentMethod' => 'iDEAL'])->send()->getIssuers();
// [['id' => 'ABNANL2A', 'name' => 'ABN AMRO'], ...]
Wrap the gateway in a service, not a controller. In Laravel, bind a configured instance in a service provider and inject it. This keeps setApiKey/setTestMode out of controllers and makes swapping the fake driver in tests trivial.
$this->app->singleton(PaymentGateway::class, fn () => Omnipay::create('MultiSafepay')
->setApiKey(config('services.multisafepay.key'))
->setTestMode(config('services.multisafepay.test')));
Correlate with two keys. Keep both your internal transactionId and the MultiSafepay transactionReference in the orders table with a unique index. completePurchase() needs the MultiSafepay one; refunds and support tickets usually need the other. Store both.
Refund and status lookups use the transaction reference:
$gateway->refund(['transactionReference' => $order->msp_ref, 'amount' => '10.00'])->send();
$gateway->fetchTransaction(['transactionReference' => $order->msp_ref])->send();
1000 becomes €1000, not €10. Format at the boundary with number_format($cents / 100, 2, '.', '').completePurchase takes the MultiSafepay ID, not yours. The key is transactionId in Omnipay parlance, but the value is what MultiSafepay sent as transactionid in the callback. Passing your merchant order ID silently returns "not found" or a pending status.paid_at column or a unique constraint on msp_ref so a second callback cannot double-credit. Return HTTP 200 with a plain body; a redirect or JSON envelope can trigger retries.isSuccessful(). MultiSafepay returns initialized, uncleared, completed, declined, cancelled, refunded, partial_refunded, expired. The driver maps the common ones, but uncleared (manually reviewed) is a real state you must model in your order workflow — it is neither success nor failure from the merchant's perspective.setTestMode(true) routes to the MultiSafepay test environment; test API keys only work there and live keys only in live. A silent isRedirect() === false with an auth-style error usually means the key and mode are mismatched.accountId/siteId/securityCode instead of apiKey. If you inherit a codebase using those, do not half-migrate; the driver picks the credential shape at request build time.paymentMethod vs issuer. paymentMethod maps to MultiSafepay's gateway field. Getting the casing wrong ("ideal" vs "iDEAL") is a common 400-error source; check fetchPaymentMethods() output rather than guessing strings.currency against the chosen paymentMethod in your form layer so the API does not reject the request after the user has confirmed.completePurchase() is your only integrity check. Never derive the payment status from query-string data directly.$gateway->getDefaultParameters() plus a custom request class; the abstract base handles the HTTP client so you only map the JSON body.Given the small community (20 stars) and sparse docs, pin the version in composer.json and add integration tests against the test API for every payment method you expose.
How can I help you explore Laravel packages today?