omnipay/sagepay
Sage Pay (Opayo) driver for Omnipay, the framework-agnostic PHP payment library. Integrates Form, Server, Direct and XML gateways behind a consistent API, covering purchases, refunds, voids, recurring payments and 3D Secure in Laravel or any PHP application.
Install via Composer alongside Omnipay itself:
composer require omnipay/sagepay league/omnipay
Pick the gateway that matches your integration model — this is the single most important decision, and the package ships three:
Omnipay\SagePay\ServerGateway — redirect-based, Sage Pay hosts the payment page and 3D Secure. Best default for new integrations and the only one that comfortably handles SCA.Omnipay\SagePay\DirectGateway — server-to-server, you collect card data. Requires PCI DSS scope. Use only if you must.Omnipay\SagePay\FormGateway — legacy encrypted-form POST. Avoid for new builds.First working call (Direct, test mode):
use Omnipay\Omnipay;
$gateway = Omnipay::create('SagePay\Direct');
$gateway->setVendor('myvendor');
$gateway->setTestMode(true);
$response = $gateway->purchase([
'amount' => '10.00',
'currency' => 'GBP',
'card' => $card,
'transactionId' => 'ORDER-123',
])->send();
if ($response->isSuccessful()) { /* ... */ }
Where to look first: the tests/ directory mirrors real request/response shapes and is the fastest way to learn the required fields per operation. Then read the gateway class docblocks — the supported options are enumerated per method. The first real use case to wire up is a purchase() against the Sage Pay simulator in test mode, then a refund() against the same transaction.
Server gateway (recommended) — two-phase redirect flow. The pattern is: purchase() returns a redirect to Sage Pay; Sage Pay posts back to your notifyUrl and redirects the shopper to your returnUrl. Handle both, idempotently:
// Step 1 – kick off
$response = $gateway->purchase([
'amount' => '25.00', 'currency' => 'GBP',
'transactionId' => $order->id,
'returnUrl' => route('sagepay.return'),
'notifyUrl' => route('sagepay.notify'),
])->send();
$response->redirect(); // or $response->getRedirectUrl()
// Step 2a – browser return
$response = $gateway->completePurchase([
'transactionId' => $request->transactionId,
])->send();
// Step 2b – server-to-server notification
$response = $gateway->acceptNotification()->send();
Direct gateway with 3D Secure. Because of SCA, a plain purchase() will frequently return a 3DS challenge. Structure it as purchase() → detect $response->isRedirect() → send shopper to ACS → completePurchase() with the PaRes/CRes value. Authorize-then-capture is often cleaner than purchase:
$auth = $gateway->authorize([...])->send();
// ... later
$gateway->capture(['amount' => '25.00', 'transactionReference' => $auth->getTransactionReference()])->send();
Storing references. Persist getTransactionReference() on every response, not just successful ones — refunds, voids, and repeated captures all key off it. Also persist getTransactionId() (your order ID) separately so you can reconcile Sage Pay's VPSTxId/VendorTxCode mapping.
Reconciliation / notifications. Treat the notify callback as canonical: verify the signature, then look up the order, then update state. Guard against duplicate deliveries (Sage Pay can retry) — check whether the transaction ID has already been processed and ack the second time.
Integration tip. Wrap the gateway in a small service that maps your domain responses to Omnipay's PurchaseResponse/Response objects, so controllers never touch raw Sage Pay statuses (OK, NOTAUTHED, 3DAUTH, REGISTERED, etc.). This keeps 3DS and error branching in one place.
Test harness. Use the simulator with vendor myvendor and test cards (4929 0000 0000 6 for success, 4111 1111 1111 1111 with a specific CVV for decline). The package's own tests show the exact form-encoded payloads, which is invaluable when a real response doesn't match expectations.
sagepay.com test/live hosts. Check getEndpoint() and the constants in each gateway class before going live.'10.00', not 1000 and not a float. Mixing this up produces silent mischarges. Validate currency precision yourself.setVendor() expects the vendor name configured in MyOpayo. The encryption key only applies to the legacy Form gateway — do not confuse it with the API key used by newer integrations.json_decode. Use acceptNotification() and check $notification->getTransactionStatus() (or isSuccessful()/isPending() after the package normalises it), and always validate the signature before trusting the body.DirectGateway purchase() without 3DS will be rejected in live mode. ServerGateway handles this for you; if you're on Direct you must implement completePurchase with the ACS response yourself.completeAuthorize vs completePurchase. For authorize() + 3DS you must call completeAuthorize(); calling completePurchase() will fail cryptically. Mirror whichever operation you started.items to the request.$response->isSuccessful() covers the common case, but $response->getCode() / $response->getMessage() give the Sage Pay status (2000, 2001, 4021, etc.). Log the raw message; the human-readable text is often more useful than the code alone.OK (or 200 OK). Return that immediately from the callback and do the order updates in a queued job, otherwise a slow downstream call causes retries and double-processing.AbstractGateway, and each operation is its own Parameter/Response class. Custom response handling (e.g. mapping a merchant-specific status) is done by subclassing the gateway's response classes — inspect Omnipay\SagePay\Message\*Response for the hooks.composer update will be safe in a live payments path.How can I help you explore Laravel packages today?