Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Worldpay Laravel Package

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.

Deep Wiki
Context7

Getting Started

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

Implementation Patterns

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.

  1. GET /pay/{order} — call purchase(), return the auto-submitting form.
  2. 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.

Gotchas and Tips

  • CSRF will eat your callback. Add worldpay/* to VerifyCsrfToken::$except (Laravel 11: validateCsrfTokens(except: [...])). This is the single most common "the payment succeeded but the order is still pending" cause.
  • Don't return a URL — return the form. Select Junior is a POST integration. return redirect($response->getRedirectUrl()) will fail; use $response->getRedirectResponse() (auto-submitting HTML) or getRedirectData().
  • Amounts are major units, as strings. 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.
  • Cancellations may never reach you. If the shopper abandons the page, no callback fires. Run a scheduled reconciliation (pending older than N minutes → query by cartId in the Merchant Interface, or expire the order) so orders don't dangle forever.
  • The gateway object is stateful. Setting parameters mutates the instance. If you reuse one instance for multiple orders in a single process (tests, batch jobs), reset or rebuild it, otherwise one order's 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.
  • Debug from the transaction, not the exception. Omnipay throws for configuration errors but returns unsuccessful responses for declined payments — check 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.
  • Package age is a real risk. Last release 2017 means no guarantees against PHP 8.x deprecations in the Omnipay 3 chain, and no upstream fixes. Pin 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).
  • Extension point is subclassing, not configuration. If you need custom fields or different validation, extend 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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log
gregsphotography/site-builder-components
io-digital/clickatell
snowsoft/laravel-admin
oxygen/auth
relicsoftware/barakah-payment-gateway
tobya/work-with-composer
equipc/laravel-email-validator
equipc/laravel-email-database-log
adedsec/nova-translated-labels
wazza/laravel-db-encryption
hokny/laravel-generator
vonsogt/backup
jengo/queues
jeremykenedy/laravel-observability
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums