omnipay/worldpay
Omnipay WorldPay is a PHP gateway that adds WorldPay payment processing to the Omnipay library. It provides a consistent API for purchases, authorizations, captures, refunds, and voids, plus request and response handling for integrating WorldPay into your application.
Omnipay is a gateway-agnostic PHP payment library; omnipay/worldpay adds a driver for WorldPay's classic Select Junior hosted payment page (and, depending on version, FuturePay). It is framework-agnostic, so in Laravel you wire it up yourself.
Install and verify compatibility first
composer require omnipay/worldpay
composer show omnipay/worldpay omnipay/common # check the omnipay/common constraint
The last tagged release is from 2017. On modern PHP/Laravel, require omnipay/common:^3 explicitly and confirm Composer resolves without downgrading Omnipay. If you're on PHP 8, smoke-test the request/response classes before committing.
Where to look first
src/Gateway.php — short name (WorldPay), default parameters, which requests it supports.src/Message/PurchaseRequest.php — exactly which form fields are POSTed to WorldPay (instId, cartId, amount, currency, testMode, MC_callback, etc.).src/Message/CompletePurchaseRequest.php / CompletePurchaseResponse.php — how transStatus, callbackPW, transId and amounts are interpreted.tests/ — fixtures showing real callback payloads; the fastest way to understand expected data.First use case — take a payment
use Omnipay\Omnipay;
use Omnipay\Common\GatewayInterface;
$gateway = Omnipay::create('WorldPay');
$gateway->setInstallationId(config('services.worldpay.installation_id'));
$gateway->setSecret(config('services.worldpay.callback_password'));
$gateway->setTestMode(app()->isLocal());
$response = $gateway->purchase([
'amount' => number_format($order->total_pence / 100, 2, '.', ''), // "10.00", major units
'currency' => $order->currency, // "GBP"
'transactionId' => $order->reference, // echoed back as cartId
'description' => "Order {$order->reference}",
'returnUrl' => route('worldpay.callback'), // absolute, public, no auth
'name' => $order->customer_name,
'email' => $order->customer_email,
])->send();
abort_unless($response->isRedirect(), 502);
// WorldPay requires a POST to the hosted page, so the redirect is an
// auto-submitting HTML form, not a 302. Laravel returns Symfony responses as-is.
return $response->getRedirectResponse();
Then handle the browser return / POST-back:
$response = $gateway->completePurchase([
'transactionId' => $request->input('cartId'),
])->send();
$response->isSuccessful(); // transStatus === 'Y' (+ callbackPW check)
$response->getTransactionReference(); // WorldPay transId — store this
$response->getMessage(); // rawAuthMessage, for logs
Isolate gateway construction in a binding. Don't scatter setInstallationId calls across controllers; bind a configured instance and inject it. Keep the config in config/services.php:
// config/services.php
'worldpay' => [
'installation_id' => env('WORLDPAY_INSTALLATION_ID'),
'callback_password' => env('WORLDPAY_CALLBACK_PASSWORD'),
'test_mode' => env('WORLDPAY_TEST_MODE', false),
],
$this->app->bind(WorldPayGateway::class, fn () => tap(
Omnipay::create('WorldPay'),
fn ($g) => $g
->setInstallationId(config('services.worldpay.installation_id'))
->setSecret(config('services.worldpay.callback_password'))
->setTestMode(config('services.worldpay.test_mode'))
));
Bind as bind, not singleton, unless you're certain the gateway is never reconfigured per request.
Model the payment as a state machine, not a one-shot call. Persist a Payment (or fields on Order) with states pending → paid | failed | cancelled, plus columns for gateway_reference (transId), raw_auth_code, raw_auth_message, and the raw callback JSON. This makes reconciliation and support tickets tractable.
Two endpoints, one flow.
GET /pay/{order} — call purchase(), return the auto-submitting form.POST /worldpay/callback — call completePurchase(), verify, update, render a result page.WorldPay POSTs back to returnUrl; there is no server-to-server webhook separate from the shopper's browser in this product. Treat the callback as truth only after verifying.
Callback verification belongs in your app, not the driver. The driver checks callbackPW against your configured secret and transStatus === 'Y', but you must additionally: look up the order by cartId, compare amount/currency against the stored total, reject already-paid orders (idempotency), and log the full payload.
public function callback(Request $request, WorldPayGateway $gateway)
{
Log::info('worldpay.callback', $request->all());
$order = Order::where('reference', $request->input('cartId'))->firstOrFail();
if ($order->payment_state === 'paid') {
return view('payments.result', ['ok' => true]); // duplicate callback
}
$response = $gateway->completePurchase(['transactionId' => $order->reference])->send();
if ($response->isSuccessful()
&& $request->input('authAmount') === number_format($order->total_pence / 100, 2, '.', '')
&& $request->input('authCurrency') === $order->currency
) {
$order->markPaid($response->getTransactionReference(), $request->all());
} else {
$order->markFailed($request->input('rawAuthMessage'));
}
return view('payments.result', ['ok' => $order->payment_state === 'paid']);
}
Use cartId as your idempotency key. Set transactionId to your order/attempt reference and make it unique per attempt (ORD-1042-1). WorldPay echoes it back verbatim, which is the only reliable join key in the callback.
Inspect the form if you need AJAX. PurchaseResponse exposes getRedirectMethod() (POST), getRedirectUrl() and getRedirectData(). If you can't return an auto-submitting page, render the field array into your own hidden form and submit it client-side.
Test without hitting WorldPay. The purchase response is built entirely offline (no network call), so you can unit-test field generation directly. For the callback, construct a controller test that POSTs a fixture payload; don't try to drive completePurchase() from a queue job or Artisan command — it reads the live HTTP request.
Recurring (FuturePay), if present in your version. It's a separate gateway/flow; verify the class exists in vendor/omnipay/worldpay/src before planning around it. For refunds, captures and voids, this driver will not help — those need WorldPay's XML/API integration or the merchant interface.
worldpay/* to VerifyCsrfToken::$except (Laravel 11: validateCsrfTokens(except: [...])). This is the single most common "the payment succeeded but the order is still pending" cause.return redirect($response->getRedirectUrl()) will fail; use $response->getRedirectResponse() (auto-submitting HTML) or getRedirectData().10.00, not 1000. Format at the boundary with number_format($pence / 100, 2, '.', '') and compare the callback's authAmount the same way. Floating-point arithmetic anywhere in this path is a bug waiting to happen.secret is the Callback Password, not an API key. It's the value you set in the WorldPay Merchant Interface; the driver compares it with the returned callbackPW. There's no cryptographic signature on Select Junior callbacks, which is why amount/currency re-verification in your own code is non-negotiable.testMode is a form field, not an endpoint switch. WorldPay uses magic values (e.g. 100 yields an authorised test transaction). Make sure production config can never resolve to a test value — an env var defaulting to true is a classic incident.transStatus has more values than Y/N. Y authorised, C cancelled, and others exist. Map codes to your own states with an explicit match/switch and log the unknown ones rather than coercing to boolean.pending older than N minutes → query by cartId in the Merchant Interface, or expire the order) so orders don't dangle forever.transactionId leaks into the next.transactionId/cartId has length and character limits, and returnUrl must be absolute, publicly reachable, and unauthenticated. Signed/temporary URLs that expire in minutes will break slow 3-D Secure flows.isSuccessful() and getMessage() rather than assuming an exception means failure. Keep the full callback payload; search WorldPay's Merchant Interface by transId when a shopper disputes an outcome.omnipay/common and omnipay/worldpay in composer.json, add a callback integration test, and keep an eye on whether your WorldPay account is being migrated to a newer API (which would make this driver obsolete).Omnipay\WorldPay\Gateway and override createPurchaseRequest() / createCompletePurchaseRequest(), then instantiate your class directly (Omnipay::create(MyWorldPayGateway::class) or new MyWorldPayGateway($httpClient, $httpRequest)); there's no supported "swap the request class" setter.How can I help you explore Laravel packages today?