maestrodimateo / simple-mobile-money
A simple, unified Laravel API for Gabonese mobile money aggregators (E-Billing, SingPay, PViT)
Package info
github.com/maestrodimateo/simple-mobile-money
pkg:composer/maestrodimateo/simple-mobile-money
Requires
- php: ^8.3 || ^8.4
- illuminate/database: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.29
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^4.0
README
One simple, unified Laravel API to collect mobile money payments in Gabon β through E-Billing, SingPay and PViT (Airtel Money & Moov Money).
You write the same code for every provider. The package hides each API's base URL, auth scheme, payload format and webhook shape behind neutral DTOs, a single facade, and Laravel events.
π«π· Nouveau sur le mobile money ? Lis le Guide du dΓ©butant β une explication pas Γ pas (en franΓ§ais) du fonctionnement du package et de chaque agrΓ©gateur.
$response = MobileMoney::driver('singpay')->pay(new PaymentRequest( amount: 500, // XAF reference: 'ORDER-1042', // your unique reference msisdn: '074000000', // customer's number operator: Operator::AIRTEL, ));
Contents
- Guide du dΓ©butant (FR) β step-by-step beginner guide
- How it works β read this first
- Requirements
- Installation
- Provider setup
- Quickstart (end to end)
- API reference
- Webhooks in depth
- Security model
- Configuration reference
- Recipes & FAQ
- Testing
- Roadmap
How it works
A mobile money collection is asynchronous: pay() only starts it. The real
outcome (success / failure) arrives later, through a webhook, which the
package turns into a Laravel event.
There are two flows, depending on the provider:
| Flow | Providers | What pay() returns |
What you do |
|---|---|---|---|
| USSD push | SingPay (default), PViT, E-Billing (flow: ussd_push) |
PaymentResponse with needsRedirect() === false |
Tell the user to confirm the push (PIN) on their phone, then wait for the event |
| Hosted redirect | E-Billing (default), SingPay (flow: ext) |
PaymentResponse with a redirectUrl |
Redirect the user to that URL; they pay on the provider's page and come back |
The full lifecycle:
Your app Package Provider
β pay(PaymentRequest) β β
β ββββββββββββββββββββββββββββββΊ β initiate + store txn β
β β ββββββββββββββββββββββββββββΊ β
β ββββββββββββββββββββββββββββββ β PaymentResponse (PENDING) β
β (redirect OR "confirm push") β β
β β βββ customer pays βββββ€
β β β (PIN / hosted page)β
β β ββββββββ΄ββ webhook βββββββββ β
β β re-verify status via API β
β β ββββββββββββββββββββββββββββΊ β
β βββ PaymentSucceeded event βββ β update txn + dispatch event β
β fulfill the order β β
Key idea: never mark an order as paid from the
pay()response. Fulfil it only when you receive thePaymentSucceededevent.
Requirements
- PHP 8.3+
- Laravel 12.x / 13.x
Installation
composer require maestrodimateo/simple-mobile-money
Publish the config and run the migration (the transactions table is registered automatically):
php artisan vendor:publish --tag=mobile-money-config php artisan migrate
Provider setup
Pick your default provider and fill in the credentials for the ones you use.
# ββ default provider used when you call MobileMoney::driver() with no argument MOBILE_MONEY_PROVIDER=singpay
E-Billing (Digitech)
Credentials come from your merchant profile (LAB or PROD). Auth is HTTP Basic.
EBILLING_USERNAME= EBILLING_SHARED_KEY= EBILLING_ENV=lab # lab | production EBILLING_FLOW=redirect # redirect (hosted portal) | ussd_push (Airtel/Moov) # EBILLING_EXPIRY_PERIOD=30 # bill validity in minutes (optional)
E-Billing requires
payer_emailandpayer_nameβ setpayerEmailandpayerNameon thePaymentRequest, otherwise the driver throws. It is also callback-only: it exposes no status endpoint, sostatus()throws for this driver (see How it works).
SingPay
Credentials come from SingPay Workspace (client.singpay.ga). A test wallet
is created automatically; the same base URL is used for test and production.
SINGPAY_CLIENT_ID= SINGPAY_CLIENT_SECRET= SINGPAY_WALLET= # SINGPAY_DISBURSEMENT= # required only for a production wallet SINGPAY_FLOW=ussd_push # ussd_push (direct) | ext (hosted page) # For the ext flow (both required by SingPay; callbackUrl overrides success): # SINGPAY_REDIRECT_SUCCESS=https://your-app.com/pay/success # SINGPAY_REDIRECT_ERROR=https://your-app.com/pay/error # SINGPAY_LOGO_URL=
PViT (mypvit / BakoAI, API v2)
Credentials come from your PViT merchant space. Auth is the X-Secret header.
PVIT_SECRET=sk_live_xxxxxxxx PVIT_CODE_URL= # the {codeUrl} in your endpoints PVIT_ACCOUNT_OPERATION_CODE= PVIT_CALLBACK_URL_CODE=
Some provider details are not publicly documented (PViT's Airtel v2 operator code, E-Billing operator machine codes and status endpoint). They are configurable in
config/mobile-money.php(providers.*.operators) rather than hard-coded β confirm them with each provider and adjust if needed.
Quickstart (end to end)
1. Start the payment (controller)
use Illuminate\Http\Request; use Maestrodimateo\MobileMoney\Facades\MobileMoney; use Maestrodimateo\MobileMoney\Data\PaymentRequest; use Maestrodimateo\MobileMoney\Enums\Operator; class CheckoutController { public function pay(Request $request) { $response = MobileMoney::driver('singpay')->pay(new PaymentRequest( amount: 500, // XAF, integer reference: 'ORDER-'.$order->id, // your unique reference msisdn: $request->input('phone'), // e.g. '074000000' operator: Operator::AIRTEL, description: "Order #{$order->id}", )); // Hosted flow (E-Billing): send the customer to the provider's page. if ($response->needsRedirect()) { return redirect()->away($response->redirectUrl); } // USSD-push flow (SingPay, PViT): the customer confirms on their phone. return view('checkout.pending', ['reference' => $response->reference]); } }
$response->status is normally PaymentStatus::PENDING here. Do not treat
it as paid yet.
2. Register the webhook URL
The package already exposes the route
POST /mobile-money/webhook/{provider}. In each provider's merchant dashboard,
register the matching public URL:
https://your-app.com/mobile-money/webhook/singpay
https://your-app.com/mobile-money/webhook/ebilling
https://your-app.com/mobile-money/webhook/pvit
3. Fulfil the order when the payment succeeds (listener)
use Maestrodimateo\MobileMoney\Events\PaymentSucceeded; class FulfilOrder { public function handle(PaymentSucceeded $event): void { // $event->result is a CallbackResult (provider-neutral) $order = Order::where('reference', $event->result->reference)->firstOrFail(); if ($order->isPaid()) { return; // idempotent: the same webhook may arrive more than once } $order->markPaid(); } }
Register it in your EventServiceProvider (or with an attribute listener). A
PaymentFailed event is dispatched for failed / cancelled / expired payments.
That's the whole loop: initiate β (redirect or push) β event β fulfil.
API reference
Facade β MobileMoney
MobileMoney::driver(?string $provider = null): Gateway // null = default provider
Each Gateway exposes:
| Method | Returns | Purpose |
|---|---|---|
pay(PaymentRequest $request) |
PaymentResponse |
Start a collection |
status(string $providerReference) |
PaymentStatus |
Poll the current status (throws if supportsStatusQuery() is false) |
supportsStatusQuery() |
bool |
Whether the provider exposes status polling (E-Billing: false) |
verify(string $merchantRef, ?string $providerRef) |
PaymentStatus |
Re-verify bound to the merchant reference (used internally by the webhook) |
parseWebhook(Request $request) |
CallbackResult |
Normalise a raw webhook (used internally) |
name() |
string |
The provider key |
PaymentRequest (you build this)
| Field | Type | Required | Notes |
|---|---|---|---|
amount |
int |
yes | XAF, integer (no decimals) |
reference |
string |
yes | Your unique reference (PViT: β€ 15 chars) |
msisdn |
string |
yes | Customer number, e.g. 074000000 |
operator |
Operator |
yes | Operator::AIRTEL or Operator::MOOV |
description |
?string |
no | Shown to the customer |
payerName |
?string |
no | |
payerEmail |
?string |
no | |
callbackUrl |
?string |
no | Per-request return URL (hosted flow) |
metadata |
array |
no | Echoed back to you; never sent to the provider |
PaymentResponse (returned by pay())
| Property | Type | Notes |
|---|---|---|
status |
PaymentStatus |
Usually PENDING |
reference |
string |
Your reference |
providerReference |
?string |
Provider transaction / bill id |
redirectUrl |
?string |
Set for hosted flows |
raw |
array |
Raw provider response |
needsRedirect() |
bool |
true when a redirect is required |
PaymentStatus (enum)
PENDING, SUCCESS, FAILED, CANCELLED, EXPIRED, AMBIGUOUS, UNKNOWN.
$status->isFinal(); // true for SUCCESS / FAILED / CANCELLED / EXPIRED $status->isSuccessful(); // true only for SUCCESS
Operator (enum)
Operator::AIRTEL, Operator::MOOV β with ->label() ("Airtel Money" / "Moov Money").
CallbackResult (carried by webhook events)
reference, status, providerReference, amount, operator, raw.
Events
| Event | When | Properties |
|---|---|---|
PaymentInitiated |
after pay() |
provider, request, response, transaction |
PaymentSucceeded |
webhook re-verified as success | provider, result, transaction |
PaymentFailed |
webhook re-verified as failed/cancelled/expired | provider, result, transaction |
transaction is the stored Transaction model (or null if persistence is off).
Transaction model
Stored in mobile_money_transactions: provider, reference,
provider_reference, status (cast to PaymentStatus), amount, currency,
msisdn, operator (cast to Operator), description, metadata, raw.
Exceptions
InvalidConfigurationExceptionβ missing credential or unmapped operator.ProviderRequestExceptionβ the provider returned an HTTP error (->provider,->statusCode,->context).- Both extend
MobileMoneyException.
Webhooks in depth
- Endpoint:
POST {webhooks.path}/{provider}(defaultmobile-money/webhook/{provider}), route namemobile-money.webhook. - No CSRF: the route is registered outside the
webgroup, so external POSTs work without a token. - Acknowledgement: the endpoint replies
{ "responseCode": 200, "transactionId": "..." }β the exact shape PViT requires; the others just need HTTP 200. - Testing locally: expose your app with a tunnel (e.g.
php artisan exposeor ngrok) and register the public tunnel URL in the dashboards. - Disable entirely: set
MOBILE_MONEY_WEBHOOKS=false(the route won't be registered).
Security model
None of the three aggregators cryptographically sign their webhooks, so the callback body is treated as an untrusted hint β never as truth. On every webhook the package:
- optionally restricts callbacks to an IP allowlist
(
webhooks.allowed_ips) β configure TrustedProxies sorequest()->ip()is the real client IP behind a load balancer; - matches the callback to a stored transaction by merchant reference and rejects any webhook pointing to a different provider transaction than the one on record (reference-confusion protection);
- re-verifies the status against the provider API using the stored
provider reference β never the one in the webhook body
(
webhooks.verify_status, on by default β keep it on). For SingPay'sextflow (where no provider reference is known until the webhook), the outcome is additionally bound to the merchant reference: the re-verified provider transaction must belong to the expected order, or it is rejected; - fails closed (HTTP 4xx, no success dispatched) when it cannot re-verify
or is unsure (
UNKNOWN/AMBIGUOUS), and never moves a transaction out of a terminal state. For E-Billing (callback-only), a matching amount is mandatory and a partial payment is never promoted to a full success; - dispatches terminal events only on the transition into the final state, so a replayed webhook cannot re-trigger fulfilment;
- is rate-limited by default (
webhooks.middleware), since each call triggers a synchronous outbound verification.
These guarantees rely on the stored transaction, so keep persistence enabled (
store.enabled) in production. With storage off, the package can only do a best-effort check and your application must validate the reference β provider-reference mapping itself.
Configuration reference
config/mobile-money.php:
| Key | Default | Purpose |
|---|---|---|
default |
ebilling |
Provider used by MobileMoney::driver() |
store.enabled |
true |
Persist transactions (also gates the auto-migration) |
store.store_raw |
true |
Persist providers' raw payloads (contain PII) β set false to omit |
store.table |
mobile_money_transactions |
Table name |
store.model |
Transaction::class |
Model used for persistence (a custom one must extend Transaction) |
webhooks.enabled |
true |
Register the webhook route |
webhooks.path |
mobile-money/webhook |
Base path (provider is appended) |
webhooks.middleware |
['throttle:60,1'] |
Middleware for the webhook route (throttling recommended) |
webhooks.verify_status |
true |
Re-verify status before trusting a success |
webhooks.allowed_ips |
[] |
Per-provider IP allowlist ([] = no IP check) |
providers.* |
β | Credentials, base URLs and operator maps |
Recipes & FAQ
Use a custom Transaction model β point store.model at your own model
(extend the package's Transaction, or match its columns).
Go stateless β set store.enabled=false. You then persist whatever you need
from the PaymentInitiated / PaymentSucceeded events yourself. Read the
security note first.
Make fulfilment idempotent β the same webhook can arrive more than once.
Guard your listener (if ($order->isPaid()) return;) as shown in the quickstart.
Poll instead of / in addition to webhooks β call
MobileMoney::driver('pvit')->status($providerReference) where
$providerReference is PaymentResponse->providerReference (or the stored
Transaction->provider_reference). Not available for E-Billing, which is
callback-only (supportsStatusQuery() === false).
Test your integration β the package uses Laravel's HTTP client, so fake it:
use Illuminate\Support\Facades\Http; Http::fake([ 'gateway.singpay.ga/*' => Http::response(['transaction' => ['id' => 'tx-1', 'status' => 'Start']]), ]); $response = MobileMoney::driver('singpay')->pay(/* ... */); expect($response->providerReference)->toBe('tx-1');
Testing
composer test # Pest composer lint # Pint (code style) composer analyse # PHPStan / Larastan level 6
Roadmap
- Disbursement / payout (cash-out).
- SingPay hosted payment page (
/ext) as an alternative to the USSD-push flow. - Direct Airtel Money / Moov Money integrations (operator contracts required).
License
MIT.
