omnipay/paymentexpress
Omnipay driver for PaymentExpress (DPS), the New Zealand payment gateway. Adds PxPay and PxFusion support to Omnipay so PHP applications can process purchases, authorizations, and refunds through a consistent, framework-agnostic API.
Install and pin the version that matches your Omnipay major.
composer require omnipay/paymentexpress
composer show omnipay/paymentexpress omnipay/common
omnipay/paymentexpress 2.x → requires omnipay/common ^3 (PHP 7.1+). This is the version most current Laravel apps want.omnipay/common ^2. If composer show reports a 1.x install, you almost certainly want to upgrade rather than build on it.The package is a thin driver: it supplies gateway classes, request/response classes, and XML serialization for Payment Express (now Windcave). Everything else — redirects, persistence, retries, logging — is Omnipay common behaviour, so the Omnipay docs are your primary reference.
Choose the gateway first — it determines your PCI scope.
| Gateway | Flow | Card data touches your server? | Use when |
|---|---|---|---|
PaymentExpress_PxPay |
Hosted redirect (PxPay) | No | Default choice; SAQ A, 3-D Secure handled by the hosted page |
PaymentExpress_PxPost |
Server-to-server XML POST | Yes (PAN + CVV) | Token billing, refunds/voids, or no redirect possible; SAQ D scope |
First use case — PxPay purchase redirect.
use Omnipay\Omnipay;
$gateway = Omnipay::create('PaymentExpress_PxPay');
$gateway->setUsername(config('services.paymentexpress.pxpay_user_id')); // PxPayUserId
$gateway->setPassword(config('services.paymentexpress.pxpay_key')); // PxPayKey
$gateway->setTestMode(app()->environment(['local', 'testing']));
$response = $gateway->purchase([
'amount' => number_format($order->total, 2, '.', ''), // "10.00", not cents
'currency' => 'NZD',
'transactionId' => (string) $order->id,
'description' => 'Order #'.$order->id,
'returnUrl' => route('payment.return'),
'cancelUrl' => route('payment.return'), // often mapped to UrlFail
])->send();
if (! $response->isRedirect()) {
// Auth request to Payment Express failed before the user ever saw the page
throw new RuntimeException($response->getMessage());
}
$order->update(['payment_token' => $response->getTransactionReference()]);
return redirect()->away($response->getRedirectUrl());
Handle the return (GET, with ?result=<token>).
if (! $request->filled('result')) {
// user cancelled / malformed return — do not call completePurchase
return to_route('checkout.cancelled');
}
$response = $gateway->completePurchase([
'transactionId' => $request->query('result'), // NOTE: the token goes in transactionId
])->send();
if ($response->isSuccessful()) {
// verify before fulfilling
abort_unless(
$response->getAmount() === number_format($order->total, 2, '.', '')
&& $response->getCurrency() === $order->currency,
422
);
$order->update([
'paid_at' => now(),
'dps_txn_ref' => $response->getTransactionReference(), // DpsTxnRef
'auth_code' => $response->getAuthCode(),
'card_masked' => $response->getCardNumber(), // e.g. "411111xxxxxx1111"
]);
}
Where to look first
src/PxPayGateway.php and src/PxPostGateway.php — the public API surface (which methods exist, which parameters they accept).src/Message/PxPay*Request.php / PxPost*Request.php — the exact XML field names sent to Payment Express, and therefore the exact parameter names you must supply.tests/ — the XML fixtures show real request/response shapes; this is faster than the Windcave PDFs for edge cases.purchase()/completePurchase()/refund() semantics and the CreditCard object.PxPay end-to-end (the default pattern).
pending, generate a local transactionId (your order reference).purchase() → persist the returned token to the order, redirect the user off-site.completePurchase(['transactionId' => $_GET['result']]) → verify amount + currency → mark paid and store DpsTxnRef.UrlCallback/status check if your account and driver version support it.PxPay authorize + complete (pre-auth).
$auth = $gateway->authorize([...same params...])->send();
// redirect, then on return:
$result = $gateway->completeAuthorize(['transactionId' => $request->query('result')])->send();
// capture later (via PxPost TxnType=Complete using $result->getTransactionReference())
Common for hotels, rentals, and delayed-capture flows. The completion mechanics are identical to purchase — same single-use token, same naming quirk.
PxPost direct charge.
$gateway = Omnipay::create('PaymentExpress_PxPost');
$gateway->setUsername(config('services.paymentexpress.pxpost_user'));
$gateway->setPassword(config('services.paymentexpress.pxpost_password'));
$response = $gateway->purchase([
'amount' => '10.00',
'currency' => 'NZD',
'transactionId' => 'ORDER-123',
'card' => [
'number' => '4111111111111111',
'expiryMonth' => 12,
'expiryYear' => 2030,
'cvv' => '123',
'name' => 'Test User',
],
])->send();
if ($response->isSuccessful()) {
$txnRef = $response->getTransactionReference();
}
PxPost returns HTTP 200 for declines, so branch on isSuccessful(), not on transport status. Exceptions are reserved for invalid/absent input and connection failures.
Token billing on PxPost (avoid storing PANs).
// Enrol
$created = $gateway->createCard([
'transactionId' => 'CUSTOMER-'.$customer->id,
'card' => $cardDetails,
])->send();
$billingId = $created->getCardReference(); // store this, not the card
// Charge later
$gateway->purchase([
'amount' => '10.00',
'currency' => 'NZD',
'transactionId' => 'ORDER-124',
'cardReference' => $billingId,
])->send();
// Remove
$gateway->deleteCard(['cardReference' => $billingId])->send();
This is the main reason to accept PxPost's PCI burden: repeat/subscription charges without re-entering card details.
Refunds and status.
$gateway->refund([
'amount' => '10.00',
'transactionReference' => $payment->dps_txn_ref,
])->send();
Refunds are typically a PxPost operation against the original DpsTxnRef (returned by the PxPay completion response), even when the original charge was PxPay. Check PxPayGateway in your installed version before assuming refund() exists there; the reliable path is PxPost plus the stored reference.
Laravel wiring.
// config/services.php
'paymentexpress' => [
'pxpay_user_id' => env('PXPAY_USER_ID'),
'pxpay_key' => env('PXPAY_KEY'),
'pxpost_user' => env('PXPOST_USER'),
'pxpost_pass' => env('PXPOST_PASSWORD'),
],
// AppServiceProvider::register()
$this->app->singleton('paymentexpress.pxpay', function () {
$gateway = Omnipay::create('PaymentExpress_PxPay');
$gateway->setUsername(config('services.paymentexpress.pxpay_user_id'));
$gateway->setPassword(config('services.paymentexpress.pxpay_key'));
$gateway->setTestMode(app()->environment(['local', 'testing']));
return $gateway;
});
Keep routes for the return handler as GET (PxPay returns via GET with result in the query string), and exclude any server-to-server callback route from CSRF if you implement one. Wrap send() in a small service that catches Omnipay\Common\Exception\InvalidRequestException (bad/missing params), InvalidCreditCardException (PxPost Luhn/expiry failures), and Guzzle ConnectException, converting all three into a domain-level "payment failed" result.
Testing.
setTestMode(true) points at the UAT host; real cards don't work there.4111111111111111, any future expiry, any CVC.new PxPayGateway($httpClient, $httpRequest)), or use the XML fixtures under tests/ as canned responses. Avoid hitting UAT in CI — it is slow and flaky.getTransactionReference(), isSuccessful()), never on the raw XML string.The transactionId vs transactionReference trap. On PxPay completePurchase(), the ?result= token must be supplied as transactionId. Passing transactionReference (the intuitively correct name) or your own order ID produces a gateway error or an empty result. This is the single most common integration bug with this driver.
The PxPay token is single-use. If the user refreshes the return page after you have already completed, the second completePurchase() call fails. Guard with an order-state check (if ($order->paid_at) return success) before calling the gateway. Also handle a missing result parameter gracefully — that's cancellation, not a payment to complete.
Amounts are major units, as strings. "10.00" for $10, never 1000. Use integer maths plus number_format($cents / 100, 2, '.', ''). The driver formats for the XML, but it will not rescue a value you already scaled to cents.
Currency is account-scoped. Payment Express accounts are configured for a currency (often NZD, AUD, or USD); a mismatch fails or is silently coerced depending on endpoint version. Don't assume you can charge USD on an NZD account just because the driver accepts a currency parameter.
PxPost declines are HTTP 200. <Success>0</Success> with a <ResponseText> ("Declined", "Invalid Card Number", etc.) comes back looking like a healthy response. Always check isSuccessful() and log getMessage()/getCode(). The same applies to PxPay's completion response — read <Success>, not the query string.
PxPay vs PxPay 2.0 endpoints. The request classes post to hardcoded Payment Express hostnames/paths selected by setTestMode(). Older accounts use the legacy PxPay path; newer Windcave accounts are provisioned on PxPay 2.0 (.../pxaccess/pxpay.aspx). If every Auth call returns an authentication/credential error despite correct PxPayUserId/PxPayKey, inspect the endpoint in the gateway/request class and confirm it matches the version your account is on. Override it by subclassing the gateway/request in your app — do not edit vendor/.
PCI scope. PxPost transmits the full PAN and CVV from your server. That puts you in SAQ D territory and means card data must never hit logs, traces, queues, or error reporters. Prefer PxPay unless you specifically need token billing or refunds; even then, consider tokenising via the hosted page if your account supports it.
Redact before logging. PxPay request XML contains PxPayKey; PxPost request XML contains the full card number and CVC. If you use Omnipay's debug middleware or log request bodies, scrub these fields explicitly. Log the local transactionId, the gateway response code/message, and the DpsTxnRef — not the raw payload.
Two different "transaction references." PxPay purchase()'s getTransactionReference() is the redirect token, not a DPS reference. The meaningful DpsTxnRef only exists after completePurchase(). Store both under distinct column names; use the DpsTxnRef for refunds and reconciliation.
Idempotency is your job. Neither gateway enforces uniqueness on your TxnRef. Use a unique order ID, enforce a one-payment-per-order constraint in the database, and make the fulfill step transactional. Duplicate submissions from double-clicks will otherwise create duplicate charges.
Timeouts. The default Guzzle timeout is longer than a web request should block. Set a 5–10 second timeout on the injected client and treat a timeout as "unknown" — never as failure. Mark the order pending_verification and reconcile, because a timed-out POST may still have been processed.
Request validation exceptions are not declines. InvalidRequestException (missing amount, returnUrl, etc.) and InvalidCreditCardException (PxPost Luhn/expiry) are thrown before any network call. Catch them separately from gateway declines so your error messaging and retry logic aren't conflated.
Extension point: subclass the request, not the vendor code. Fields the driver doesn't expose (for example UrlCallback or EnableAddBillCard on PxPay, or extra PxPost XML nodes) require overriding getData() on a request subclass, then overriding the corresponding gateway method to return your class. Keep the subclass minimal — the package is in low-maintenance mode (last release 2020) and you don't want a large local fork to rebase.
Test mode is a gateway-level setting. Call setTestMode(true) before send(); it is not a per-request parameter. A common staging bug is a singleton gateway cached with production endpoints because setTestMode() ran after the first request.
UAT is not production-shaped. UAT occasionally returns slightly different XML, doesn't exercise every acquirer path, and can't validate your live account's currency or 3-D Secure configuration. Always run one small live transaction before launch.
How can I help you explore Laravel packages today?