michaelgatuma/laravel-kopokopo
Laravel package wrapping the Kopo Kopo mobile money API. Accept M-Pesa payments, send money to recipients, and process transaction webhooks with an expressive, configurable interface. Simplifies connecting your Laravel app to Kopo Kopo's payment services in Kenya.
Install and publish config
composer require michaelgatuma/laravel-kopokopo
php artisan vendor:publish --tag=kopokopo-config
Set credentials in .env (never inline in config at runtime):
KOPOKOPO_CLIENT_ID=
KOPOKOPO_CLIENT_SECRET=
KOPOKOPO_API_KEY=
KOPOKOPO_BASE_URL=https://sandbox.kopokopo.com
# production: https://api.kopokopo.com
The service provider is auto-discovered, so there is nothing to register in config/app.php on Laravel 9+. Confirm the client is bound before building anything on top of it — a quick app(Kopokopo::class) in tinker will tell you immediately whether credentials are wired correctly.
Where to look first, in order
config/kopokopo.php — base URL, credentials, timeout, and default callback/till settings. This file answers most "why is this failing" questions.src/ — it shows the exact request payload shape and which endpoints are wrapped versus which you'll need to call yourself.First use case: an STK push (M-Pesa prompt)
The canonical day-one flow is "user clicks Pay, phone buzzes, webhook confirms." Start there:
use Michaelgatuma\Kopokopo\Facades\Kopokopo;
$response = Kopokopo::stkPush([
'payment_channel' => 'M-PESA STK Push',
'till_number' => config('kopokopo.till'),
'subscriber' => [
'first_name' => $order->user->first_name,
'last_name' => $order->user->last_name,
'phone' => '254712345678', // MSISDN, no plus sign
],
'amount' => ['currency' => 'KES', 'value' => 1500],
'metadata' => [
'order_id' => (string) $order->id, // your correlation key
'notes' => 'Order #'.$order->id,
],
'_links' => ['callback_url' => route('kopokopo.webhook')],
]);
Persist whatever reference the API returns ($response['location'] or the resource id, depending on endpoint) against your order before redirecting the user. The webhook arrives independently of the browser session, so that stored reference is the only thing that ties the two together.
Resolve through the container, never new
Bind your own thin wrapper around the package client and type-hint that:
final class MpesaGateway
{
public function __construct(private readonly Kopokopo $client) {}
}
This lets you swap in a fake in tests and add logging/retry without touching call sites.
Webhook endpoint design
Put the route in routes/api.php (no CSRF, no session) and keep the handler under ~20 lines:
Route::post('/webhooks/kopokopo', KopokopoWebhookController::class)
->middleware('throttle:120,1')
->name('kopokopo.webhook');
public function __invoke(Request $request)
{
abort_unless($this->signatureIsValid($request), 403);
ProcessKopokopoEvent::dispatch($request->all());
return response()->json(['status' => 'ok']); // always 200, fast
}
The controller's only job is verification plus dispatch. Everything else — matching the event to an order, marking it paid, sending receipts — belongs in the queued job.
Idempotency is not optional
Kopo Kopo retries webhooks. Store a processed-events table keyed on the event/resource id with a unique index, and make the job ->middleware(WithoutOverlapping::class) or guard on that table. This is the single most common source of double-credit bugs with mobile money integrations.
Correlate via metadata, not amounts
Send metadata.order_id (or your own identifier) on every initiation and read it back from the webhook payload. Matching on amount + phone is fragile and will eventually reconcile the wrong two transactions.
Reconciliation job as a safety net
Webhooks get lost. Run a scheduled job that polls the transaction status endpoint for anything stuck in pending for more than N minutes and resolves it:
Schedule::job(new ReconcilePendingPayments)->everyFiveMinutes()->withoutOverlapping();
Treat this as a first-class path, not a fallback — design your order state machine so a poll result and a webhook result both transition it identically.
Multi-tenant / multi-app setups
The config is global, so if you serve several Kopo Kopo applications (per-tenant till numbers), you can't rely on config(). Build a small factory that constructs a client per tenant with credentials from your database and register it in the container as a scoped binding. Verify whether the package supports instantiating the client with an explicit config array; if not, wrap it or set config at runtime per request/queue worker — and remember to reset it.
Testing
Fake at the HTTP layer (Http::fake()) rather than mocking the package class, so you also catch payload-shape regressions. For webhook tests, keep a handful of real captured sandbox payloads in tests/Fixtures/ and replay them — including a duplicate, an out-of-order event, and a failure event.
1500, not 1500.00 or "1,500". Decimal values are rejected or truncated, and a truncated amount produces a payment that reconciles incorrectly rather than failing loudly.2547XXXXXXXX / 2541XXXXXXXX. Strip +, spaces, and leading 0 in a cast or mutator before it ever reaches the API.localhost and query strings cause silent no-shows. Use a tunnel (ngrok/Expose) for local dev and verify that your tunnel preserves the raw body — signature verification breaks if middleware re-encodes it.JobProcessed.config:cache freezes your environment. Keep env() calls strictly inside config/, and re-run php artisan config:clear after editing .env — otherwise a typo'd secret looks like an auth bug for an hour.On this release (1.3.1): the changelog is a bare compare link with no listed changes, which in practice means a patch release of small fixes and dependency bumps. Nothing here suggests a breaking change, so an upgrade from 1.3.0 is low risk — but run your full test suite (especially webhook payload fixtures and any request-shape assertions) before deploying, since a patch can still alter a serialized field name. Pin the version in composer.json and review the diff on GitHub if you have custom code reaching into src/.
How can I help you explore Laravel packages today?