Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Mpesa Laravel Package

iankumu/mpesa

iankumu/mpesa is a Laravel package that streamlines Safaricom M-Pesa integration. It supports STK Push, C2B, B2C, transaction status, reversals, and callback handling through a clean, configurable API, making it easier to build payments, collections, and disbursement flows.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Install and scaffold. The package ships a service provider with auto-discovery, so the only required step is the Composer require. Everything else is opt-in publishing:

composer require iankumu/mpesa
php artisan vendor:publish --tag=mpesa-config
php artisan vendor:publish --tag=mpesa-migrations
php artisan migrate

Publish config before migrations — the migration set typically defines the transactions/callback table the rest of the package (and your code) will read from. If you already own a transactions table, inspect the published migration first and rename or skip it rather than migrating blind.

2. Read two files first. Before writing a line of application code, open:

  • config/mpesa.php — this is the whole mental model: consumer key/secret, passkey, shortcode, env (sandbox vs production), callback URLs, and the base URLs per environment. Everything you configure for day-to-day work lives here.
  • The facade's concrete class (usually src/Mpesa.php or a Mpesa service class) — it is the authoritative method list. Method names below are the common shape; confirm against the facade since this package is pre-3.x and signatures move in minors.

3. Fill in .env. Typical keys:

MPESA_ENV=sandbox
MPESA_CONSUMER_KEY=
MPESA_CONSUMER_SECRET=
MPESA_PASSKEY=
MPESA_SHORTCODE=174379
MPESA_CALLBACK_URL=https://your-tunnel.example.com/api/mpesa/callback

Never commit these. Add a config/mpesa.php default of env('MPESA_...') check if the published file hardcodes anything — it typically does not, but verify once.

4. First use case: STK Push on checkout. This is 90% of real usage. Your controller calls the facade, stores the CheckoutRequestID, and returns "check your phone" to the user. The payment is not confirmed here.

use Iankumu\Mpesa\Facades\Mpesa;

$response = Mpesa::stkPush(
    phoneNumber: '254708374149',
    amount: 100,
    accountReference: $order->reference,
    transactionDesc: $order->description,
    callbackUrl: route('mpesa.callback'),
);

$order->update([
    'checkout_request_id' => $response['CheckoutRequestID'],
    'merchant_request_id' => $response['MerchantRequestID'],
]);

Then a single callback route handles the asynchronous result. Start with sandbox test MSISDN 254708374149 and shortcode 174379; that combination is what the Daraja sandbox simulator responds to.

5. Expose the callback URL. Sandbox requires a publicly reachable HTTPS endpoint. Use ngrok/expose/Valet share in development and put that URL in MPESA_CALLBACK_URL. Callbacks will never arrive on localhost, and silent non-arrival is the #1 first-day confusion.

Implementation Patterns

Treat the callback as the source of truth, never the STK response. The clean pattern is a thin controller that persists the raw payload, returns 200 immediately, and dispatches a job:

public function callback(Request $request)
{
    MpesaCallback::create(['payload' => $request->all()]);

    ProcessMpesaCallback::dispatch($request->all());

    return response()->json(['ResultCode' => 0, 'ResultDesc' => 'Accepted']);
}

Doing the order-fulfilment work inline is the most common production bug in M-Pesa integrations: Safaricom retries on timeout, so a slow handler produces duplicate callbacks.

Model a payment state machine, not a boolean. Orders move pending → request_accepted → paid | failed | cancelled | timed_out. The STK response only proves request_accepted. Store CheckoutRequestID, MerchantRequestID, MpesaReceiptNumber, ResultCode, ResultDesc, and the flattened CallbackMetadata on the order or a payments table.

Idempotency is non-negotiable. Callbacks arrive multiple times. Key on CheckoutRequestID (STK) or TransID (C2B/B2C) with a unique index, and wrap the fulfilment in a transaction with lockForUpdate():

DB::transaction(function () use ($payload) {
    $payment = Payment::where('checkout_request_id', $payload['CheckoutRequestID'])
        ->lockForUpdate()
        ->firstOrFail();

    if ($payment->status === 'paid') {
        return; // already processed, swallow the duplicate
    }

    $payment->markPaid($payload);
});

Reconciliation job for anything that matters. Callbacks get lost. Schedule a job that finds request_accepted payments older than two minutes and calls the transaction-status query (Mpesa::transactionStatus(...) or stkQuery), then applies the same state transition. This is your safety net, and it also gives you a way to debug sandbox flows where callbacks were misconfigured.

