njoguamos/laravel-pesapal
Laravel package for the Pesapal v3 API that generates and caches access tokens, stores Instant Payment Notifications in the database, and lets you submit order requests and check transaction statuses without rewriting integration code.
Minimum viable setup (about 10 minutes):
composer require njoguamos/laravel-pesapal
php artisan vendor:publish --tag="pesapal-migrations"
php artisan migrate
php artisan vendor:publish --tag="pesapal-config" # optional but recommended
Add credentials to .env from your Pesapal merchant dashboard (start with the sandbox keys):
PESAPAL_LIVE=false
PESAPAL_CONSUMER_KEY=
PESAPAL_CONSUMER_SECRET=
Where to look first: open config/pesapal.php after publishing, then read src/Pesapal.php — the entire public surface of this package is that one static class plus the DTOs and enums in src/DTOs and src/Enums. It is a thin Saloon wrapper; there is very little magic, so 15 minutes of source reading pays for itself.
First use case — take one payment end to end:
// routes/console.php (Laravel 11/12)
use Illuminate\Support\Facades\Schedule;
Schedule::command('pesapal:auth')->everyFourMinutes()->withoutOverlapping();
php artisan pesapal:auth # run once manually to confirm credentials work
ipn_id for every order:use NjoguAmos\Pesapal\Pesapal;
use NjoguAmos\Pesapal\Enums\IpnType;
$ipn = Pesapal::createIpn(
url: 'https://yourapp.com/pesapal/ipn',
ipnType: IpnType::GET,
);
config(['services.pesapal.ipn_id' => $ipn->ipn_id]); // or store in a settings table
use NjoguAmos\Pesapal\{Pesapal};
use NjoguAmos\Pesapal\DTOs\{PesapalAddressData, PesapalOrderData};
use NjoguAmos\Pesapal\Enums\{ISOCountryCode, ISOCurrencyCode, RedirectMode};
$orderData = new PesapalOrderData(
id: (string) $order->id, // becomes merchant_reference — must be unique
currency: ISOCurrencyCode::KES,
amount: 1500.00,
description: "Order #{$order->id}",
callbackUrl: route('pesapal.callback'),
notificationId: config('services.pesapal.ipn_id'),
cancellationUrl: route('checkout.cancel'),
redirectMode: RedirectMode::PARENT_WINDOW,
);
$billingAddress = new PesapalAddressData(
phoneNumber: '0700325008', // phone or email required
emailAddress: $order->user->email,
countryCode: ISOCountryCode::KE,
);
$response = Pesapal::createOrder($orderData, $billingAddress);
if (is_array($response)) {
$order->update(['pesapal_tracking_id' => $response['order_tracking_id']]);
return redirect()->away($response['redirect_url']);
}
Pesapal::getTransactionStatus() — never trust the query string alone. See the next section.That is the whole loop: token → IPN → order → redirect → verify.
Never call the static class from controllers directly. Wrap it in one application service (App\Services\PesapalGateway) that normalizes the array|SaloonResponse union, logs failures, and maps the response into a domain object. The union return type is the single biggest friction point in daily use, and centralizing the is_array() branch keeps it out of twenty call sites:
class PesapalGateway
{
public function createOrder(PesapalOrderData $data, PesapalAddressData $address): OrderResult
{
$response = Pesapal::createOrder($data, $address);
if (! is_array($response)) {
report(new PesapalFailed($response->status(), $response->body()));
throw new PaymentProviderException('Unable to start payment');
}
return new OrderResult($response['order_tracking_id'], $response['redirect_url']);
}
}
Persist the mapping, not just the tracking id. merchant_reference is your id field echoed back to you on the callback (?OrderMerchantReference=). Store order_tracking_id on your order row with a unique index so a callback can resolve it in one query and re-entrant callbacks are idempotent.
Callback handler pattern — re-query, then reconcile:
public function callback(Request $request)
{
$order = Order::where('pesapal_tracking_id', $request->query('OrderTrackingId'))
->lockForUpdate()
->firstOrFail();
$status = Pesapal::getTransactionStatus($order->pesapal_tracking_id);
if (! is_array($status)) {
return response('pending', 200); // let Pesapal retry; don't 500
}
$paid = $status['status_code'] === 1
&& $status['payment_status_description'] === 'Completed'
&& (float) $status['amount'] >= (float) $order->total
&& $status['currency'] === 'KES'
&& $status['merchant_reference'] === (string) $order->id;
if ($paid) {
$order->markAsPaid($status['confirmation_code']);
}
return response('ok', 200); // always 200 so Pesapal stops retrying
}
Amount, currency, and merchant_reference checks matter — a mismatched callback for a cheaper order is the classic way to lose money on an integration like this.
Idempotency in a job/transaction. Callbacks arrive more than once. Do the marking inside DB::transaction with lockForUpdate() and guard on a paid_at column being null, or dispatch a queued VerifyPayment job keyed on the tracking id with ShouldBeUnique.
Abandoned-payment recovery. Use Pesapal::getRedirectUrl($trackingId) to send a user back into the checkout if they dropped out — no need to build a second order. This is the cheapest win the package gives you.
Scheduling in Laravel 11/12 — two commands, not one. Tokens accumulate rows, and stale rows make debugging "which token am I using?" harder:
Schedule::command('pesapal:auth')->everyFourMinutes()->withoutOverlapping();
Schedule::command('model:prune', ['--model' => [PesapalToken::class]])->hourly();
IPN registration belongs in a deploy step, not a request. Put an artisan command (php artisan pesapal:register-ipn) that creates the IPN if one doesn't exist for the current environment URL and writes the ipn_id to a settings row. Run it per environment — the sandbox ipn_id is not valid in live.
Testing. The package's own suite hits the real sandbox and stores fixtures, so do not copy that approach in your app. Fake at the HTTP layer with Saloon's mock client, or simply bind your PesapalGateway to a fake in the container and assert your controller's behaviour. Test the failure branch explicitly — that's the branch you'll otherwise ship broken.
Local development. Pesapal must be able to reach your IPN and callback URLs. Use a tunnel (ngrok, expose, valet share) and register a temporary IPN per developer, or skip createIpn locally and point PESAPAL_LIVE=false with the sandbox URL.
The five-minute token is the number one source of "it just stopped working". pesapal:auth must be running on a scheduler that actually runs (schedule:work in dev, cron schedule:run in prod). If the token expires mid-flight you get a Saloon Response, not an exception — see below. Consider adding a Schedule::command('pesapal:auth') failure alert with ->onFailure() or an emailOutputOnFailure().
Saloon does not throw by default. A failed call returns a NjoguAmos\Pesapal\...\Response instance, so is_array($result) is your only success signal. Every if (is_array(...)) you skip is a silently unpaid order. Log $response->status() and $response->body() on the else branch — without that, debugging is guesswork.
createToken() returns null on failure. Nothing throws. If you call it outside the artisan command, check for null or you'll store a null token and every downstream call dies.
IpnType enum vs. the type column. The database stores type as an integer (0/1) while the API and DTO use IpnType::GET/POST. Don't assume $ipn->type is an enum when you read a row back from PesapalIpn; inspect the cast in the model before comparing.
Two different IPN sources, one table. Pesapal::getIpns() returns the remote list from Pesapal; PesapalIpn::all() returns what this package has stored locally. They can drift — IPNs registered directly in the Pesapal dashboard won't be in your table. Treat the local table as a cache, not the source of truth, and backfill with getIpns() when in doubt.
Order creation hard-requires a registered IPN. A stale notificationId produces an opaque API error. Validate that the ipn_id you're passing still exists before you build the DTO.
Floats and money. PesapalOrderData::$amount is a float. Pass values derived from integer minor units ($cents / 100) rather than accumulating float arithmetic, and store the amount you intended on your order so the callback reconciliation compares against a fixed value, not a recomputed one.
PESAPAL_LIVE boolean parsing. env('PESAPAL_LIVE') string-to-bool semantics bite people who write PESAPAL_LIVE="false" and forget php artisan config:clear. After any .env change run php artisan config:clear — and remember config:cache in production will freeze the value.
Publish migrations before config caching in CI. php artisan vendor:publish --tag="pesapal-migrations" then migrate must happen before anything reads pesapal_tokens. If you use migrate:fresh in test setup, re-register the fake IPN — the seeded ipn_id is gone.
Redirect modes matter for UX. RedirectMode::PARENT_WINDOW keeps the user inside your site but breaks if you're framed or if the browser blocks it; TOP_WINDOW is the safer default for standalone checkouts. Test whichever you pick in the actual sandbox iframe.
Recurring payments and refunds are unimplemented. The README literally has - [ ] TODO for both. If you need them you'll be reaching for the package's Saloon connector directly, or bypassing the package with your own HTTP client. Budget for that up front rather than discovering it mid-sprint.
Extension points are thin but real. The package ships a Saloon connector; you can resolve it from the container and add middleware, retries, or your own requests without forking. If you need ->retry() or custom timeouts, that's the seam — check src/Http for the connector class name in the version you install.
Small-community risk management. Eight stars, zero dependents, and a fast-moving 2.x line. Pin the exact version in composer.json ("njoguamos/laravel-pesapal": "^2.4" at minimum), keep your PesapalGateway wrapper free of package internals, and be ready to vendor the handful of files if upstream goes quiet. The wrapper pattern above is your insurance policy: replacing this package later should mean rewriting one class, not grepping the codebase.
Debugging checklist when a payment "vanishes": is the token fresh (SELECT expires_at FROM pesapal_tokens)? did createOrder return an array or a Response? is the ipn_id still registered (Pesapal::getIpns())? is your callback URL publicly reachable? does getTransactionStatus return status_code === 1? Work down that list in order and you'll resolve most incidents in a couple of minutes.
How can I help you explore Laravel packages today?