billowapp/payfast
Laravel package for integrating PayFast payments with a clean, expressive API. Create payment requests, redirect customers to PayFast, and handle ITN callbacks and payment status updates without boilerplate. Includes configurable credentials and sandbox support.
Install and publish
composer require billowapp/payfast
php artisan vendor:publish --provider="Billowapp\Payfast\PayfastServiceProvider"
This drops config/payfast.php into your app. Fill in:
// config/payfast.php (typical shape)
return [
'merchant_id' => env('PAYFAST_MERCHANT_ID'),
'merchant_key' => env('PAYFAST_MERCHANT_KEY'),
'passphrase' => env('PAYFAST_PASSPHRASE'),
'sandbox' => env('PAYFAST_SANDBOX', true),
'return_url' => env('PAYFAST_RETURN_URL'),
'cancel_url' => env('PAYFAST_CANCEL_URL'),
'notify_url' => env('PAYFAST_NOTIFY_URL'),
];
Where to look first (in this order)
config/payfast.php — the single source of truth for credentials and URLs.vendor/billowapp/payfast/src/PayfastServiceProvider.php — to confirm whether an ITN route is auto-registered, and what the facade alias is.Payfast facade / service class — method names here are what you'll actually call day-to-day.First use case
The canonical first integration is a one-off product checkout:
Order with status pending and a UUID m_payment_id.notify_url → you verify and flip the order to paid.return_url / cancel_url for a thank-you message — but fulfillment is never driven from this URL.If you only get one thing working end-to-end, get the ITN handler working with the sandbox, because everything else is UI on top of that.
Fluent builder → form fields → render
The package exposes a builder-style API on the Payfast facade. Representative shape (verify exact method names against the README of your locked version):
use Billowapp\Payfast\Facades\Payfast;
$fields = Payfast::setMerchantId(config('payfast.merchant_id'))
->setMerchantKey(config('payfast.merchant_key'))
->setReturnUrl(route('payfast.return', $order))
->setCancelUrl(route('payfast.cancel', $order))
->setNotifyUrl(route('payfast.itn'))
->setNameFirst($order->user->first_name)
->setNameLast($order->user->last_name)
->setEmailAddress($order->user->email)
->setMPaymentId($order->uuid)
->setAmount($order->total) // ZAR, 2dp
->setItemName($order->description)
->setCustomStr1((string) $order->id)
->setSandbox(config('payfast.sandbox'))
->getFormFields(); // includes the generated signature
Controller pattern — keep it thin
class PayfastCheckoutController
{
public function redirect(Order $order)
{
abort_unless($order->isPending(), 403);
return view('payfast.redirect', [
'url' => Payfast::getPaymentUrl(),
'fields' => Payfast::buildForOrder($order),
]);
}
}
Wrap the builder in an Action/Service (e.g. BuildPayfastPayment) so the order → gateway mapping lives in one testable place. The facade is stateful in a request, so avoid sprinkling Payfast::setAmount(...) calls across multiple controllers.
ITN webhook handling
Route::post('/payfast/itn', [PayfastItnController::class, 'handle'])
->withoutMiddleware([VerifyCsrfToken::class])
->name('payfast.itn');
public function handle(Request $request)
{
if (! Payfast::isValidItn($request->all())) { // or verify manually
return response('', 400);
}
$order = Order::where('uuid', $request->input('m_payment_id'))->firstOrFail();
// Never trust the amount from the payload — compare to the stored total
if ((float) $request->input('amount_gross') < $order->total) {
return response('', 400);
}
$order->markPaid($request->input('pf_payment_id'));
return response('', 200);
}
Key rules baked into that snippet:
200 OK with an empty body — anything else and PayFast retries.pf_payment_id and short-circuit if the order is already paid.State machine
Model payment states explicitly: pending → paid → cancelled/refunded. The ITN is the only event that should move an order to paid. The return_url should just look up the order and show a message — it must not do fulfillment, because the user can land there without the ITN having arrived yet.
Sandbox vs. live
Toggle via config, and use config('payfast.sandbox') rather than branching on app()->environment(). You'll want to test a live key in staging sometimes; tying sandbox to APP_ENV makes that awkward.
Custom fields as your metadata channel
custom_str1–custom_str4 round-trip through the ITN unchanged. Use custom_str1 for your internal order ID (short, numeric string) so the ITN handler doesn't have to trust m_payment_id alone — belt-and-braces against a bad client-side ID.
Stale package — check compatibility first
Last release was Feb 2020, so composer.json almost certainly pins laravel/framework to 5.x–7.x and PHP ^7.0. On Laravel 9/10/11 you'll either:
composer require billowapp/payfast --ignore-platform-reqs (may work since it's a thin wrapper), orilluminate/support, orDo a 10-minute spike before committing to the package on modern Laravel.
Signature is order- and encoding-sensitive
PayFast's signature is the MD5 of the URL-encoded parameter string in PayFast's defined field order, with the passphrase appended (also URL-encoded) if configured. Common failure modes:
"Signature does not match") — dump the concatenated string and diff character by character.If the package's signature helper misbehaves in your setup, implement it yourself against the official PayFast docs; it's safer than fighting the wrapper.
notify_url must be publicly reachable
http://localhost/... will never receive an ITN. Use a tunnel (ngrok, expose, Laravel Valet's share) during development, or test against the sandbox directly on a staging domain. PayFast retries on failure, so a silently broken notify_url in production means orders stuck in pending.
Config caching bites
php artisan config:cache freezes PAYFAST_SANDBOX at cache time. If you toggle sandbox in .env and orders still hit production, run php artisan config:clear. Same trap for merchant keys when rotating credentials.
Amount formatting
PayFast expects a decimal string in ZAR ("149.99", no thousands separator, no currency symbol). Passing a float that stringifies to scientific notation (e.g. 1.5E-5) or an integer for 149.99 will fail validation. Cast with number_format($amount, 2, '.', '').
ITN verification checklist (do all four)
amount_gross (and item_name/m_payment_id) match the stored order./eng/query/validate endpoint and confirm VALID. This catches spoofed ITNs that pass IP/signature checks.The package historically covers #2 (and maybe #4); #1 and #3 are usually on you.
Return URL is for humans, ITN is for money
A user can complete payment and hit return_url before the ITN arrives (PayFast sends them nearly simultaneously, but not guaranteed). Your return_url page should show "Payment received, processing…" and poll or refresh order status — never mark the order paid there. Conversely, if the ITN arrives first, return_url should show a success state. Design for both orders.
cancel_url is not a cancellation event
Users can hit "cancel" on the PayFast page, close the tab, or abandon. Never treat cancel_url as "order failed" — leave the order pending and let a cron prune abandoned orders after N hours. You can't trust the user's action.
Sandbox vs. production keys
PayFast sandbox requires separate merchant credentials. The most common "it works in dev, fails in prod" bug is shipping sandbox keys to production (or the reverse) because someone forgot to flip PAYFAST_SANDBOX. Log the target URL on every checkout (sandbox.payfast.co.za vs www.payfast.co.za) in non-production environments for quick triage.
No refunds, no webhook headers
This package is payments-in only. Refunds need the PayFast dashboard or a direct API call — plan for that separately. And since PayFast doesn't sign a request header (the signature is inside the body), your ITN controller must be the one doing all verification; don't rely on a middleware that doesn't exist.
Testing
PayFast's sandbox won't generate an ITN for every test scenario. Write a FakePayfastItn helper that constructs a signed payload and POSTs it to your route in tests — this is the fastest way to cover paid, amount-mismatch, replayed, and invalid-signature branches without touching the network.
When to outgrow the package
If you find yourself patching the signature helper, adding IP verification, and re-implementing the field mapping, you're at the point where a 150-line custom PayfastGateway class (form builder + verifyItn()) is more maintainable than a stale 2020 wrapper. The package's value is the form field ordering and signature; once you've reproduced those, the dependency adds risk more than convenience.
How can I help you explore Laravel packages today?