Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Kopokopo Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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

  1. config/kopokopo.php — base URL, credentials, timeout, and default callback/till settings. This file answers most "why is this failing" questions.
  2. The package's HTTP client / API wrapper under src/ — it shows the exact request payload shape and which endpoints are wrapped versus which you'll need to call yourself.
  3. The webhook/event handling path, if the package ships one. If it doesn't, you own that code and should plan for it up front.
  4. The README examples — the fastest path to a working STK push.

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.

Implementation Patterns

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.

Gotchas and Tips

  • Amounts are not floats. Send an integer (or integer-string) in KES — 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.
  • Phone format is strict. 2547XXXXXXXX / 2541XXXXXXXX. Strip +, spaces, and leading 0 in a cast or mutator before it ever reaches the API.
  • Callback URLs must be publicly reachable HTTPS. 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.
  • Always return 200 from the webhook, even on your own internal failure, and log the failure. A non-2xx triggers retries that can multiply the problem; a swallowed event is recoverable via reconciliation.
  • Token/credential caching in long-lived workers. The OAuth token has a TTL. In queue workers and Octane, a cached client instance can outlive the token. If you see sporadic 401s that only appear on workers that have been up for a while, that's the cause — resolve the client per job or reset it on 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.
  • Sandbox ≠ production. Sandbox doesn't send real STK prompts and some flows behave differently. Get one real low-value production transaction through end-to-end before launch; don't ship on sandbox green.
  • Errors sometimes arrive with an HTTP 200. Inspect the response body for an error/status field rather than trusting the status code alone, and wrap calls in a try/catch that surfaces the API's message.
  • Timestamps are Africa/Nairobi. Settlement and payout payloads are not UTC. Store as UTC on ingest and convert on display, or your daily settlement reports won't line up.
  • Extension points worth knowing. The facade supports macros, so cross-cutting concerns (logging each call, tagging with a correlation id) can live in a service provider instead of being sprinkled through your code. If the package's webhook handling is too thin for you, keep your own controller and use the client purely for outbound calls — mixing both is fine.

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/.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
abbasudo/artisan-route
mirvan/ippanel
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package