omnipay/payfast is a thin Omnipay 3 driver for PayFast (South Africa): it builds the signed redirect form for a purchase and parses/validates the ITN (Instant Transaction Notification) webhook. It does not ship a Laravel service provider, config file, or migrations — you wire it up yourself.
Install:
composer require omnipay/payfast
Put credentials in config/services.php (never in code):
'payfast' => [
'merchant_id' => env('PAYFAST_MERCHANT_ID'),
'merchant_key' => env('PAYFAST_MERCHANT_KEY'),
'passphrase' => env('PAYFAST_PASSPHRASE'), // set the same value in the PayFast dashboard
'test_mode' => env('PAYFAST_TEST_MODE', true),
],
Bind the gateway as a singleton so credentials/test mode are configured once:
// app/Providers/AppServiceProvider.php
$this->app->singleton(\Omnipay\PayFast\Gateway::class, function () {
$gateway = new \Omnipay\PayFast\Gateway();
$gateway->setMerchantId(config('services.payfast.merchant_id'));
$gateway->setMerchantKey(config('services.payfast.merchant_key'));
$gateway->setPassphrase(config('services.payfast.passphrase'));
$gateway->setTestMode((bool) config('services.payfast.test_mode'));
return $gateway;
});
Where to look first: vendor/omnipay/payfast/src/Message/PurchaseRequest.php (getData() shows the exact PayFast field names and how the signature is built) and CompletePurchaseRequest.php (what it reads from the incoming ITN and what it validates). The README is short; the code is the spec.
First use case — redirect checkout:
$response = $gateway->purchase([
'amount' => number_format($order->total_cents / 100, 2, '.', ''),
'currency' => 'ZAR',
'transactionId' => (string) $order->id, // maps to m_payment_id
'description' => "Order #{$order->id}",
'returnUrl' => route('payfast.return'),
'cancelUrl' => route('payfast.cancel'),
'notifyUrl' => route('payfast.itn'),
])->send();
if ($response->isRedirect()) {
return view('payfast.redirect', [
'url' => $response->getRedirectUrl(),
'method' => $response->getRedirectMethod(), // POST for PayFast
'data' => $response->getRedirectData(),
]);
}
report(new \RuntimeException('PayFast purchase failed: '.$response->getMessage()));
return back()->withErrors('Payment could not be started.');
PayFast uses a POST redirect, not a query-string Location. Prefer rendering a tiny auto-submitting form in Blade over calling $response->redirect(), which writes headers/output directly and bypasses Laravel's response pipeline.
Then a public ITN route:
Route::post('/payfast/itn', [PayFastController::class, 'itn'])
->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class])
->name('payfast.itn');
ITN handler (the only source of truth). The return URL is a browser redirect the user controls; the ITN is the server-to-server notification you trust.
public function itn(Request $request, \Omnipay\PayFast\Gateway $gateway)
{
$response = $gateway->completePurchase($request->all())->send();
// If the driver only reads globals, ->completePurchase()->send() also works for form POSTs.
if (! $response->isSuccessful()) {
Log::warning('PayFast ITN rejected', [
'message' => $response->getMessage(),
'payload' => $request->all(),
]);
return response('', 200); // acknowledge so PayFast does not hammer retries
}
$order = Order::whereKey($response->getTransactionId())->lockForUpdate()->firstOrFail();
// Re-check the amount against your own total (integer cents) — never trust amount_gross alone.
if ((int) round($order->total_cents) !== (int) round(((float) $request->input('amount_gross')) * 100)) {
Log::error('PayFast amount mismatch', ['order' => $order->id, 'itn' => $request->all()]);
return response('', 200);
}
if ($request->input('payment_status') !== 'COMPLETE') {
$order->markPaymentFailed($request->input('payment_status'));
return response('', 200);
}
if (! $order->isPaid()) {
$order->markPaid($request->input('pf_payment_id'));
// dispatch OrderPaid, send receipt, etc.
}
return response('', 200); // PayFast expects an empty 200
}
Idempotency. PayFast retries ITNs and can send duplicates. Key on pf_payment_id (unique per transaction) or at minimum guard with if ($order->isPaid()) return. Use lockForUpdate() or an atomic update to survive concurrent deliveries.
Order correlation. Use transactionId (m_payment_id) as your order reference. If you need extra metadata (cart hash, user id), use PayFast's custom_str1..5 / custom_int1..5 — check whether the driver exposes them; if not, extend PurchaseRequest and override purchase() on a subclassed gateway to include them. Keep m_payment_id short and URL-safe.
Return/cancel pages. Treat them purely as UX: read the order from the DB and show pending/paid/declined based on status. Do not mutate payment state from the return URL — the ITN may not have arrived yet, so show "processing" and optionally poll.
Testing. Generate a fixture ITN with a valid signature using the passphrase and post it to the route in a feature test. Test the unhappy paths too: bad signature, wrong amount, payment_status=FAILED, duplicate ITN. Use $gateway->purchase([...])->getData() in a unit test to assert the signed payload without hitting the network. Sandbox mode (setTestMode(true)) uses sandbox.payfast.co.za with the well-known sandbox merchant credentials.
Refunds and lookups. PayFast's merchant API here does not support refunds or transaction fetching through this driver. Refunds are done in the PayFast dashboard; record them in your own admin tooling and reconcile.
merchantKey is public (it appears in the form); merchantId is public too. Only the passphrase is secret.'currency' => 'ZAR'. Pass amounts as strings with two decimals (number_format(..., 2, '.', '')), not floats, to avoid rounding and locale issues.key=urlencode(value) pairs for non-empty fields in the same order, append &passphrase=..., and md5 the result.$response->isSuccessful() on completePurchase() means signature + status passed, not that you've reconciled the order. Always verify merchant_id, amount_gross, and that the order exists and is unpaid.VerifyCsrfToken, don't put it behind auth, and make sure it's publicly reachable. localhost URLs won't work — use valet share/ngrok/Expose for local sandbox testing.$response->redirect() bypasses Laravel. Render getRedirectUrl()/getRedirectMethod()/getRedirectData() in a Blade form so middleware, sessions, and tests behave normally.getData(). Before calling send(), $gateway->purchase($params)->getData() returns exactly what will be signed and posted — fastest way to diagnose "signature mismatch" and missing/extra fields.PayFastService wrapper around the gateway so you can swap implementations, and lean on your own integration tests for the ITN contract. Do not assume new PayFast fields (subscriptions, tokenization) are supported without reading PurchaseRequest.config:cache staleness. Because credentials are read in the singleton closure, changing .env requires php artisan config:clear. In tests, override test_mode explicitly rather than relying on the environment.pf_payment_id, m_payment_id, amount_gross, and the timestamp.How can I help you explore Laravel packages today?