savannabits/daraja
Laravel package for Safaricom's M-Pesa Daraja API, simplifying STK Push, C2B, B2C, transaction status and reversal requests with fluent, configurable methods so you can integrate mobile payments into your PHP app quickly.
Install and get a sandbox round-trip working before anything else.
composer require savannabits/daraja
php artisan vendor:publish --provider="Savannabits\Daraja\DarajaServiceProvider"
The publish step is the whole onboarding. Everything you need to know about the package's runtime contract lives in the published config file and the service provider — not in the README.
Read these three things, in this order, before writing a line of application code:
config/daraja.php — the env keys, shortcode, passkey, initiator credentials, callback URLs and environment switch. Copy the key names into your .env.example verbatim. Config keys renamed between 0.2.0 and 1.0.0 will fail at runtime (blank shortcode, 404 from Safaricom), not at boot, so a typo costs you an afternoon.src/ — the facade and the class it resolves. Read the public method list and confirm the exact argument shape and return type. The package is small enough to read end-to-end in under an hour, and you should, because every one of those methods moves money.Get a publicly reachable callback URL first. STK Push and C2B callbacks are asynchronous POSTs from Safaricom; localhost is invisible to them. Run valet share / ngrok http 443 / expose and register those exact URLs both in your env and in the Daraja developer portal against your sandbox app. Mismatched callback URLs are the #1 cause of "the package is broken" on day one.
First use case: sandbox STK Push (Lipa na M-Pesa Online). It exercises the entire stack in one call — OAuth token acquisition, outbound POST, synchronous acknowledgement with a CheckoutRequestID, then the asynchronous callback. Nothing else in the package is worth touching until this works.
// Confirm the exact signature against src/ first — this is shape, not gospel.
$response = Daraja::stkPush([
'phone' => '254712345678',
'amount' => 1,
'reference' => 'INV-0001',
'description' => 'Sandbox smoke test',
'callback' => route('mpesa.callback'),
]);
Trigger it, capture the CheckoutRequestID, enter the sandbox PIN on the simulated prompt, and log the raw callback body. Do not build the payment state machine until you have that JSON in a log file — every downstream design decision is derived from it.
Done means: a database row written with the CheckoutRequestID before the request leaves your app, and a callback route that can find that row by CheckoutRequestID. That's the minimum viable integration; everything after is refinement.
Never call Daraja:: outside one class. This is the single highest-leverage day-to-day decision, and it takes ten minutes.
interface PaymentGateway {
public function requestPayment(ChargeRequest $req): ChargeResult;
public function queryStatus(string $checkoutRequestId): PaymentStatus;
public function reverse(string $receipt, int $amount): ReversalResult;
}
final class DarajaPaymentGateway implements PaymentGateway { /* the only file importing Savannabits\Daraja */ }
Bind the interface in a small provider, inject it everywhere. You get fakeable tests, one place for logging/retries/idempotency, and — given this package's maintenance profile — a fork-or-replace that costs one class instead of a grep across the codebase.
Persist the intent before you send. The canonical M-Pesa bug is an outbound request that succeeded while your process died before persisting the response. The customer is charged; you have no record.
$txn = MpesaTransaction::create([
'idempotency_key' => $key, // unique index
'amount' => $amount,
'phone' => $phone,
'status' => 'initiating',
]);
try {
$res = $this->gateway->requestPayment(new ChargeRequest(...));
$txn->update([
'checkout_request_id' => $res->checkoutRequestId,
'merchant_request_id' => $res->merchantRequestId,
'status' => 'pending',
'raw_response' => $res->raw,
]);
} catch (Throwable $e) {
$txn->update(['status' => 'indeterminate', 'raw_response' => $e->getMessage()]);
throw $e;
}
indeterminate is a real, permanent state — "we may have charged them, go query." Every timeout and every unrecognised result code lands there.
Funnel both entry points through one state transition. STK Push has a synchronous response and an asynchronous callback, and they race. Normalise both into one method guarded by a row lock:
DB::transaction(function () use ($checkoutRequestId, $payload) {
$txn = MpesaTransaction::where('checkout_request_id', $checkoutRequestId)
->lockForUpdate()
->firstOrFail();
if ($txn->isTerminal()) {
return; // duplicate callback — acknowledge and swallow
}
$txn->update([
'status' => mapResultCode($payload['ResultCode']),
'raw_callback' => $payload,
]);
// Fire your domain event only on the transition, never on the duplicate.
});
Always return HTTP 200 to Safaricom for anything you've already processed. A non-2xx response triggers their retry logic and multiplies your duplicate problem — which is how you get double credits.
Queue every outbound call. A synchronous Daraja call in a web request means 30-second page loads during a Safaricom incident. Use a dedicated queue connection, bounded concurrency (M-Pesa throttles per shortcode), ->timeout(60), ->tries(3), and backoff() with exponential delay. Wrap the client call in a RateLimiter at minimum so an upstream outage doesn't fill your queue and starve unrelated jobs.
A reconciliation command is not optional. Schedule it every 5–15 minutes and have it:
pending rows older than ~90 seconds.indeterminate rows.Callbacks genuinely get lost — in sandbox and in production, at different rates. Polling is your source of truth; the callback is a latency optimisation on top of it.
Test in three layers.
PaymentGateway with a fake — fast, covers your business logic.Http::fake() — catches payload drift when you upgrade or fork. This is the layer that makes the package replaceable.Use reversal and balance only where you mean it. Reversal needs a separate initiator credential set that teams routinely forget to configure until the day they need a refund. Exercise it in sandbox in week one, not in production in month three. Balance queries make a cheap liveness heartbeat for the shortcode — wire one into your health-check command.
Normalise at the boundary. Whether the package returns an array, an Illuminate\Http\Client\Response, or a DTO, convert it into your own ChargeResult / PaymentStatus immediately. Then an upstream field rename in a future version is a one-file change rather than a hunt through your codebase.
Treat 1.0.0 as an unread rewrite. The changelog is a single PR described as "complete improvement," with no migration guide, jumping 0.2.0 → 1.0.0. Before adopting, diff that PR and enumerate every public-surface change: namespace, facade alias, config keys, method signatures, exception classes, response shape. Then pin the exact version ("1.0.0", not ^1.0) and commit the published config rather than relying on package defaults. A composer update that silently changes a config default is a Tuesday-morning incident.
Verify the framework matrix yourself. Declared support is PHP 8.1 / Laravel 9, last released November 2022 — Laravel 9 is EOL. It may well work on 10/11/12, but "may work" is not a support statement you can put in a design doc. Run the suite, check guzzlehttp/guzzle constraints against your lockfile, and write the result down somewhere durable. On Laravel 8 or PHP 8.0, adopting this implies a framework upgrade — surface that as a roadmap item now, not mid-sprint.
Leave the OAuth cache alone, but know where it is. Token caching is the main reason to use this package instead of a hand-rolled client. Two consequences: if your deploy script's cache:clear wipes the package's store, you'll hammer the auth endpoint and get rate-limited — exclude it. And a cached-but-revoked token surfaces as a 401 on a business request, so handle 401 by flushing and retrying exactly once, never in a loop.
Callback authenticity is your problem, not the package's. Daraja callbacks are unauthenticated POSTs to a public URL. Harden the route: IP allowlisting for Safaricom's ranges, an obfuscated path segment, VerifyCsrfToken exclusion, no auth middleware — and a handler that trusts only the CheckoutRequestID and reads the amount from your database. A forged callback that credits an arbitrary amount is a direct, unrecoverable loss.
Phone numbers and amounts will bite you. MSISDN must be 2547XXXXXXXX — no +, no leading 0, no spaces. Amounts are integer KES; passing "10.50" earns you an unexpected ResultCode. Normalise in a value object or FormRequest rule at the edge, not in five scattered controllers.
Duplicate and out-of-order callbacks are the normal case. Build for them from commit one: unique index on checkout_request_id, lockForUpdate in the handler, absorbing terminal states, idempotent event dispatch. A double-credit is a business-loss event that no retry logic repairs — the only fix afterwards is manual ledger reconciliation and an unhappy ops team.
Result codes are a taxonomy, not a boolean. 0 success, 1032 cancelled by user, 1037 timeout / no customer response, 1 insufficient balance, 2001 wrong PIN. Map them into your own enum with user-facing copy. Anything unrecognised becomes indeterminate → query status, never failed. 1037 is the dangerous one: no STK response does not mean no charge.
Answer the multi-tenancy question before your schema is committed. If you need per-merchant shortcodes — marketplaces, franchises, multi-branch — check now whether the client is a boot-time singleton with fixed config or whether config is injectable per call. Single-config-only means one queue worker pool per shortcode, plus config([...]) at runtime with a scoped container binding. And beware: if the client caches one token per instance, per-tenant instances can collide on cache key. Cheapest thing to verify, most expensive thing to retrofit.
Own the knowledge, not the package's. Write the Daraja notes — the two-phase STK flow, the result-code table, callback retry behaviour, portal registration steps, the go-live approval checklist — into your own repo/wiki. The engineering is 1–3 days. Shortcode provisioning and Safaricom go-live approval is measured in weeks, and that, not code, is your launch date. Start the paperwork the same week you start the spike.
Name an owner and pre-approve a fork budget. One primary maintainer, 12 stars, zero dependents, no release since November 2022. That's acceptable for a pilot behind an interface on a pinned version. Watch the repo for releases, and know that the surface is small enough that forking is genuinely a day's work — decide who's allowed to do it before you need to.
How can I help you explore Laravel packages today?