openpesa/laravel-pesa
Simplify mobile money payments in Laravel with openpesa/laravel-pesa. Configure API credentials, send payment requests, check transaction status, and process callbacks for Tanzanian payment providers through one clean package interface.
This is the Laravel wrapper around the OpenPesa gateway for Tanzanian mobile money (M-Pesa / Tigo Pesa / Airtel Money), so treat it as a thin HTTP client plus a config file — not a billing system. It signs requests, posts them to OpenPesa, and hands back a response object.
composer require openpesa/laravel-pesa — v1.0.2 is the first release that explicitly supports Laravel 9 and PHP 8.1. If you are still on Laravel 8 / PHP 8.0, pin to v1.0.0 rather than assuming forward compatibility.php artisan vendor:publish --provider="OpenPesa\LaravelPesa\PesaServiceProvider" (check config/pesa.php after publishing — the key is usually pesa, not laravel-pesa)..env with your OpenPesa credentials (OPENPESA_API_KEY, OPENPESA_PUBLIC_KEY, OPENPESA_ENVIRONMENT, and the service provider / shortcode identifiers). These are not M-Pesa portal credentials — they come from your OpenPesa dashboard, and mixing the two is the single most common first-day mistake.Where to look first, in order: config/pesa.php (what is configurable), the facade's underlying client class (what methods actually exist — the README lags the code), then the tests directory. v1.0.2 moved the suite to Pest, which means the tests are now a genuinely readable specification of the payload shapes each endpoint expects. Read them before trusting the docs.
Wrap the facade, don't call it from controllers. The package returns gateway response objects/arrays whose shape is dictated by OpenPesa, not by you. Put a PesaService or a small MpesaGateway class in front of it so that when the response format shifts you change one file. Inject that wrapper; never use OpenPesa\...\Facades\Pesa; inside a controller or a Blade-adjacent class.
Initiation is async — model it as a state machine. A push returns an acknowledgement, not a payment. Persist a transactions row immediately (status = 'pending', your reference, amount, msisdn) before calling the gateway, then reconcile later. Two reconciliation paths, and you should build both:
auth middleware but validate the payload against a stored record rather than trusting it — never let the callback body be the source of truth for the amount.php artisan command every few minutes that queries status for transactions stuck in pending past a threshold. Callbacks get lost; this is your safety net, and it's also how you debug.Always send your own idempotency reference. Generate a ULID/UUID per attempt, store it, and use it as the transaction reference. Retries then collapse onto the same logical payment instead of double-charging a customer. Combined with Laravel's queue retry semantics (tries, backoff), this is the difference between a resilient integration and an incident.
Queue the outbound calls. Wrap the gateway call in a job (ShouldQueue, $tries = 3, exponential backoff, failed() handler that marks the transaction failed). The gateway is a third party in a different network; do not hold an HTTP request open for it in a checkout flow.
Abstract the environment. config('pesa.environment') should drive both the base URL and the credentials, and it should be impossible to accidentally boot production credentials in a test suite. Assert this in a test: the sandbox config is what CI runs against.
Testing. With Pest now in the suite, follow the same approach in your own app: Http::fake() on the OpenPesa endpoints and assert your wrapper produces the right payload from a given transaction model. Do not hit the sandbox from your test suite — it's rate-limited and stateful.
pesa:* command from the initial release, it will now fail with "command not found." Grep your codebase and your scheduler for it. This is the only behavior change in the release and it is undocumented in the notes beyond the changelog line.illuminate/support in composer.json for the actual constraint rather than assuming. Older Laravel + this release is a combination nobody has validated.255XXXXXXXXX. A leading 0 or a + prefix may produce a gateway error that looks like an auth error in the response body. Normalize on ingest and validate with a regex before ever calling the API..env, run php artisan config:clear. A stale config:cache from a prior deploy will happily send sandbox credentials to production and you'll debug it for an hour.pesa log channel with a day or two of retention pays for itself during onboarding.^1.0.2, keep the wrapper boundary in place, and re-evaluate if a v2 introduces a reworked client or a Laravel 10/11 constraint.How can I help you explore Laravel packages today?