This guide adds M-Pesa payments to a Laravel 11, 12 or 13 app using Safaricom’s Daraja API and STK push (M-Pesa Express), where the customer gets a PIN prompt on their phone. By the end you’ll have:
- an
MpesaClientservice with token caching and the Password and Timestamp done right, - an
mpesa_paymentstable that records every attempt, - an endpoint that sends the STK push,
- a callback route that is confirmed with STK Push Query before anything is marked paid,
- a queued job that handles
1037and other ambiguous results without failing the payment, - a scheduled check for callbacks that never arrive,
- a feature test that runs the whole flow without calling Safaricom.
Everything uses Laravel’s built-in HTTP client. Every code block here was run in a fresh Laravel 13 app, and the test at the end passes.
Before you start
- Create an app on the Daraja portal (developer.safaricom.co.ke) with M-Pesa Express enabled, and copy its Consumer Key and Consumer Secret.
- From the sandbox test credentials, note the test shortcode (normally
174379) and its passkey. - Your callback URL must be public HTTPS. For local development, run a tunnel such as
cloudflared tunnel --url http://localhost:8000and use the address it prints. - 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:
MPESA_ENV=sandbox
MPESA_CONSUMER_KEY=your-consumer-key
MPESA_CONSUMER_SECRET=your-consumer-secret
MPESA_SHORTCODE=174379
MPESA_PASSKEY=your-passkey
MPESA_CALLBACK_URL=https://your-tunnel.example.com/api/webhooks/stk
Keep “mpesa” and “safaricom” out of the callback URL. Developers report that Safaricom silently drops callbacks to URLs containing them, which is why this guide uses /api/webhooks/stk.
Map the values in config/services.php. Always read them through config(), because after php artisan config:cache a call to env() outside config files returns null.
<?php
// config/services.php
return [
// ...your existing services
'mpesa' => [
'base_url' => env('MPESA_ENV') === 'production' ? 'https://api.safaricom.co.ke' : 'https://sandbox.safaricom.co.ke',
'consumer_key' => env('MPESA_CONSUMER_KEY'),
'consumer_secret' => env('MPESA_CONSUMER_SECRET'),
'shortcode' => env('MPESA_SHORTCODE', '174379'),
'passkey' => env('MPESA_PASSKEY'),
'callback_url' => env('MPESA_CALLBACK_URL'),
// Buy Goods tills: CustomerBuyGoodsOnline, and the till number as party_b.
'transaction_type' => env('MPESA_TRANSACTION_TYPE', 'CustomerPayBillOnline'),
'party_b' => env('MPESA_PARTY_B'),
],
];
Create the payments table
Every STK push gets its own row. Safaricom identifies it by the CheckoutRequestID it returns, and that’s how callbacks and status queries find the payment later.
php artisan make:model MpesaPayment -m
<?php
// database/migrations/xxxx_xx_xx_create_mpesa_payments_table.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('mpesa_payments', function (Blueprint $table) {
$table->id();
$table->string('order_id')->index();
$table->unsignedInteger('amount'); // whole shillings: M-Pesa has no cents
$table->string('phone', 12); // 2547XXXXXXXX
$table->string('checkout_request_id')->nullable()->unique(); // set when Safaricom accepts the push
$table->string('status')->default('pending'); // pending | paid | failed
$table->string('result_code')->nullable();
$table->string('receipt')->nullable(); // MpesaReceiptNumber, e.g. TJR1234567
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('mpesa_payments');
}
};
<?php
// app/Models/MpesaPayment.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class MpesaPayment extends Model
{
protected $fillable = ['order_id', 'amount', 'phone', 'checkout_request_id', 'status', 'result_code', 'receipt'];
protected $casts = ['amount' => 'integer'];
}
Run php artisan migrate.
The Daraja service class
All the Daraja details live in one class. The two that trip people up most:
- Password is
base64(BusinessShortCode + Passkey + Timestamp), and the request must send that same Timestamp. - Timestamp is Kenyan time (
Africa/Nairobi) asYYYYMMDDHHmmss, whatever timezone your server orconfig/app.phpuses.
<?php
// app/Services/MpesaClient.php
namespace App\Services;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use InvalidArgumentException;
use RuntimeException;
class MpesaClient
{
private function config(string $key): string
{
return (string) config("services.mpesa.$key");
}
private function http(): PendingRequest
{
return Http::baseUrl($this->config('base_url'))->acceptJson()->timeout(30);
}
public function token(): string
{
if ($token = Cache::get('mpesa.token')) {
return $token;
}
$response = $this->http()
->withBasicAuth($this->config('consumer_key'), $this->config('consumer_secret'))
->get('/oauth/v1/generate', ['grant_type' => 'client_credentials'])
->throw();
$token = $response->json('access_token');
$ttl = max(60, (int) $response->json('expires_in', 3599) - 60); // refresh a minute early
Cache::put('mpesa.token', $token, $ttl);
return $token;
}
/** Kenyan time as YYYYMMDDHHmmss, regardless of the server's timezone. */
public static function timestamp(?Carbon $now = null): string
{
return ($now ?? Carbon::now())->copy()->setTimezone('Africa/Nairobi')->format('YmdHis');
}
public function password(string $timestamp): string
{
return base64_encode($this->config('shortcode') . $this->config('passkey') . $timestamp);
}
/** "0712 345 678", "+254712345678", "712345678" → "254712345678" */
public static function normalisePhone(string $raw): string
{
$digits = preg_replace('/\D/', '', $raw);
if (str_starts_with($digits, '00')) {
$digits = substr($digits, 2);
}
if (str_starts_with($digits, '2540')) {
$digits = '254' . substr($digits, 4); // "+254 (0)712…"
} elseif (str_starts_with($digits, '0')) {
$digits = '254' . substr($digits, 1);
} elseif (preg_match('/^[17]\d{8}$/', $digits)) {
$digits = '254' . $digits;
}
if (! preg_match('/^254[17]\d{8}$/', $digits)) {
throw new InvalidArgumentException("Not a Kenyan mobile number: $raw");
}
return $digits;
}
private function post(string $path, array $body): Response
{
return $this->http()->withToken($this->token())->post($path, $body);
}
/** Sends the PIN prompt. Returns the CheckoutRequestID. */
public function stkPush(int $amount, string $phone, string $reference, string $description = 'Payment'): string
{
$timestamp = self::timestamp();
$response = $this->post('/mpesa/stkpush/v1/processrequest', [
'BusinessShortCode' => $this->config('shortcode'),
'Password' => $this->password($timestamp),
'Timestamp' => $timestamp, // the same value used in the Password
'TransactionType' => $this->config('transaction_type'),
'Amount' => $amount, // whole shillings only
'PartyA' => $phone,
'PartyB' => $this->config('party_b') ?: $this->config('shortcode'),
'PhoneNumber' => $phone,
'CallBackURL' => $this->config('callback_url'),
'AccountReference' => mb_substr($reference, 0, 12),
'TransactionDesc' => mb_substr($description, 0, 13),
]);
if (! $response->successful() || (string) $response->json('ResponseCode') !== '0') {
$code = $response->json('errorCode') ?? $response->status();
throw new RuntimeException("STK push rejected: $code " . ($response->json('errorMessage') ?? $response->json('ResponseDescription')));
}
return $response->json('CheckoutRequestID');
}
/** The status of a push, straight from Safaricom: ['ResultCode' => '0', ...] or an errorCode. */
public function stkQuery(string $checkoutRequestId): array
{
$timestamp = self::timestamp();
return $this->post('/mpesa/stkpushquery/v1/query', [
'BusinessShortCode' => $this->config('shortcode'),
'Password' => $this->password($timestamp),
'Timestamp' => $timestamp,
'CheckoutRequestID' => $checkoutRequestId,
])->json() ?? [];
}
/** Reads a callback safely. Failed payments have no CallbackMetadata. */
public static function parseCallback(array $body): ?array
{
$cb = $body['Body']['stkCallback'] ?? null;
if (! is_array($cb) || empty($cb['CheckoutRequestID'])) {
return null;
}
$meta = [];
foreach ($cb['CallbackMetadata']['Item'] ?? [] as $item) {
$meta[$item['Name'] ?? ''] = $item['Value'] ?? null;
}
return [
'checkout_request_id' => (string) $cb['CheckoutRequestID'],
'result_code' => (string) ($cb['ResultCode'] ?? ''),
'receipt' => $meta['MpesaReceiptNumber'] ?? null,
];
}
/** paid | failed | unknown. "unknown" means: ask again before deciding. */
public static function outcome(?string $resultCode): string
{
return match ((string) $resultCode) {
'0' => 'paid',
'1', '1032', '2001', '1019' => 'failed',
default => 'unknown', // 1037, 1001, 1025, no answer yet, and anything new
};
}
}
The outcome method is deliberately cautious. Anything that isn’t clearly paid or clearly failed becomes unknown, and the code asks Safaricom again instead of guessing. Every ResultCode is explained in the M-Pesa error code reference. For why callbacks go missing and what 1037 really means, see M-Pesa callbacks and ResultCode 1037.
Start a payment
The controller saves the row before calling Safaricom, so a crash halfway can never lose a payment. Then it stores the CheckoutRequestID that comes back.
<?php
// app/Http/Controllers/MpesaPaymentController.php
namespace App\Http\Controllers;
use App\Models\MpesaPayment;
use App\Services\MpesaClient;
use Illuminate\Http\Client\HttpClientException;
use Illuminate\Http\Request;
use InvalidArgumentException;
use RuntimeException;
class MpesaPaymentController extends Controller
{
public function store(Request $request, MpesaClient $mpesa)
{
$data = $request->validate([
'order_id' => ['required', 'string', 'max:64'],
'amount' => ['required', 'integer', 'min:1'], // take it from your order record in a real app
'phone' => ['required', 'string', 'max:20'],
]);
try {
$phone = MpesaClient::normalisePhone($data['phone']);
} catch (InvalidArgumentException $e) {
return response()->json(['errors' => ['phone' => [$e->getMessage()]]], 422);
}
$payment = MpesaPayment::create(['order_id' => $data['order_id'], 'amount' => $data['amount'], 'phone' => $phone]);
try {
$payment->update(['checkout_request_id' => $mpesa->stkPush($payment->amount, $phone, $payment->order_id)]);
} catch (RuntimeException|HttpClientException $e) { // rejected, or Safaricom unreachable
$payment->update(['status' => 'failed', 'result_code' => 'push_rejected']);
report($e);
return response()->json(['message' => 'M-Pesa could not send the payment prompt. Please try again.'], 502);
}
return response()->json([
'id' => $payment->id,
'message' => 'Check your phone and enter your M-Pesa PIN.',
], 202);
}
/** Your frontend polls this, not Safaricom. */
public function show(MpesaPayment $payment)
{
return response()->json(['status' => $payment->status, 'receipt' => $payment->receipt]);
}
}
If the HTTP call times out, Safaricom may still have sent the prompt, but without a CheckoutRequestID there’s nothing to query. The code marks that attempt failed so the customer can try again; a real duplicate charge can’t happen, because the customer would have to enter their PIN twice.
Handle the callback
Safaricom can’t send a CSRF token, so the callback lives in routes/api.php, which has no CSRF check and adds an /api prefix. If your app doesn’t have that file yet, run php artisan install:api. Add these routes to the file, and keep only one copy of each use line, because install:api already imports Request and Route.
<?php
// routes/api.php
use App\Http\Controllers\MpesaPaymentController;
use App\Jobs\ConfirmMpesaPayment;
use App\Models\MpesaPayment;
use App\Services\MpesaClient;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::post('/payments/stk', [MpesaPaymentController::class, 'store']);
Route::get('/payments/stk/{payment}', [MpesaPaymentController::class, 'show']);
Route::post('/webhooks/stk', function (Request $request) {
$cb = MpesaClient::parseCallback($request->json()->all());
if ($cb && MpesaPayment::where('checkout_request_id', $cb['checkout_request_id'])->exists()) {
// Don't trust the ResultCode here: callbacks aren't signed. The job asks Safaricom.
ConfirmMpesaPayment::dispatch($cb['checkout_request_id'], $cb['result_code'] === '0' ? $cb['receipt'] : null);
}
return response()->json(['ResultCode' => 0, 'ResultDesc' => 'Accepted']); // always acknowledge
});
The job does the real work. It asks Safaricom with STK Push Query, then updates the row inside a locked transaction, so a duplicate callback or the scheduled check running at the same moment can’t settle the payment twice. If the answer is still unclear (1037, or 500.001.1001 “still processing” on a query), it puts itself back on the queue and asks again later.
<?php
// app/Jobs/ConfirmMpesaPayment.php
namespace App\Jobs;
use App\Models\MpesaPayment;
use App\Services\MpesaClient;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
class ConfirmMpesaPayment implements ShouldQueue
{
use Queueable;
public int $tries = 6;
public function __construct(public string $checkoutRequestId, public ?string $receipt = null)
{
}
public function handle(MpesaClient $mpesa): void
{
$result = $mpesa->stkQuery($this->checkoutRequestId); // network call outside the lock
$outcome = MpesaClient::outcome(isset($result['ResultCode']) ? (string) $result['ResultCode'] : null);
if ($outcome === 'unknown') {
$this->release(30 * $this->attempts()); // 30 s, 60 s, 90 s…: the payment may still complete
return;
}
DB::transaction(function () use ($outcome, $result) {
$payment = MpesaPayment::where('checkout_request_id', $this->checkoutRequestId)->lockForUpdate()->first();
if (! $payment || $payment->status !== 'pending') {
return; // unknown, or already settled by another callback or check
}
$payment->update([
'status' => $outcome,
'result_code' => (string) $result['ResultCode'],
'receipt' => $outcome === 'paid' ? $this->receipt : null,
]);
if ($outcome === 'paid') {
// Fulfil the order here, e.g. event(new OrderPaid($payment->order_id));
}
});
}
}
The query answer doesn’t include the receipt number, so the job takes it from the callback, but only once the query has confirmed the payment. A forged callback can therefore never mark an order paid, and at worst adds a receipt number to a payment that really is paid.
Catch callbacks that never arrive
Callbacks can be late or lost. A scheduled check in routes/console.php re-checks every push that’s been pending for more than two minutes:
<?php
// routes/console.php
use App\Jobs\ConfirmMpesaPayment;
use App\Models\MpesaPayment;
use Illuminate\Support\Facades\Schedule;
Schedule::call(function () {
MpesaPayment::where('status', 'pending')
->whereNotNull('checkout_request_id')
->where('created_at', '<', now()->subMinutes(2))
->where('created_at', '>', now()->subDay())
->pluck('checkout_request_id')
->each(fn (string $id) => ConfirmMpesaPayment::dispatch($id));
})->everyFiveMinutes();
This needs Laravel’s scheduler running: a cron entry that calls php artisan schedule:run every minute.
Test it without calling Safaricom
Http::fake() replaces Daraja, so this test runs offline and in CI. It checks the request format and the Password, that a success is confirmed by a query before the payment is marked paid, and that a forged “success” callback is ignored when the query disagrees.
<?php
// tests/Feature/MpesaPaymentTest.php
namespace Tests\Feature;
use App\Models\MpesaPayment;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class MpesaPaymentTest extends TestCase
{
use RefreshDatabase;
private string $queryResultCode = '0';
protected function setUp(): void
{
parent::setUp();
config([
'queue.default' => 'sync',
'services.mpesa.base_url' => 'https://sandbox.safaricom.co.ke',
'services.mpesa.consumer_key' => 'key',
'services.mpesa.consumer_secret' => 'secret',
'services.mpesa.shortcode' => '174379',
'services.mpesa.passkey' => 'test-passkey',
'services.mpesa.callback_url' => 'https://shop.example.com/api/webhooks/stk',
'services.mpesa.transaction_type' => 'CustomerPayBillOnline',
]);
Http::fake([
'*/oauth/v1/generate*' => Http::response(['access_token' => 'test-token', 'expires_in' => '3599']),
'*/stkpush/v1/processrequest' => Http::response([
'MerchantRequestID' => 'm-1', 'CheckoutRequestID' => 'ws_CO_1', 'ResponseCode' => '0',
'ResponseDescription' => 'Success. Request accepted for processing',
]),
'*/stkpushquery/v1/query' => fn () => Http::response(['ResponseCode' => '0', 'ResultCode' => $this->queryResultCode]),
]);
}
private function stkCallback(int $resultCode): array
{
$cb = ['MerchantRequestID' => 'm-1', 'CheckoutRequestID' => 'ws_CO_1', 'ResultCode' => $resultCode, 'ResultDesc' => 'test'];
if ($resultCode === 0) {
$cb['CallbackMetadata'] = ['Item' => [
['Name' => 'Amount', 'Value' => 10],
['Name' => 'MpesaReceiptNumber', 'Value' => 'TJR1234567'],
]];
}
return ['Body' => ['stkCallback' => $cb]];
}
public function test_successful_payment_is_confirmed_by_stk_query(): void
{
$id = $this->postJson('/api/payments/stk', ['order_id' => 'Order1001', 'amount' => 10, 'phone' => '0712 345 678'])
->assertStatus(202)->json('id');
Http::assertSent(function (Request $request) {
if (! str_contains($request->url(), 'processrequest')) {
return false;
}
return $request['PartyA'] === '254712345678'
&& $request['Amount'] === 10
&& base64_decode($request['Password']) === '174379test-passkey' . $request['Timestamp'];
});
$this->postJson('/api/webhooks/stk', $this->stkCallback(0))->assertJson(['ResultCode' => 0]);
$this->assertDatabaseHas('mpesa_payments', ['id' => $id, 'status' => 'paid', 'receipt' => 'TJR1234567']);
$this->getJson("/api/payments/stk/$id")->assertJson(['status' => 'paid', 'receipt' => 'TJR1234567']);
}
public function test_forged_success_callback_is_not_trusted(): void
{
$this->queryResultCode = '1032'; // Safaricom says the customer cancelled
$this->postJson('/api/payments/stk', ['order_id' => 'Order1002', 'amount' => 10, 'phone' => '0712345678']);
$this->postJson('/api/webhooks/stk', $this->stkCallback(0)); // claims success
$this->assertDatabaseHas('mpesa_payments', ['checkout_request_id' => 'ws_CO_1', 'status' => 'failed', 'receipt' => null]);
}
public function test_cancelled_callback_without_metadata_is_handled(): void
{
$this->queryResultCode = '1032';
$this->postJson('/api/payments/stk', ['order_id' => 'Order1003', 'amount' => 10, 'phone' => '0712345678']);
$this->postJson('/api/webhooks/stk', $this->stkCallback(1032))->assertJson(['ResultCode' => 0]);
$this->assertDatabaseHas('mpesa_payments', ['checkout_request_id' => 'ws_CO_1', 'status' => 'failed', 'result_code' => '1032']);
}
}
Run it with php artisan test --filter=MpesaPaymentTest.
Common errors and fixes
| Error | What it means | Fix |
|---|---|---|
404.001.03 Invalid Access Token |
The token expired or belongs to other credentials | The cached token refreshes a minute early. If you run several servers, use a shared cache store (Redis or database) |
400.002.02 Bad Request |
A field is invalid, often the phone number or Amount | Send 2547XXXXXXXX or 2541XXXXXXXX and a whole-number Amount |
500.001.1001 on STK push |
Wrong shortcode, passkey or Password | Check the passkey belongs to the shortcode, and decode your Password with the Password decoder |
500.001.1001 on STK query |
The push is still being processed | Ask again later. The job above already does |
1032 |
The customer cancelled the prompt | Mark it failed and let them try again |
1037 |
Safaricom couldn’t reach the phone in time | Keep it pending and query again. The payment can still complete |
2001 |
Wrong PIN | Mark it failed and let them try again |
| Callback never arrives | The URL isn’t public HTTPS, or it contains “mpesa” or “safaricom” | Use a tunnel or your real domain, and a neutral path like /api/webhooks/stk |
Test against the sandbox
With the tests passing, start a queue worker (php artisan queue:work) and your tunnel, set MPESA_CALLBACK_URL to the tunnel’s address, and send a payment to your own Safaricom number:
curl -X POST https://your-tunnel.example.com/api/payments/stk -H "Content-Type: application/json" -H "Accept: application/json" \
-d '{"order_id": "Order1001", "amount": 1, "phone": "0712345678"}'
Sandbox payments don’t move real money. You’ll get the PIN prompt, then a callback, then the job confirms it.
Going to production
When your shortcode is approved for Daraja go-live:
- Set
MPESA_ENV=productionand use the production Consumer Key, Secret and the passkey Safaricom sends you. The sandbox passkey won’t work. - Set
MPESA_CALLBACK_URLto your public HTTPS URL. Tunnels aren’t allowed for production shortcodes. - For a Buy Goods till, set
MPESA_TRANSACTION_TYPE=CustomerBuyGoodsOnline,MPESA_SHORTCODEto your store number andMPESA_PARTY_Bto the till number. - Run
php artisan config:cacheafter changing.env, and keep the queue worker and scheduler running under a process manager such as Supervisor. - Take the amount from your order record, never from the request, so customers can’t change what they pay.
Want all of this already built, tested and combined with MTN MoMo and Airtel Money? The Laravel Mobile Money Starter Kit has it ready.
Frequently asked questions
Is there an official M-Pesa package for Laravel?
Safaricom publishes the Daraja HTTP API, not a Laravel package. Laravel's built-in HTTP client covers everything STK push needs, so this guide uses no package at all.
Why does my M-Pesa callback route return 419 in Laravel?
419 is Laravel's CSRF protection rejecting the request, because Safaricom cannot send a CSRF token. Put the callback in routes/api.php, which has no CSRF check (run php artisan install:api if the file doesn't exist).
Should I trust the ResultCode in the M-Pesa callback?
Use the callback to find the payment and to read the receipt number, but confirm the result with STK Push Query before you fulfil the order. Daraja callbacks are not signed, so anyone who learns your callback URL can post a fake success.
What should I do with ResultCode 1037?
1037 means Safaricom could not reach the phone in time, and the payment can still complete. Keep it pending, query it again a little later, and don't let the customer start a second push for the same order until it resolves.
Why does env() return null for my M-Pesa keys in production?
After php artisan config:cache, env() only works inside config files. Read the keys through config/services.php with config('services.mpesa.*'), as this guide does.