This guide adds MTN Mobile Money payments to a Laravel 11, 12 or 13 app. By the end you’ll have:
- a
MomoClientservice with token caching, - a
paymentstable that records every attempt, - an endpoint that sends a Request to Pay,
- a callback route that confirms payments safely in a queued job,
- a scheduled check for callbacks that never arrive,
- a feature test that runs without touching MTN.
Everything uses Laravel’s built-in HTTP client. You don’t need a MoMo package.
Before you start
- Sign up at momodeveloper.mtn.com and subscribe to the Collections product.
- Copy your primary subscription key from your profile.
- Create a sandbox API user and API key. Steps 1 and 2 of the MoMo Request Builder generate the cURL commands for you.
- Make sure a queue worker can run (
php artisan queue:work). The database queue driver is fine to start with.
Configuration
Add your credentials to .env:
MOMO_BASE_URL=https://sandbox.momodeveloper.mtn.com
MOMO_TARGET_ENV=sandbox
MOMO_CURRENCY=EUR
MOMO_SUBSCRIPTION_KEY=your-collections-subscription-key
MOMO_API_USER=your-api-user-uuid
MOMO_API_KEY=your-api-key
Then map them in config/services.php. Always read them through config(). After php artisan config:cache, calling env() outside config files returns null, which is a classic “works locally, breaks in production” bug.
<?php
return [
// ...your existing services
'momo' => [
'base_url' => env('MOMO_BASE_URL', 'https://sandbox.momodeveloper.mtn.com'),
'target_env' => env('MOMO_TARGET_ENV', 'sandbox'),
'currency' => env('MOMO_CURRENCY', 'EUR'),
'subscription_key' => env('MOMO_SUBSCRIPTION_KEY'),
'api_user' => env('MOMO_API_USER'),
'api_key' => env('MOMO_API_KEY'),
],
];
Create the payments table
Every payment attempt gets its own row, keyed by the X-Reference-Id you send to MTN. This row is how you match callbacks, avoid paying out twice, and answer support questions later.
php artisan make:model Payment -m
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('payments', function (Blueprint $table) {
$table->id();
$table->uuid('reference_id')->unique(); // the X-Reference-Id sent to MTN
$table->string('order_id')->index(); // sent as externalId
$table->decimal('amount', 12, 2);
$table->string('currency', 3);
$table->string('phone', 15);
$table->string('status')->default('pending'); // pending | paid | failed
$table->string('financial_transaction_id')->nullable();
$table->string('failure_reason')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('payments');
}
};
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Payment extends Model
{
protected $fillable = [
'reference_id', 'order_id', 'amount', 'currency', 'phone',
'status', 'financial_transaction_id', 'failure_reason',
];
protected $casts = ['amount' => 'decimal:2'];
}
Run php artisan migrate.
The MoMo service class
This class handles the three API calls you need. The access token is cached until shortly before it expires, so you don’t request a new one for every payment.
<?php
namespace App\Services;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
class MomoClient
{
private function config(string $key): string
{
return (string) config("services.momo.$key");
}
private function http(): PendingRequest
{
return Http::baseUrl($this->config('base_url'))
->withHeaders(['Ocp-Apim-Subscription-Key' => $this->config('subscription_key')])
->timeout(30);
}
public function token(): string
{
if ($token = Cache::get('momo.token')) {
return $token;
}
$response = $this->http()
->withBasicAuth($this->config('api_user'), $this->config('api_key'))
->withBody('', 'application/json') // empty body; avoids 411 Length Required
->post('/collection/token/')
->throw();
$token = $response->json('access_token');
$ttl = max(60, (int) $response->json('expires_in', 3600) - 60); // refresh a minute early
Cache::put('momo.token', $token, $ttl);
return $token;
}
/** Sends a payment prompt. Returns the X-Reference-Id (a new UUID v4). */
public function requestToPay(string $amount, string $phone, string $orderId, ?string $callbackUrl = null, ?string $referenceId = null): string
{
$referenceId ??= (string) Str::uuid();
$headers = [
'X-Reference-Id' => $referenceId,
'X-Target-Environment' => $this->config('target_env'),
];
if ($callbackUrl) {
$headers['X-Callback-Url'] = $callbackUrl;
}
$this->http()
->withToken($this->token())
->withHeaders($headers)
->post('/collection/v1_0/requesttopay', [
'amount' => self::formatAmount($amount), // a string, no trailing ".00"
'currency' => $this->config('currency'),
'externalId' => $orderId,
'payer' => ['partyIdType' => 'MSISDN', 'partyId' => self::normalisePhone($phone)],
'payerMessage' => "Payment for order $orderId",
'payeeNote' => "Order $orderId",
])
->throw(); // MoMo answers 202 Accepted on success
return $referenceId;
}
/** The source of truth for a payment: PENDING, SUCCESSFUL or FAILED. */
public function status(string $referenceId): array
{
return $this->http()
->withToken($this->token())
->withHeaders(['X-Target-Environment' => $this->config('target_env')])
->get("/collection/v1_0/requesttopay/$referenceId")
->throw()
->json();
}
/** "500.00" → "500", "12.50" → "12.5". Currencies like UGX have no decimals. */
public static function formatAmount(string $amount): string
{
return str_contains($amount, '.') ? rtrim(rtrim($amount, '0'), '.') : $amount;
}
/** "+256 772 123456", "00256772123456", "0772123456" or "772123456" → "256772123456" */
public static function normalisePhone(string $raw, string $countryCode = '256'): string
{
$digits = preg_replace('/\D/', '', $raw);
$international = str_starts_with(trim($raw), '+') || str_starts_with($digits, '00');
if (str_starts_with($digits, '00')) {
$digits = substr($digits, 2); // "00256…" international dialling
}
if (str_starts_with($digits, $countryCode . '0')) {
return $countryCode . substr($digits, strlen($countryCode) + 1); // "+256 (0)772…"
}
if ($international) {
return $digits; // already has a country code, e.g. "+46733123451"
}
if (str_starts_with($digits, '0')) {
return $countryCode . substr($digits, 1); // local format: swap the 0 for the code
}
return str_starts_with($digits, $countryCode) ? $digits : $countryCode . $digits;
}
}
Change the default $countryCode to match your market, for example 233 for Ghana. This helper only suits countries where the leading 0 is a trunk prefix. In Côte d’Ivoire, Benin and Congo the 0 is part of the number and must stay, and Cameroon numbers have no leading 0 at all. The African Phone Number Formatter shows the right format for each country.
Start a payment
The controller creates the payment row first, then calls MTN. Saving first means that if your server crashes after MTN accepts the request, you still have the reference to look it up.
The callback URL carries the reference ID as ?ref=…. Not every MoMo callback body includes your X-Reference-Id, so you supply it yourself.
<?php
namespace App\Http\Controllers;
use App\Models\Payment;
use App\Services\MomoClient;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class MomoPaymentController extends Controller
{
public function store(Request $request, MomoClient $momo)
{
$data = $request->validate([
'order_id' => ['required', 'string', 'max:64'],
'amount' => ['required', 'numeric', 'gt:0'],
'phone' => ['required', 'string', 'max:20'],
]);
$payment = Payment::create([
'reference_id' => (string) Str::uuid(),
'order_id' => $data['order_id'],
'amount' => $data['amount'],
'currency' => config('services.momo.currency'),
'phone' => MomoClient::normalisePhone($data['phone']),
]);
$momo->requestToPay(
amount: (string) $payment->amount,
phone: $payment->phone,
orderId: $payment->order_id,
callbackUrl: route('momo.callback', ['ref' => $payment->reference_id]),
referenceId: $payment->reference_id,
);
return response()->json([
'reference' => $payment->reference_id,
'message' => 'Check your phone and enter your MoMo PIN to approve.',
], 202);
}
/** Your frontend polls this, not MTN. */
public function show(string $reference)
{
$payment = Payment::where('reference_id', $reference)->firstOrFail();
return response()->json(['status' => $payment->status]);
}
}
In a real app, take the amount from your order record, not from the request, so customers can’t change what they pay.
Handle the callback
MTN can’t send a CSRF token, so the callback must not go through the web middleware group, or Laravel will reject it with 419. The simplest fix is routes/api.php, which has no CSRF check. If your app doesn’t have that file yet, run php artisan install:api.
<?php
// routes/api.php (URLs here get an /api prefix automatically)
use App\Http\Controllers\MomoPaymentController;
use App\Jobs\ConfirmMomoPayment;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::post('/payments/momo', [MomoPaymentController::class, 'store']);
Route::get('/payments/momo/{reference}', [MomoPaymentController::class, 'show']);
// MTN's callback method isn't consistent across versions, so accept POST and PUT.
Route::match(['post', 'put'], '/webhooks/momo', function (Request $request) {
if ($ref = $request->query('ref')) {
ConfirmMomoPayment::dispatch($ref); // never trust the body; the job asks MTN
}
return response()->noContent(200); // reply fast
})->name('momo.callback');
The job does the real work. It asks MTN for the payment status, checks the amount and currency, and updates the row inside a locked transaction. That way a duplicate callback, or a callback that arrives at the same moment as your scheduled check, can’t mark the payment twice.
<?php
namespace App\Jobs;
use App\Models\Payment;
use App\Services\MomoClient;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
class ConfirmMomoPayment implements ShouldQueue
{
use Queueable;
public int $tries = 5;
public array $backoff = [10, 30, 60, 120];
public function __construct(public string $referenceId)
{
}
public function handle(MomoClient $momo): void
{
DB::transaction(function () use ($momo) {
$payment = Payment::where('reference_id', $this->referenceId)->lockForUpdate()->first();
if (! $payment || $payment->status !== 'pending') {
return; // unknown reference, or already handled
}
$result = $momo->status($payment->reference_id);
$status = strtoupper($result['status'] ?? '');
if ($status === 'SUCCESSFUL') {
$amountMatches = round((float) $result['amount'], 2) === round((float) $payment->amount, 2);
$currencyMatches = strtoupper($result['currency'] ?? '') === $payment->currency;
if ($amountMatches && $currencyMatches) {
$payment->update([
'status' => 'paid',
'financial_transaction_id' => $result['financialTransactionId'] ?? null,
]);
// Fulfil the order here, e.g. event(new OrderPaid($payment->order_id));
} else {
report(new \RuntimeException("MoMo amount/currency mismatch for {$payment->reference_id}"));
}
} elseif ($status === 'FAILED') {
$reason = $result['reason'] ?? null;
$payment->update([
'status' => 'failed',
'failure_reason' => is_array($reason) ? ($reason['code'] ?? null) : $reason,
]);
}
// PENDING: leave it; another callback or the scheduled check will finish it.
});
}
}
Comparing rounded numbers means "500" from MTN matches 500.00 in your database. Not sure what a callback means? Paste it into the MoMo Callback Validator.
Catch callbacks that never arrive
Callbacks can be late or lost. A scheduled check in routes/console.php re-checks any payment that’s been pending for more than two minutes:
<?php
use App\Jobs\ConfirmMomoPayment;
use App\Models\Payment;
use Illuminate\Support\Facades\Schedule;
Schedule::call(function () {
Payment::where('status', 'pending')
->where('created_at', '<', now()->subMinutes(2))
->where('created_at', '>', now()->subDay())
->pluck('reference_id')
->each(fn (string $ref) => ConfirmMomoPayment::dispatch($ref));
})->everyFiveMinutes();
This needs Laravel’s scheduler running: a cron entry that calls php artisan schedule:run every minute, as described in Laravel’s scheduling docs.
Test it without calling MTN
Http::fake() replaces MTN’s responses, so this test runs offline and in CI. It covers the full flow: start a payment, receive a callback, confirm it.
<?php
namespace Tests\Feature;
use App\Models\Payment;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class MomoPaymentTest extends TestCase
{
use RefreshDatabase;
public function test_successful_payment_is_confirmed_via_status_endpoint(): void
{
config(['queue.default' => 'sync', 'services.momo.currency' => 'EUR']);
Http::fake([
'*/collection/token/' => Http::response(['access_token' => 'test-token', 'expires_in' => 3600]),
'*/collection/v1_0/requesttopay' => Http::response(null, 202),
'*/collection/v1_0/requesttopay/*' => Http::response([
'amount' => '500', 'currency' => 'EUR', 'externalId' => 'order-1',
'financialTransactionId' => '123456', 'status' => 'SUCCESSFUL',
]),
]);
$reference = $this->postJson('/api/payments/momo', [
'order_id' => 'order-1', 'amount' => 500, 'phone' => '0772123456',
])->assertStatus(202)->json('reference');
$this->assertSame('256772123456', Payment::first()->phone);
// A fake "SUCCESSFUL" body must not be trusted on its own.
$this->putJson("/api/webhooks/momo?ref=$reference", ['status' => 'SUCCESSFUL'])->assertOk();
$this->assertDatabaseHas('payments', [
'reference_id' => $reference,
'status' => 'paid',
'financial_transaction_id' => '123456',
]);
}
}
Run it with php artisan test --filter=MomoPaymentTest.
Test against the sandbox
Once the fake test passes, try the real sandbox. Keep MOMO_CURRENCY=EUR, and use MTN’s test numbers: 46733123450 simulates a failed payment and 46733123451 a rejected one, while numbers not on MTN’s list simulate success. Type them with a plus (+46733123450) so normalisePhone doesn’t add your country code. The full list is on the Request Builder page.
Your callback URL must be publicly reachable. localhost won’t receive callbacks, so use a tunnel such as cloudflared or ngrok while developing, and make the host match the providerCallbackHost you registered for your API user.
Going to production
When MTN approves you for production:
- Use the API user and key from the MTN MoMo Partner Portal. The sandbox provisioning endpoints don’t exist in production.
- Change
MOMO_BASE_URLto the production host MTN gives you,MOMO_TARGET_ENVto your country (for examplemtnuganda), andMOMO_CURRENCYto the local currency (for exampleUGX). - Serve the callback over HTTPS on your registered host.
- Run
php artisan config:cacheafter changing.env. - Make sure the queue worker and the scheduler are running under a process manager such as Supervisor.
No code changes are needed, because everything reads from config('services.momo').
Frequently asked questions
Is there an official MTN MoMo package for Laravel?
MTN publishes the HTTP API rather than a maintained Laravel package. Laravel's built-in HTTP client is all you need, which also means one less dependency to keep up to date.
Why does my MoMo callback route return 419 in Laravel?
419 means CSRF protection rejected the request. MTN cannot send a CSRF token, so put the callback route in routes/api.php, which does not use CSRF, or exclude it from CSRF protection in bootstrap/app.php.
Why does env() return null for my MoMo keys in production?
After php artisan config:cache, env() only works inside config files. Read your keys through config/services.php and config('services.momo.*'), as this guide does.
How do I test MTN MoMo in Laravel without calling the API?
Use Http::fake() to return fake token, Request to Pay and status responses, then assert on your database. The test in this guide does exactly that.
How long should I wait before marking a MoMo payment as failed?
The customer has about five minutes to approve the prompt. Keep the payment pending, rely on the callback, and run a scheduled check that polls the status endpoint for payments that are still pending after a few minutes.