safaricom/mpesa
Laravel package for Safaricom M-Pesa integration, simplifying STK Push, C2B, B2C, transaction status, and callback handling. Configure credentials, initiate payments, and reconcile responses with clean, expressive PHP APIs built for Laravel applications.
Revision note (1.0.8). Upstream published 1.0.8 — "Allows parsing of .env parameters." That is a one-line note with no changelog, no dependency/compatibility statement, and no date or scope. Two conclusions follow: (a) the "permanently abandoned" framing softens to "very low cadence, unproven maintenance" — a single release after a multi-year gap is not a maintenance commitment; (b) the feature itself is a mild negative for our use case, because a transport library that reads .env directly conflicts with Laravel's config model. Net verdict is unchanged: fork, don't depend.
Architecture fit. safaricom/mpesa remains a thin, framework-agnostic PHP client over the M-Pesa Daraja REST surface (OAuth token, STK Push/Query, C2B register/simulate, B2C, B2B, reversal, balance, transaction status). That still makes it a natural anti-corruption layer — behind a first-party MpesaGateway interface in the payments domain, never called from controllers or models. It is not a Laravel package: no service provider, facade, publishable config, queue integration, or events. Everything Laravel-shaped (config, DI binding, retries, jobs, webhook routes, persistence of CheckoutRequestID/MerchantRequestID) is ours.
The 1.0.8 release adds a fourth thing that is not Laravel-shaped: an in-library .env parser. In a Laravel app this is an anti-pattern, not a convenience — env() is only meaningful inside config/ files, and after php artisan config:cache Laravel does not load .env at all. A library that resolves credentials from ambient environment state will behave inconsistently across local, CI, cached-config production, and container images shipped without a .env file. It also widens the secret-handling surface: a parser that ingests consumer secret, passkey, and initiator password from process env is one stack trace away from exposure.
Integration feasibility. Three gates now, before any code:
| Gate | Concern | Why it matters |
|---|---|---|
| Dependency resolution | 2019-era composer.json likely pins PHP ~7.0–7.2 and Guzzle 5/6; 1.0.8 does not advertise a bump |
Laravel 9+ ships Guzzle 7 and requires PHP 8.0+; composer require may fail or force an app-wide downgrade |
| API currency | Daraja has evolved (STK, dynamic QR, Bill Manager, ratiba, new result codes) | A 2019-core client may cover only the core four products and miss current fields/endpoints |
| Config model (new) | .env parsing — is it implicit on construction or opt-in? Does it survive config:cache? |
Determines whether the library can be used at all in a cached-config production deployment |
None is fatal; all argue for vendoring/forking under our namespace and deleting the env loader.
Technical risk register.
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Low-cadence upstream; security patches not guaranteed | High | High | Fork into app/Services/Mpesa or internal acme/mpesa-client; we own it. One release does not reset this |
| Unpinned/outdated transitive deps (Guzzle, PSR) | High | Medium | Rebuild against Guzzle 7 + PHP 8.x, or drop Guzzle and use Laravel's Http facade |
In-library .env parsing (1.0.8) bypasses config() / breaks under config:cache |
High | Medium | Strip in fork; credentials only via config('services.mpesa.*'); fail-fast validation at boot |
| Credential handling left to caller — and now possibly auto-read from ambient env | High | High | Secret manager + config() only; never log payloads with MSISDN or credentials; scrub exception payloads |
| No idempotency or callback verification primitives | High | High | DB-level dedupe on CheckoutRequestID/TransID; verify callback source |
| Untested error/result-code paths | Medium | High | Result-code map (0, 1, 1032, 1037, 2001…) → explicit domain outcomes |
| No retry/backoff, default timeouts | Medium | Medium | Laravel HTTP retry policy + explicit connect/read timeouts |
Key questions (answer before committing):
Stack fit. Unchanged: the package is transport, and three layers go around it.
MpesaClient (forked/adapter) — normalized methods (stkPush, stkQuery, b2c, c2bRegister) returning typed DTOs, not raw arrays. The 1.0.8 env parser is deleted in the fork; the client receives a config object in its constructor and nothing else.MpesaGateway (Laravel service) — bound in a service provider, config('services.mpesa.*') driven, sandbox/production base URLs per environment, OAuth token cached with an expiry margin (never minted per request).InitiatePayment / HandleCallback actions, queued outbound jobs, queued callback listener, PaymentInitiated/PaymentSettled events, mpesa_transactions as reconciliation source of truth.Migration path. Four options, ranked:
| Option | Effort | Verdict |
|---|---|---|
A. composer require upstream as-is |
1–2 d | Reject — dependency risk, no owner, and 1.0.8 adds a config model that fights Laravel's |
| B. Fork into internal package, upgrade deps, remove env parser, add tests | 5–8 d | Recommend — cheapest credible path, preserves vendor payload fidelity |
C. Use strictly as spec, write ~400-line in-house client on Laravel Http |
6–10 d | Viable if scope is only STK + C2B |
| D. Adopt a maintained community Laravel package | 3–5 d + vetting | Evaluate in parallel; wins if it covers callback verification and idempotency |
Recommended: B with a C-shaped boundary — the MpesaGateway interface makes swapping to a maintained package a one-class change later.
Compatibility. Pin PHP 8.1+/8.2+, Guzzle 7 (or bypass Guzzle via Http to eliminate the conflict), PSR-4 under our vendor namespace, PSR-3 logger injection. Explicitly test:
php artisan config:cache — the deployment mode where library-side .env reading silently yields nulls. This is now a required test case, not a nicety..env on disk — production and CI reality.Sequencing.
config:cache and with no .env present. Gate: does it run at all on target stack, and does it run in a cached-config container?Maintenance. Still a permanent fork commitment, not a dependency: ~2–4 engineer-days per quarter for dependency bumps, Daraja schema drift, and security review. The 1.0.8 release should not be read as revival — a single undocumented feature after a long gap is a data point, not a cadence. Keep a named owning team and an explicit sunset/replace trigger ("if a maintained package covers STK+C2B+B2C with callback verification by Q3, migrate"). Additional standing task introduced by 1.0.8: monitor whether upstream keeps expanding env/config handling, and if so, whether any of it is worth back-porting into the fork. Default answer: no.
Support. No vendor SLA on the package. Two-tier escalation: internal on-call for our code, Safaricom Daraja support portal for platform issues (shortcodes, go-live approval, callback whitelisting, rate limits). Document the Daraja path and expected response times in the runbook — go-live approval alone can take days and must be scheduled ahead of a release, not during it. Note that 1.0.8 shipped without a changelog: treat every future release as opaque and diff it before adopting.
Scaling. The client is stateless HTTP and scales horizontally; constraints sit elsewhere:
PENDING; the safety net for every dropped callback.Failure modes to design for explicitly.
| Failure | Symptom | Handling |
|---|---|---|
| Env parser active in a cached-config deployment | Blank/null credentials at runtime, or secrets sourced from an unexpected place | Boot-time config validation that fails loudly; fork has no env loader; test under config:cache |
| Token expiry / 404 on token | Auth errors mid-traffic | Refresh-and-retry once, then fail loudly |
| Callback never arrives | Payment stuck PENDING |
Scheduled Transaction Status query + timeout to terminal state |
| Duplicate callback | Double-crediting a customer | Unique index on TransID; idempotent state machine |
| User cancels / wrong PIN / insufficient funds | Non-zero ResultCode |
Map to domain outcomes; never treat transport 200 as success |
| Gateway timeout with side effect (push actually sent) | Orphaned transaction | Persist the request before sending; query, don't re-push |
| MSISDN format drift (254… / 0… / +254…) | Request rejected | Normalize at the boundary, one tested helper |
Ramp-up. A mid-level Laravel engineer is productive on the fork in ~3–5 days given a runbook, sandbox credentials, and one worked example. The curve is M-Pesa semantics, not PHP: result codes, shortcode/float limits, the gap between an accepted push and a completed payment, sandbox/production quirks. 1.0.8 adds a small trap for newcomers — a documented config path that looks usable but breaks under config:cache — so the runbook must state plainly that the fork does not use the library's env parsing and that all settings flow through config('services.mpesa.*'). Budget one engineer as M-Pesa SME, pair a second for bus-factor, and treat the runbook plus result-code map as the primary onboarding artifacts — they outlive the package.
How can I help you explore Laravel packages today?