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

Daraja Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. 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.
  2. 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.
  3. The service provider bindings — is the client a singleton with config bound at boot, or is it constructed per call? This single fact decides whether multi-tenant shortcodes are possible without a fork.

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.

Implementation Patterns

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:

  • Poll the status-query endpoint for pending rows older than ~90 seconds.
  • Do the same for indeterminate rows.
  • Escalate anything older than ~24 hours into a manual-review queue with a Slack ping.

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.

  1. Unit tests against PaymentGateway with a fake — fast, covers your business logic.
  2. Contract tests that replay recorded sandbox JSON fixtures through Http::fake() — catches payload drift when you upgrade or fork. This is the layer that makes the package replaceable.
  3. A manually-triggered sandbox smoke test, not gated on every PR. The sandbox is flaky enough that making it a merge gate trains the team to ignore red builds.

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.

Gotchas and Tips

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.

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.
3mdikram/worldmoneywords
snowsoft/config
itmosoft/laravel-subscriptions
larva/laravel-ai-qwen
diephp/sequences
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib