bmatovu/laravel-mtn-momo
Laravel package for integrating MTN Mobile Money (MoMo) APIs. Collect payments, disburse funds, check transaction status, and handle callbacks via a clean, fluent interface, with sandbox and production environments plus publishable configuration.
1. Install and publish
composer require bmatovu/laravel-mtn-momo
php artisan vendor:publish --provider="Bmatovu\MtnMomo\MtnMomoServiceProvider"
Auto-discovery is enabled on modern Laravel, so no manual provider registration is needed. After publishing, look at two things first: the generated config file (usually config/mtn-momo.php) and the package's README / src/ directory — the class names and config keys are the contract you'll code against, and they change between minor releases.
2. Provision credentials before writing any code
The MTN MoMo API requires three separate values per product, and they are not interchangeable:
POST /v1_0/apiuser with an X-Reference-Id you generate.POST /v1_0/apiuser/{uuid}/apikey.If the package ships artisan commands for this, use them; otherwise do it once by hand with cURL against the sandbox and paste the results into .env:
MTN_MOMO_TARGET_ENVIRONMENT=sandbox
MTN_MOMO_SUBSCRIPTION_KEY=xxxxxxxxxxxxxxxx
MTN_MOMO_API_USER=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
MTN_MOMO_API_KEY=xxxxxxxxxxxxxxxx
MTN_MOMO_CALLBACK_HOST=https://your-public-host.test
3. First use case: collect a payment (requestToPay)
Collections is the 80% case — pulling money from a customer's wallet. The flow is always: generate a UUID → POST requestToPay → get 202 Accepted → wait for callback or poll → read status. Anything that treats the 202 as "payment succeeded" is broken.
$referenceId = (string) Str::uuid();
MtnMomo::requestToPay([
'amount' => '5000',
'currency' => 'UGX',
'externalId' => $order->id,
'payer' => ['partyIdType' => 'MSISDN', 'partyId' => '256770000000'],
'payerMessage' => 'Order #'.$order->id,
'payeeNote' => 'Thanks',
], $referenceId);
Persist $referenceId on the order immediately — it is the only key you'll have for reconciliation.
Model the reference ID as your primary key for the transaction. Use a UUIDv4 generated by you, stored in a column with a unique index. Every status poll callback, retry, and support ticket resolves through this ID. MTN's own financialTransactionId arrives later and should be stored alongside it, not used as the lookup key.
Separate the intent from the confirmation. The cleanest integration is an internal state machine:
enum MomoStatus: string { case Pending = 'PENDING'; case Success = 'SUCCESSFUL'; case Failed = 'FAILED'; }
Create the record as Pending on the API call, flip it in a webhook controller, and fire domain events (MomoPaymentConfirmed, MomoPaymentFailed) so billing/fulfilment code never touches the MoMo client directly.
Webhook controller, then queue. MTN retries callbacks and does not guarantee ordering. Respond 200 fast, verify the payload shape, dedupe on referenceId + status, and push the actual work to a job:
Route::post('/webhooks/momo', function (Request $request) {
MomoCallback::dispatch($request->all())->onQueue('payments');
return response()->noContent();
})->withoutMiddleware([VerifyCsrfToken::class]);
Do not add the webhook route behind auth middleware, and remember php artisan route:list is the fastest way to confirm it's actually registered.
Poll as a fallback, not the default. Callbacks get lost during local dev, deploys, and network blips. A momo:poll-pending scheduled command (every minute, only Pending rows older than 30s, max N attempts) closes the loop cheaply. Give it a hard ceiling — abandoned pending transactions should be marked Failed after ~10–15 minutes rather than polled forever.
Disbursements are a separate client and separate key. If you're paying refunds or vendor payouts, expect a second subscription key, a second api user/api key pair, and separate credentials in config. Wrap each in its own service class rather than branching inside one god-object.
Wrap it in your own interface. The package is thin and its API surface is unstable (143 stars, one maintainer, last release Feb 2025). A MomoGateway interface with a FakeMomoGateway binding for tests means a future swap or a package-breaking change costs you one class.
Test against sandbox with a real callback host. Use ngrok/expose/Laravel Herd's share feature, set MTN_MOMO_CALLBACK_HOST to the tunnel, and approve the pending request in the MTN sandbox UI (the sandbox does not send a real prompt to a phone).
requestToPay and transfer return 202 Accepted with an empty body. Only GET /requesttopay/{referenceId} or a callback tells you the outcome. Every "payment lost" bug I've seen traces back to this.Str::uuid() and nothing else.mtnuganda/mtnghana. If you get 401s, check environment/key pairing first — it's the most common misconfiguration.sandbox vs production should never share a token cache). Redis key prefix collisions between staging and prod cause maddening intermittent 401s."5000", not "5000.00" and not 5000. Send currency explicitly every call; there is no default.+ (256770000000). +256... and 0770... both fail validation on some endpoints, and the error message is unhelpful.{"code":"INVALID_CALLBACK_URL"}). Without the raw payload and the X-Reference-Id you sent, debugging is guesswork. Never log Ocp-Apim-Subscription-Key or the API key.http://localhost will silently never fire in sandbox.FAILED and REJECTED explicitly in your state machine tests; many teams ship having only ever seen SUCCESSFUL.Http client factory if the package exposes one, and the events it fires. Reach for those before editing vendor code — this package's release cadence means patches won't survive composer update.How can I help you explore Laravel packages today?