Search by

catidegla / laravel-mobile-money

catidegla

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.

Package info

github.com/catidegla/laravel-mobile-money

pkg:composer/catidegla/laravel-mobile-money

Statistics

Installs: 16

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.6.0 2026-09-11 03:39 UTC

This package is auto-updated.

Last update: 2026-09-11 03:40:27 UTC


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.

Tests PHP Laravel License

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:

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:

  1. Assert the wire format. A test that only checks a driver returns a Transaction proves nothing. Assert the request body and headers.
  2. Map failures honestly. If the provider flattens "declined" and "timed out" into one code, separate them. If you cannot tell, return Unknown instead of guessing.

License

MIT