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

Payfast Laravel Package

omnipay/payfast

View on GitHub
Deep Wiki
Context7

Getting Started

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');

Implementation Patterns

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.

Gotchas and Tips

  • Passphrase is the #1 failure. It is never sent over the wire — it is appended to the signature input and also configured in the PayFast dashboard. A mismatch makes both outgoing purchases and incoming ITN signature checks fail with a generic message. Sandbox and live have separate passphrases.
  • merchantKey is public (it appears in the form); merchantId is public too. Only the passphrase is secret.
  • Currency is ZAR only. Omnipay's default currency is not ZAR — always pass 'currency' => 'ZAR'. Pass amounts as strings with two decimals (number_format(..., 2, '.', '')), not floats, to avoid rounding and locale issues.
  • Signature generation is order-sensitive and skips empty fields. Don't reimplement it in your app for production — use the driver. If you must verify manually, build 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.
  • Return 200 with an empty body from the ITN route. A redirect, view, JSON body, or 500 causes PayFast to retry; keep the handler fast and push heavy work to a queued job after acknowledging.
  • CSRF/auth will break the ITN. Exclude the route from 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.
  • Debug with 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.
  • Maintenance risk. Last release was 2022, the package is small (24 stars), and it targets Omnipay 3. Pin the version, keep a thin 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.
  • Log the raw ITN payload (with sensitive fields redacted) on every call. When reconciliation goes wrong, PayFast support will ask for pf_payment_id, m_payment_id, amount_gross, and the timestamp.
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.
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
frankyso/ipaymu
3mdikram/worldmoneywords
snowsoft/config
itmosoft/laravel-subscriptions
larva/laravel-ai-qwen
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core