catidegla / laravel-mobile-money
One Laravel API across MTN MoMo, Wave and Orange Money: per-provider drivers, idempotent collections, verified webhooks, numbering-plan aware phone parsing and reconciliation for callbacks that never arrive. Works with brick/money.
Requires
- php: ^8.2
- illuminate/contracts: ^12.0|^13.0
- illuminate/database: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- brick/money: ^0.11.2
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^11.0|^12.0
Suggests
- brick/money: For a fuller money type. Money::fromBrick() and toBrick() interoperate with it, and this package never requires it.
Provides
None
Conflicts
None
Replaces
None
README
Laravel Mobile Money
Three mobile money networks behind one Laravel interface.
MTN MoMo, Wave and Orange Money. Idempotent collections, verified webhooks, numbering-plan aware phone parsing, and reconciliation for the callbacks that never arrive.
Status: in development. MTN MoMo, Wave and Orange Money are implemented and tested. Moov, KKiaPay, CinetPay and PayDunya are next. See Provider status for exactly what works today, and read the note under it before going live.
use Catidegla\MobileMoney\Facades\MobileMoney; use Catidegla\MobileMoney\Data\{CollectionRequest, Money, Msisdn}; use Catidegla\MobileMoney\Enums\Currency; $transaction = MobileMoney::collect(CollectionRequest::make( amount: Money::of(1500, Currency::XOF), payer: Msisdn::parse('+229 01 97 12 34 56'), reference: 'ORDER-42', )); $transaction->status; // PaymentStatus::Pending $transaction->reference; // poll or reconcile with this
No driver named. The payer's number decides which network handles it.
Where it works today. MTN MoMo in Benin, Côte d'Ivoire, Cameroon, Guinea, Ghana, Uganda and Rwanda. Wave in Senegal and Côte d'Ivoire. Orange Money in Côte d'Ivoire, Senegal, Mali, Burkina Faso, Cameroon and Guinea. Four more providers are planned and none of them is implemented yet, so read Provider status before you plan around one.
Skip to: why this exists · install · usage · provider status · testing
The five sections under the parts that are easy to get wrong are the reason the package is worth more than a wrapper, and each one is a bug that has cost somebody money:
- XOF is a zero-decimal currency, so the always-store-cents rule sends 100 times the amount
- Numbering plans move, so yesterday's 8 digit number is today's invalid one
- A timeout is not a failure, and treating it as one is how customers get charged twice
- The row goes in before the call, so a crash mid-payment leaves evidence rather than silence
- A reference the provider does not recognise means two different things, and only your own HTTP client can say which
Why this exists
Integrating mobile money in this region means writing the same four hundred lines against Orange Money, then again against MTN, then again against Wave, each with a different idea of what a phone number looks like and what an amount is.
There is no shortage of PHP packages in this space. They fall into two groups, and neither one does that job.
Aggregator SDKs are the well trodden option, and several are in good health: MeSomb shipped 3.1.2 in March 2026, NotchPay 2.0 in May 2025, Moneroo v0.2.0 in November 2025. Reaching for one means contracting with the aggregator, routing settlement through them and paying a share of every transaction. That is a reasonable trade and if it suits you, take it. It is a commercial decision more than a technical one, and this package is not an argument against it.
Single provider SDKs go direct, which is the other side of that trade, but each covers one provider. faso-dev/orange-money-burkina-sdk is Orange Money in Burkina Faso, opsofts/laravel-mtn-momo is MTN collections. Reaching three networks means installing three of them and then writing the layer underneath yourself: one amount type, one phone parser, one status enum, one idempotency story. That layer is most of the work and nearly all of the risk, and it is what the rest of this README is about.
This package is the second option with that layer already written. Direct to each provider, nobody in the payment path who does not have to be, one interface across all of them.
To be clear about where the value is: the money type here is the least interesting part, and brick/money does that job better. The provider layer is the reason to install this. Money::fromBrick() and Money::toBrick() interoperate with it and the dependency is never required, so you can keep brick/money as your money type and use this only for the thing it is actually for.
Figures were checked on Packagist on 8 September 2026. Note also that mmchrist89/laravel-mobile-money shares this package's name under a different vendor; it targets MTN and Airtel, has no tagged release, and has not changed since February 2026.
The parts that are easy to get wrong
XOF is a zero-decimal currency
There is no centime of CFA franc in circulation. ISO 4217 gives XOF, XAF and GNF an exponent of zero, and every provider in the region quotes whole francs.
Money::of(10000, Currency::XOF)->forProvider(); // "10000" // A library that assumes money is cents sends "1000000". A hundredfold // overcharge, silent, and invisible to a type system that models money // as a float.
This part is not novel, and it would be dishonest to imply otherwise. brick/money ships the full ISO 4217 table and has XOF at an exponent of zero, and moneyphp/money models exponents correctly too. If a correct money type is all you need, use one of those; they are older, more widely used and better tested than anything here. What neither of them does is talk to MTN, Wave or Orange, and that provider layer is what this package is actually for. The money type exists because the drivers need one, not because the existing ones are wrong.
Money holds integer minor units and refuses rather than rounds:
Money::of(1500.50, Currency::XOF); // InvalidMoneyException: XOF is a zero-decimal currency, so 1500.5 cannot be // represented. Round to a whole CFA before building the amount, and decide // deliberately which way it rounds. Money::of(100, Currency::XOF)->add(Money::of(100, Currency::XAF)); // InvalidMoneyException: Cannot combine XOF with XAF. XOF and XAF are both // called the CFA franc but are separate currencies and are not interchangeable.
Numbering plans move, and old numbers stop working
Benin added a mandatory 01 prefix on 30 November 2024. Côte d'Ivoire did the same in January 2021. Numbers stored before those dates no longer connect, and the failure usually surfaces years later when an old row is finally charged.
Msisdn::parse('97 12 34 56', 'BJ'); // InvalidPhoneNumberException: Phone number "97 12 34 56" matches the BJ // numbering plan that was retired on 30 November 2024. Numbers gained a // leading 01, so this is most likely 0197123456.
Côte d'Ivoire encodes the operator in the number, so routing needs no extra input:
Msisdn::parse('+225 05 12 34 56 78')->operatorHint(); // "mtn" Msisdn::parse('+225 07 12 34 56 78')->operatorHint(); // "moov" Msisdn::parse('+221 77 123 45 67')->operatorHint(); // null, ask rather than guess
A wrong operator hint routes a payment to a provider that rejects it, so markets whose prefixes are not documented and stable return null instead of a guess.
A timeout is not a failure
Mobile money is asynchronous. Initiating a collection sends a prompt to a handset; the customer then finds their phone and types a PIN, or does not.
PaymentStatus has an Unknown state for when a request times out and you genuinely do not know whether the provider received it.
$transaction->status->allowsRetry(); // false for Unknown
Treating that as failed and retrying is how customers get charged twice. Query by the idempotency key first.
Providers also flatten distinctions that matter. MTN reports a customer declining the prompt and a customer never answering it both as FAILED; this package separates them into Cancelled and Expired, because only some are worth prompting again for.
The row goes in before the call
Unknown covers a call that came back unreadable. It cannot cover a process that died before the call returned at all, because nothing was written.
So collect() writes a row first, in a Claimed state meaning the request may have reached the provider and nothing has come back:
$status->isAcknowledged(); // false only for Claimed $status->allowsRetry(); // false for Claimed, same as Unknown
The ordering is the whole point, and both of the obvious alternatives have a hole. Writing the row after the call means a crash in between leaves no trace, so the retry looks like a first attempt. Writing it after the call but marking it done immediately has the same hole from the other side: a failure after the mark leaves a key that reads as handled, the redelivery is discarded, and the payment is lost.
Two more things fall out of writing it first:
The second call is answered, not repeated. A collect() carrying an idempotency key that already reached a final state returns the stored result instead of calling the provider again. The unique index on idempotency_key is what makes that safe under concurrency: two requests race to insert, the database picks one, and the loser reads the winner's row rather than opening a second payment.
A claim can still be polled. MTN answers status on the X-Reference-Id, which is derived deterministically from the idempotency key, so the handle is known before the call and stored with the claim. A payment that never got a reply is still something you can ask about, which is the only reason recording it is worth anything.
The reconciler picks up Claimed rows alongside Pending and Unknown, since a process that died between the claim and the call leaves a row nobody else is waiting on.
Set ledger.enabled to false to make collect() a pure provider call again. That hands you the whole problem: you then own recording the attempt before it happens.
A reference the provider does not recognise means two different things
Poll a claim and the provider may answer that it has no transaction with that reference. That sentence has two readings and the provider does not say which one it means: either it never received the request, or it received one it has not indexed yet.
They need opposite handling. Read the first as the second and the payment is polled until the schedule runs out, leaving an order nobody ever settles. Read the second as the first and you have failed a payment that is about to succeed, with the customer's money already gone.
Nothing on the provider's side separates them. Your own HTTP client does, at the moment the call fails and nowhere afterwards, so collect() writes down what it saw:
$record->delivery; // Delivery::NeverSent
Unattempted |
Claimed, nothing on the wire yet |
NeverSent |
The connection never opened, so the provider cannot hold it |
Indeterminate |
The request went out and no usable answer came back |
Delivered |
The provider answered, whatever the answer was |
A refused connection, a host that never resolved, a failed handshake, or a timeout that expired before the connection was established: all of those are proof the request did not arrive. A read timeout is proof of nothing, because the body may well have gone out and only the reply got lost.
That makes the 404 conclusive when, and only when, both halves line up. The reconciler closes a payment it can show was never sent and the provider does not recognise, and leaves every other combination polling exactly as before:
ORDER-1: never sent and unknown to mtn_momo, closed
ORDER-2: not recognised by mtn_momo, still polling
It closes to Failed rather than Unknown, which is the point of the whole column. Unknown exists to block a retry that might be a second charge; here there is nothing to charge twice, so the order is released to be attempted again.
The classification is deliberately asymmetric. NeverSent is only recorded on positive evidence and everything else falls through to Indeterminate, because the two mistakes do not cost the same: calling an arrived request never sent fails a live payment, while calling a lost one indeterminate just means it keeps being polled, which is what already happened. Rows written before this column existed carry no observation and conclude nothing.
Provider status
| Provider | Markets | Flow | Collections | Webhooks | Payouts | Sandbox verified |
|---|---|---|---|---|---|---|
| MTN MoMo | BJ, CI, CM, GN, GH, UG, RW | handset prompt | yes | planned | planned | partly, see below |
| Wave | SN, CI | redirect | yes | yes, HMAC signed | planned | not yet |
| Orange Money | CI, SN, ML, BF, CM, GN | redirect | yes | yes, token matched | planned | not yet |
| Moov Africa | BJ, CI, TG, BF, ML | planned | planned | planned | no | |
| KKiaPay | BJ | planned | planned | planned | no | |
| CinetPay | UEMOA | planned | planned | planned | no | |
| PayDunya | SN, CI, BJ, TG | planned | planned | planned | no |
The three flows are genuinely different, and calling code has to branch on it. MTN pushes a prompt to the handset and returns nothing to redirect to. Wave and Orange both return a URL. $transaction->requiresRedirect() tells you which you got.
Webhook verification differs too, and one is weaker than the other. Wave signs the body with HMAC-SHA256 and a rotating secret. Orange does not sign anything: it issues a notif_token when the payment is created and sends the same token back, so verification means comparing it against the one you stored. That makes the token a bearer secret travelling in the request body, only as safe as the transport. Serve the notification URL over HTTPS and treat the token as a credential.
Because Orange needs a lookup this package cannot perform on its own, you have to teach it how:
MobileMoney::driver('orange_money')->resolveNotifTokenUsing( fn (string $orderId) => Payment::where('reference', $orderId)->value('notif_token'), );
Without a resolver, verification returns false for every callback. That is deliberate. The alternative is an endpoint that marks any order paid on request.
On "sandbox verified". Every driver is written against the provider's published API contract and covered by tests that assert the exact request shape. That column only says yes when a real transaction has cleared, and none has. Treat the rest as documented rather than proven, and run your own sandbox test before going live.
MTN says partly because a run on 9 September 2026 got further than the others and is worth being precise about. Verified against the live sandbox: API user provisioning, token acquisition, 202 with an empty body on the initiating call, the pending to settled transition on a real clock, and the reason strings the provider actually returns. Not verified: the /collection/ endpoints themselves, because MTN has hit Azure's 25,000 subscription cap on its Collections product and no new developer can subscribe to it, so the shared endpoints had to stand in. Also not verified: the zero decimal handling, because the sandbox settles in EUR whatever market you target, and answers XOF with HTTP 500 INVALID_CURRENCY.
That run found a real bug. MTN answers a replayed X-Reference-Id with 409 RESOURCE_ALREADY_EXIST, and the driver was throwing on it, which would have pushed a caller retrying after a timeout into issuing a fresh reference and charging the customer twice. Fixed in 0.2.0. SANDBOX.md has the full procedure, the observed reason code table, and what each result does and does not prove.
Install
composer require catidegla/laravel-mobile-money php artisan vendor:publish --tag=mobile-money-config
Requires PHP 8.2 and Laravel 12 or 13. Laravel 13 itself needs PHP 8.3, so on 8.2 Composer will resolve you to 12, which is the intended outcome rather than a failure.
Laravel 11 is deliberately not supported: advisory PKSA-mdq4-51ck-6kdq covers the whole
>=11.0.0,<12.0.0 range with no fix on that branch, so Composer's default advisory policy refuses
to install it. Supporting 11 would mean asking you to turn that protection off.
MTN_MOMO_BASE_URL=https://sandbox.momodeveloper.mtn.com MTN_MOMO_ENVIRONMENT=sandbox MTN_MOMO_SUBSCRIPTION_KEY= MTN_MOMO_API_USER= MTN_MOMO_API_KEY=
Usage
Collect
$request = CollectionRequest::make( amount: Money::of(2500, Currency::XOF), payer: Msisdn::parse('0197123456', 'BJ'), reference: 'INV-2026-001', description: 'Abonnement mensuel', ); $transaction = MobileMoney::collect($request); // routed by number $transaction = MobileMoney::collect($request, 'mtn_momo'); // or pinned
CollectionRequest::make() generates an idempotency key. Keep it across retries: calling collect twice with the same key must not create a second charge, and that guarantee is the only thing standing between a flaky connection and a double debit.
Reconcile
$transaction = MobileMoney::status($reference, 'mtn_momo'); if ($transaction->isSettled()) { // the only state safe to fulfil on }
Webhooks in this region are not reliable enough to be the only path to a final state, so poll anything still pending. The reconciliation config block holds a backoff schedule and a give-up window.
Route by hand
MobileMoney::availableFor(Currency::XOF, 'CI'); // every driver serving that market MobileMoney::routeFor($request); // the one it would pick
Webhooks
The package registers POST /mobile-money/webhook/{provider} on the api middleware group, so callbacks are not rejected by CSRF. Verification runs before the body is read, and listeners fire on the transition rather than on each delivery, so a provider retrying a callback does not deliver the order twice.
Event::listen(PaymentSucceeded::class, function (PaymentSucceeded $event) { $event->record; // the local row $event->transaction; // what the provider said });
Every rejection answers 202 with the same body, whether the signature failed, the provider is unknown, or the order does not exist. Different responses would let someone probing the endpoint work out which orders exist and which part of their forgery to fix.
Reconciliation
Callbacks in this region are lost often enough that relying on them alone strands orders, and the customer whose money left their wallet will not accept "we did not get the notification". Treat polling as the primary path.
// routes/console.php Schedule::command('mobile-money:reconcile')->everyMinute();
The backoff lives on each row, so running every minute costs nothing for payments that are not due. Polling gives up once the configured schedule is exhausted, and PaymentStatus::Unknown, the state a timeout leaves behind, is picked up instead of treated as final.
Persistence
MobileMoneyTransaction stores amounts as integer minor units, keeps the idempotency key unique, and encrypts Orange Money's notif_token at rest, since anyone holding it can forge a notification for that order.
It refuses to move a settled payment backwards. Out of order delivery is normal, and a stale failure arriving after a poll already confirmed success must not reopen the order.
Alongside the payment's status it keeps delivery, which records what the outbound request looked like from this side rather than from the provider's. Once a row shows the provider answered, it stays that way: a later refused connection cannot unsay an earlier delivery.
Testing
composer install vendor/bin/phpunit
135 tests. The suite fakes HTTP and asserts the exact bytes sent to each provider, including that 10 000 XOF leaves as "10000".
The webhook tests are the ones worth reading. They cover a tampered body, a signature from the wrong secret, a replayed callback outside the tolerance window, a missing or malformed header, both signatures during a key rotation, and the case where verification must fail closed because no secret is configured. There is also a test proving the raw request body is used rather than re-encoded JSON, since re-encoding can reorder keys and silently break every signature.
The other half: accepting payment in person
This package pushes a prompt to a handset, which covers a customer paying from wherever they are. It does nothing for a customer standing at a counter.
That case runs on a different rail. Since September 2025 the eight UEMOA countries share one interoperable instant payment platform, PI-SPI, and a merchant accepts on it by displaying a QR the payer scans. The QR is EMV, but the profile is specific: the scheme identifier must be int.bceao.pi, the currency is always 952, and only a 36 character payment address may appear in it, so the phone number alias that works everywhere else on the rail is invalid there.
catidegla/pi-spi-qr builds, parses and validates those payloads. Zero dependencies, no framework required, and its test suite carries BCEAO's own published worked example so the specification can contradict the package rather than only the reverse.
composer require catidegla/pi-spi-qr
The two do not depend on each other and are useful separately. Together they cover a customer paying from their phone and a customer paying at your counter.
Contributing
Adding a provider means implementing Contracts\Provider, plus VerifiesWebhooks if it signs callbacks. Two rules:
- Assert the wire format. A test that only checks a driver returns a
Transactionproves nothing. Assert the request body and headers. - Map failures honestly. If the provider flattens "declined" and "timed out" into one code, separate them. If you cannot tell, return
Unknowninstead of guessing.