C2B for Paybill/Till flows. Two distinct pieces: register the validation and confirmation URLs once per shortcode (Mpesa::c2bRegisterUrls() — do this from an artisan command or a one-off tinker call, not on every deploy), then handle both incoming endpoints. The validation endpoint must return {"ResultCode": 0, "ResultDesc": "Accepted"} to allow the payment; return a non-zero code to reject. Confirmation is the one that credits the order.

B2C for payouts and refunds. B2C is outbound money, so wrap it in a queued job with its own retry policy and a hard cap. Crucially, B2C needs more than consumer key/secret: an initiator name and a security credential (the initiator password RSA-encrypted with Safaricom's public certificate). Keep the certificate on disk and generate the credential at boot, not per request.

Multi-tenant shortcodes. If you serve multiple merchants, avoid the single global config. Either set config at runtime right before the call inside a queued job:

config([
    'mpesa.consumer_key' => $tenant->consumer_key,
    'mpesa.shortcode' => $tenant->shortcode,
    // ...
]);

…or bind a configured service instance per tenant. Runtime config() mutation is safe inside a job but not inside a long-lived request that also serves another tenant — that is a real leak in Octane.

Test against a fake, not the sandbox. Write a FakeMpesa binding that records calls and returns canned payloads, and a test helper that POSTs a realistic callback body to your route. Assert on the state machine transitions, not on the HTTP client. Reserve real sandbox calls for a smoke test of credentials and callback URL reachability.

Use the events. If the package emits events on callback receipt, hang your domain listeners (send receipt email, fire webhook to a partner, increment loyalty) off those rather than off your controller. It keeps the callback path thin and makes the side effects replayable when you re-drive a missed callback.

Gotchas and Tips

Callbacks are unauthenticated public POSTs. Anyone who learns the URL can post a fake "success". Defend with: an unguessable path segment, an IP allowlist on Safaricom's published ranges at the edge, and — most importantly — verify the amount and the CheckoutRequestID against an open payment record before marking anything paid. Never trust the callback's amount alone.

ResultCode and ResponseCode are different things. ResponseCode: 0 on the STK response means "request accepted". The callback's ResultCode: 0 means paid. Also: 1032 = cancelled by user, 1037 = timeout/no response, 1 = insufficient funds, 2001 = wrong PIN. Map these to user-facing copy — a generic "payment failed" for a user who simply cancelled is a support-ticket generator.

CallbackMetadata is an array of {Name, Value} pairs, not a map. Flatten it once in a helper:

collect($payload['CallbackMetadata']['Item'])
    ->pluck('Value', 'Name')
    ->all();
// ['Amount' => 100, 'MpesaReceiptNumber' => '...', 'PhoneNumber' => 2547...]

Skipping this and reaching for $payload['CallbackMetadata']['Amount'] is the classic first bug.

Normalise phone numbers at the boundary. Daraya wants 2547XXXXXXXX — no +, no leading 0, no spaces. Cast and validate before the call; the API's error for a malformed MSISDN is unhelpful.

Amounts are integers. 100.00 becomes 100, and 99.99 will either be rejected or silently truncated. If your prices have decimals, decide explicitly whether you round or block.

Do the HTTP call outside the request/response cycle where possible. M-Pesa endpoints are slow (multi-second) and rate-limited. Queue the STK push when the UX permits, and always queue B2C and status queries. Also cache the OAuth token — the package handles auth, but if you're issuing many calls, repeated token fetches will hit rate limits.

Watch the token/rate-limit behaviour under load. Daraya throttles per app. A burst of checkouts in a flash sale can produce 429s that surface as generic failures. Add a small retry-with-backoff around the facade call and log the raw response body on failure.

Log hygiene. The package logs requests by default in some versions. Confirm it does not log the Authorization header or the consumer secret at debug level before you point it at production, and never dd() a full config array into a log channel.

Migrations may collide. The published migration creates its own table. If you already track payments, either adapt the package table as an audit log and keep your domain table separate, or skip the publish. Add indexes on CheckoutRequestID and MpesaReceiptNumber yourself — the callback lookup path is hot.

Sandbox quirks will cost you an afternoon. Test MSISDN 254708374149 only; the simulator ignores others. Callback URL must be HTTPS and publicly resolvable. If callbacks never fire, check the URL in config/mpesa.php (not just .env) — env caching with php artisan config:cache is the usual culprit after a tunnel restart.

Version pinning matters more than usual here. Laravel 13 support landed in 2.3.0, which is the first release you can use on a Laravel 13 app. Pin ^2.3 in composer.json, and check the package's PHP constraint against yours before an upgrade — a mismatch will fail at Composer resolution rather than at runtime, which is the good failure mode. Given the package is young and the surface area is small, keep the integration behind your own thin service class (PaymentGateway) so a future swap or fork is a one-file change rather than a hunt through controllers.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums
rlacerda83/query-parser
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings