starnerz/laravel-daraja
Laravel Daraja wraps all seventeen Safaricom M-Pesa Daraja APIs in ordinary Laravel code: STK Push, C2B, B2C, B2B, Dynamic QR, Ratiba, Bill Manager, typed responses, cached tokens, and testable callbacks. PHP 8.3+, Laravel 12/13.
composer require starnerz/laravel-daraja. v5.2.0 accepts guzzlehttp/guzzle: ^7.8 || ^8.0, so it installs cleanly on both Laravel 12 (Guzzle 7) and Laravel 13 (Guzzle 8) stacks.php artisan vendor:publish and pick the Daraja provider) and commit config/daraja.php with env-driven values only — never the credentials themselves..env from your Safaricom developer portal app: consumer key/secret, shortcode (Paybill/Till), passkey, initiator name/password (for B2C/B2B), and the callback/result URLs. Set sandbox vs production via env so staging can never hit live money.starnerz.github.io/daraja-docs/ links still redirect, so bookmarks and blog posts keep working.CheckoutRequestID, and receive the result asynchronously at your callback URL. That single flow exercises almost everything else — token auth, request signing, callback routing, and reconciliation — so build it end-to-end in sandbox before touching B2C or C2B.php artisan config:clear (and config:cache in deploy) after changing any Daraja env value; the package reads cached config.Keep the SDK at the edge of your domain. Wrap every package call in your own small service (e.g. App\Services\Mpesa\StkService) that translates your models into package calls and the package response into your own DTO. When request/response shapes shift, you fix one class instead of every controller.
One callback controller per product, one route each. Safaricom posts to distinct URLs for STK, C2B confirmation, C2B validation, B2C result, balance result, and reversal result. Registering them separately keeps parsing logic honest — the payload shapes genuinely differ (Body.stkCallback vs Result vs TransactionType), and a single "smart" handler becomes unreadable fast.
Acknowledge first, process later. Callbacks are fire-and-forget HTTP POSTs to your app; Safaricom does not retry reliably and will time out. Return 200 immediately, dispatch a queued job with the raw payload, and do the ledger work in the worker. Persist the raw JSON before dispatching so you can replay it.
Make the callback idempotent and unique-indexed. Store CheckoutRequestID / MerchantRequestID / TransID with a unique constraint. Safaricom retries, sandbox duplicates, and your own replay jobs will all hit the same row eventually. An upsert keyed on the request ID is the cheapest insurance you'll ever write.
Model the lifecycle, don't infer it. Give yourself a mpesa_transactions table with an explicit status enum (pending → success | failed | cancelled | timeout) plus the raw response. Your order fulfilment should key off that state, not off the raw ResultCode scattered through the codebase.
Reconcile the silences. STK pushes that never call back are the single most common production complaint. Ship a scheduled command that finds pending transactions older than ~2 minutes and calls the Transaction Status API. Never mark an order paid purely because a callback arrived — confirm with a status query for anything above a low value threshold.
Per-tenant / multi-shortcode config. If you serve multiple merchants, resolve shortcode and passkey at runtime rather than reading the singleton config. Cache each tenant's OAuth token separately; tokens are valid roughly an hour and re-fetching on every request is the fastest way to get throttled.
Test with Http::fake(), not the sandbox. Assert your request payload (amount is an integer, phone is 2547XXXXXXXX) and your callback handling by posting fixture JSON directly at your route. Reserve live sandbox credentials for a nightly/manual suite.
Version pinning. If another dependency in your app hard-requires guzzlehttp/guzzle: ^7.0, you simply won't resolve Guzzle 8 — that's fine, nothing in this package forces it. Nothing changed in the package code in 5.2.0, so upgrading from 5.1.x is a composer update with no code edits.
routes/api.php (or withoutMiddleware), and never behind auth — Safaricom's servers have no session. Local dev needs a tunnel (ngrok/expose); localhost callbacks silently never arrive.10.5 fails or gets truncated. Convert at the boundary, not in the package call.2547XXXXXXXX / 2541XXXXXXXX. Strip +, leading 0, and spaces in one place.0 success, 1032 cancelled by user, 1037 user unreachable/timeout, 2001 wrong PIN, 1 insufficient balance, 1019/1001 transaction in progress. Map them to human messages once and reuse.timeout, not failed, and surface a retry to the user.1032.daraja.php after an env change is a classic "why is it still hitting sandbox" bug. config:clear in the release step, always.SECURITY.md pointer you mirrored.How can I help you explore Laravel packages today?