itsmurumba/laravel-mpesa
A Laravel package for integrating Safaricom M-Pesa into your applications. It handles STK Push, C2B and B2C payments, transaction status queries, and callback processing behind a clean, configurable API so you can accept and send mobile money in minutes.
Install and publish config
composer require itsmurumba/laravel-mpesa
php artisan vendor:publish --tag=mpesa-config # or --provider="Itsmurumba\Mpesa\MpesaServiceProvider"
The package auto-registers its service provider and Mpesa facade on Laravel 5.5+. On older versions, add the provider and 'Mpesa' => Itsmurumba\Mpesa\Facades\Mpesa::class to config/app.php.
Where to look first
config/mpesa.php — this is the source of truth for env variable names, endpoint selection, and per-service credentials.Examples/ or usage snippets — the method signatures mirror Daraja payloads closely, so the README doubles as the mapping table.Set credentials in .env
MPESA_ENV=sandbox
MPESA_CONSUMER_KEY=your_key
MPESA_CONSUMER_SECRET=your_secret
MPESA_SHORTCODE=174379
MPESA_PASSKEY=bfb279f9aa9bdbcf158e97dd71a467cd2e0c893059b10f78e6b72ada1ed2c919
MPESA_CALLBACK_URL=https://your-app.test/api/mpesa/callback
# B2C / reversals / balance only:
MPESA_INITIATOR_NAME=testapi
MPESA_INITIATOR_PASSWORD=Safaricom999!*!
Then always run php artisan config:clear after editing .env on cached environments.
First use case — STK Push (Lipa na M-Pesa)
use Itsmurumba\Mpesa\Facades\Mpesa;
$response = Mpesa::stkPush([
'amount' => 1,
'phone' => '254712345678',
'accountReference' => 'INV-1001',
'transactionDesc' => 'Order payment',
]);
// Persist $response['CheckoutRequestID'] against your order immediately.
Register a callback route that accepts the Daraja POST body:
Route::post('/api/mpesa/callback', [MpesaCallbackController::class, 'handle'])
->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);
The callback payload is { "Body": { "stkCallback": { ... } } } — treat that envelope as fixed and normalize early.
Wrap the facade in a domain service, not controllers. Keep Mpesa:: calls in something like App\Services\MpesaService so you can swap envs, mock in tests, and centralize logging of raw responses. Controllers stay thin: validate → dispatch → persist request ID → return.
Store the request identifiers before returning to the user. Every initiate call returns MerchantRequestID and CheckoutRequestID. Persist both plus your internal order ID in a payments.mpesa_transactions table. The callback only carries CheckoutRequestID, so your matching key must exist before the provider responds.
Callback handling pattern — verify, match, idempotent update, respond 200 fast. Daraja retries on non-200, so:
public function handle(Request $request)
{
$callback = $request->input('Body.stkCallback');
$tx = MpesaTransaction::where('checkout_request_id', $callback['CheckoutRequestID'])->firstOrFail();
if ($tx->status !== 'pending') {
return response()->json(['ResultCode' => 0, 'ResultDesc' => 'Accepted']);
}
// Dispatch a job; return immediately.
ProcessMpesaCallback::dispatch($tx, $callback);
return response()->json(['ResultCode' => 0, 'ResultDesc' => 'Accepted']);
}
Normalize the callback into your own status enum. Daraja’s ResultCode (0, 1032, 1037, 1) maps to success / user-cancelled / timeout / insufficient funds. Don’t let raw codes leak into your domain models. Extract Amount, MpesaReceiptNumber, PhoneNumber, and TransactionDate from CallbackMetadata.Item[] — the array is unordered, so index by Name, not position.
Queue the reconciliation jobs. If a callback never arrives (user cancels, network), schedule a TransactionStatus query after ~2 minutes and again after ~30. The package exposes transactionStatus, accountBalance, and reversal — use them from a scheduled command, not in the request cycle.
Separate flows by audience. STK Push covers customer-to-business online checkout. Register C2B URLs once (registerUrls) for paybill/till payments; handle the validation and confirmation endpoints similarly. Use B2C for payouts — those need InitiatorName/SecurityCredential and are typically the flow that breaks in production because developers forgot the initiator password is the encrypted credential, not the plain password.
Multi-tenant / multi-shortcode. Don’t rely on config() alone if you serve multiple paybills. Either bind a resolved config object per request, or fork config at runtime (config(['mpesa.shortcode' => $tenant->shortcode])) inside your service before each call. Keep the passkey tied to the shortcode, not global.
Testing. Fake the facade in feature tests:
Mpesa::shouldReceive('stkPush')->once()->andReturn(['CheckoutRequestID' => 'ws_CO_123']);
Integration tests against the sandbox are the only way to validate callback shape — write one happy-path and one cancelled-user test with the sandbox test numbers (254708374149 etc.) documented in the Daraja portal.
Observability. Log the full request and response (redacting SecurityCredential and consumer secret) into a mpesa_api_logs table or a dedicated log channel. Every production incident with M-Pesa is answered by “what did Daraja actually return,” and it is not recoverable retroactively.
localhost URL in .env is the most common silent failure.2547XXXXXXXX or 2541XXXXXXXX. 07... and +254... fail.config:cache breaks env reading. If credentials change, run php artisan config:clear (and optimize:clear locally). Stale cached tokens cause 400.002.02 errors that look like auth issues.174379 and the well-known passkey. Don’t commit them as “secrets” and then assume production will work — production uses your real shortcode and passkey from the Daraja portal.ResultCode 1032 means cancelled by user — do not retry. 1037 is timeout — retry once. 0 is success. Treat anything else as a soft failure and reconcile via transactionStatus.404 from /mpesa/b2c/v1/paymentrequest usually means the product isn’t enabled, not a code bug.CallbackMetadata is absent on failures. Guard $callback['CallbackMetadata']['Item'] — accessing it on a cancelled transaction throws.Carbon::parse($date, 'Africa/Nairobi') before converting for display or persistence.CheckoutRequestID on retries. Otherwise a user double-tapping “Pay” produces two STK prompts.VerifyCsrfToken. Forget this and Daraja gets a 419, which it interprets as a permanent failure and stops retrying.{ "ResultCode": 0, "ResultDesc": "Accepted" } — 200 with a different body is fine for STK, but C2B confirmation expects that shape.Mpesa::accountBalance() in a tinker session against sandbox first. If auth works there, any subsequent failure is a payload problem, not a credential problem — halves your debugging surface.How can I help you explore Laravel packages today?