PHP & Laravel
Contents
- Install & construct
- The SDK surface
- Exceptions the SDK throws
- Web transaction
- Mobile money
- Polling
- The callback handler
- Laravel wiring
- PHP gotchas
Ready-to-adapt files live in assets/php-laravel/.
Install & construct
composer require paynow/php-sdk
Requires the cURL extension. The SDK supports PHP 5.6+, but the code in this skill uses PHP 8.0+ syntax (promoted properties, named arguments).
use Paynow\Payments\Paynow;
$paynow = new Paynow(
getenv('PAYNOW_INTEGRATION_ID'),
getenv('PAYNOW_INTEGRATION_KEY'),
'https://example.com/paynow/return?ref=INV-35', // returnurl — browser lands here
'https://example.com/paynow/callback' // resulturl — server callback
);
The snippet published on the Paynow docs site is missing the comma between the two URLs and does not parse. If a user pastes a constructor that won’t compile, that is why.
URLs can also be set after construction, which is how you get the order reference into the return URL when the reference isn’t known at construction time:
$paynow->setReturnUrl('https://example.com/paynow/return?ref=' . urlencode($orderRef));
$paynow->setResultUrl('https://example.com/paynow/callback');
The SDK surface
| Method | On | Returns |
|---|---|---|
createPayment($ref, $email) |
Paynow |
FluentBuilder — the cart |
$payment->add($name, $price) |
builder | adds a line; total is the sum |
send($payment) |
Paynow |
init response (web) |
sendMobile($payment, $phone, $method) |
Paynow |
init response (ecocash / onemoney only) |
pollTransaction($pollUrl) |
Paynow |
status response |
processStatusUpdate() |
Paynow |
status response, read from $_POST, hash verified |
success() |
init response | bool — initiation accepted, not “customer paid” |
redirectUrl() |
web init | Paynow URL to redirect to |
pollUrl() |
web + mobile init | persist this |
instructions() |
mobile init | human-readable USSD guidance — render it verbatim |
errors() |
init response | space-joined string; errors(false) gives the array |
status() |
poll/status | status word, lower-cased by the SDK |
paid() |
poll/status | ⚠️ true only for literal Paid — see below |
amount(), reference(), paynowReference() |
poll/status | as named |
⚠️ paid() and status()
paid() is implemented as return $this->status() === 'paid';. It is false for
Awaiting Delivery and Delivered, both of which are fully paid. Using it as the
fulfilment gate silently drops orders on any integration using delivery confirmation.
status() lower-cases its return, so comparing against the capitalised status words
from the API docs never matches. Compare lower-cased:
$PAID = ['paid', 'awaiting delivery', 'delivered'];
if (in_array($status->status(), $PAID, true)) { /* fulfil */ }
Exceptions the SDK throws
send(), sendMobile() and pollTransaction() throw rather than returning a failed
response. success() is only reached when nothing was thrown — so a bare
if ($response->success()) with no try will 500 on the most common misconfiguration of
all, a wrong or not-yet-live integration ID.
| Exception | Namespace | Raised when |
|---|---|---|
InvalidIntegrationException |
Paynow\Payments |
Paynow replied Invalid id. |
HashMismatchException |
Paynow\Payments |
Response hash failed. Never continue. |
ConnectionException |
Paynow\Http |
Transport failure. Safe to retry. |
EmptyCartException |
Paynow\Payments |
send() with no items added. |
EmptyTransactionReferenceException |
Paynow\Payments |
Empty reference. |
InvalidUrlException |
Paynow\Payments |
returnurl/resulturl not a valid URL. |
Useful consequence: because the SDK raises HashMismatchException itself, hash
verification is already covered on those three calls. Only the resulturl callback needs
a hand-rolled check.
Web transaction
$payment = $paynow->createPayment('INV-35', $authEmail);
$payment->add('Bananas', 2.50);
$payment->add('Apples', 3.40);
try {
$response = $paynow->send($payment);
} catch (\Paynow\Payments\InvalidIntegrationException $e) {
error_log('[paynow] invalid integration id');
throw $e;
} catch (\Paynow\Payments\HashMismatchException $e) {
error_log('[paynow] RESPONSE HASH MISMATCH - do not redirect');
throw $e;
} catch (\Paynow\Http\ConnectionException $e) {
error_log('[paynow] connection failed: ' . $e->getMessage());
throw $e;
}
if (! $response->success()) {
error_log('Paynow init failed: ' . $response->errors()); // a string, not an array
// surface a retryable error to the customer
}
$pollUrl = $response->pollUrl();
$orderRepo->attachPollUrl($orderRef, $pollUrl); // persist BEFORE redirecting
header('Location: ' . $response->redirectUrl());
exit;
The reference must be unique per transaction on your side — invoice number, order id, UUID.
Mobile money
Only ecocash and onemoney go through the SDK helper. No redirect; the customer gets a
USSD prompt.
$response = $paynow->sendMobile($payment, '0771111111', 'ecocash');
if ($response->success()) {
$pollUrl = $response->pollUrl(); // persist
$instructions = $response->instructions(); // render verbatim while polling
}
For InnBucks, O’mari, Zimswitch and card tokens the SDK has no helper — use the raw HTTP
path in raw-http.md.
Polling
$status = $paynow->pollTransaction($pollUrl);
$PAID = ['paid', 'awaiting delivery', 'delivered']; // status() is lower-cased
if (in_array($status->status(), $PAID, true)) {
// fulfil, idempotently
}
The callback handler
Two options. Both are fine; the difference is where the field order comes from.
SDK helper — reads $_POST:
try {
$status = $paynow->processStatusUpdate();
} catch (\Paynow\Payments\HashMismatchException $e) {
http_response_code(400);
exit;
}
Raw body — preserves Paynow’s exact field order with no assumptions about PHP’s array
handling. This is what assets/php-laravel/ uses, and what to prefer when a hash mismatch
is the thing being debugged:
$raw = file_get_contents('php://input');
$fields = PaynowHash::parseResponse($raw); // ordered, URL-decoded
if (! $hasher->verify($fields)) {
error_log('[paynow] REJECTED callback: bad hash');
http_response_code(400);
exit;
}
Then, in order: look up the order, return 200 if already fulfilled (idempotency),
reconcile the amount against your stored total, optionally confirm by polling, mark paid,
queue the fulfilment job, return 200. Unknown references get logged and a 200 — a
4xx just makes Paynow retry ten times.
Laravel wiring
assets/php-laravel/ contains a working set:
| File | Purpose |
|---|---|
PaynowHash.php |
Hash helper + ordered response parser |
PaynowGateway.php |
Service wrapping the SDK; the only place credentials are read |
PaynowController.php |
checkout, checkoutMobile, callback, returnFromPaynow, status actions |
config-services-paynow.php |
Snippet for config/services.php |
routes-web.php |
Route definitions |
migration_add_paynow_to_orders.php |
poll_url, paynow_reference, payment_status, merchant_trace |
merchanttraceand the PHP SDK.sendMobile()takes nomerchanttraceparameter, so an Express Checkout started through the SDK never sends one and/interface/tracecannot recover it. The starter leaves the column null rather than storing a locally generated value that would look like a recovery handle and answerNotFound. To get trace recovery in PHP, initiate over raw HTTP — the field table is inraw-http.md.
Three things to wire up that are easy to miss:
- Exempt the callback route from CSRF. In Laravel 11+ that’s
$middleware->validateCsrfTokens(except: ['paynow/callback'])inbootstrap/app.php; in 10 and earlier it’s the$exceptarray inVerifyCsrfToken. Without this the callback is silently rejected and nothing is ever fulfilled. - Bind the return route to a reference —
route('paynow.return', ['ref' => $ref])— so the return page can render the right order without trusting anything Paynow sends. - Test-mode
authemail. Make it config-driven (see below), not the customer’s email.
PHP gotchas
| Gotcha | Fix |
|---|---|
| Docs constructor missing a comma between the URLs | Add it. |
send() throws instead of returning success() === false |
Wrap in try/catch. |
$status->paid() used as the fulfilment gate |
Matches only Paid. Compare status() against the lower-cased set. |
errors() treated as an array |
It’s a space-joined string; errors(false) gives the array. |
parse_str() mangling keys and order |
Use the ordered splitter in hashing.md. |
| Floats formatted with locale separators | number_format($n, 2, '.', ''). |
| CSRF middleware eating the callback | Exempt the route. |
| cURL extension missing | The SDK requires ext-curl. |
This page is generated from paynow-skills/paynow-integration/references/php-laravel.md in the skill. Spotted something wrong? Open an issue.