Skip to content

MTN MoMo · Laravel

MTN MoMo Collections API in Laravel: Step-by-Step Integration

Accept MTN Mobile Money in Laravel 11+: a MoMo service class with token caching, a payments table, Request to Pay, safe callbacks, queued checks and tests.

Published

Skip the boilerplate → Generate and test every MoMo request in this guide with the MoMo Request Builder.

This guide adds MTN Mobile Money payments to a Laravel 11, 12 or 13 app. By the end you’ll have:

  • a MomoClient service with token caching,
  • a payments table 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

  1. Sign up at momodeveloper.mtn.com and subscribe to the Collections product.
  2. Copy your primary subscription key from your profile.
  3. Create a sandbox API user and API key. Steps 1 and 2 of the MoMo Request Builder generate the cURL commands for you.
  4. 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_URL to the production host MTN gives you, MOMO_TARGET_ENV to your country (for example mtnuganda), and MOMO_CURRENCY to the local currency (for example UGX).
  • Serve the callback over HTTPS on your registered host.
  • Run php artisan config:cache after 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.

Need this built for you?

I integrate MTN MoMo, M-Pesa and Airtel Money into Laravel / PHP, Python / Django, Node.js / Next.js apps: payments, callbacks, payouts and go-live, with tests.