This is post 2 in the moffhub/ussd series. If you haven't read the intro, start there it covers why USSD matters and how the package's menu DSL works.
In this post: a real, deployable USSD app that lets a parent check their child's school-fees balance and pay it via M-Pesa STK. ~150 lines of PHP, no menu sprawl.
The user journey
*123#
> Welcome to ACME Academy
> 1) Check balance
> 2) Pay fees
> 3) View statement
Pick 2, enter the admission number, enter the amount, get an STK push, hang up. Total session length: ~25 seconds.
The menu
use Moffhub\Ussd\Menu;
Menu::root('main')
->title('Welcome to ACME Academy')
->item('Check balance', fn ($m) => $m->goto('lookup_admission'))
->item('Pay fees', fn ($m) => $m->goto('lookup_admission_pay'))
->item('View statement', fn ($m) => $m->goto('statement'));
Menu::node('lookup_admission')
->prompt('Enter admission number')
->validate('regex:/^[A-Z]{2}\d{4}$/')
->save('admission_no')
->action(ShowBalance::class);
Menu::node('lookup_admission_pay')
->prompt('Enter admission number')
->validate('regex:/^[A-Z]{2}\d{4}$/')
->save('admission_no')
->goto('amount');
Menu::node('amount')
->prompt('Enter amount (KES)')
->validate('numeric|min:50|max:200000')
->save('amount')
->goto('phone');
Menu::node('phone')
->prompt('M-Pesa number (07XX...)')
->validate('regex:/^0[17]\d{8}$/')
->save('phone')
->goto('confirm');
Menu::node('confirm')
->summary(fn ($s) =>
"Pay KES {$s['amount']} for {$s['admission_no']} from {$s['phone']}? 1) Yes 2) No"
)
->item('1', InitiateMpesaStk::class)
->item('2', fn () => Menu::end('Cancelled.'));
The action
final class InitiateMpesaStk
{
public function __invoke(SessionState $state): UssdReply
{
$admission = $state->get('admission_no');
$amount = (int) $state->get('amount');
$phone = $state->get('phone');
$student = Student::where('admission_no', $admission)->first();
if (!$student) return Menu::end('No student found for that admission.');
// Push via moffhub/billing's M-Pesa connector
$charge = MpesaProvider::stk()
->forStudent($student)
->amount($amount)
->msisdn($phone)
->reference("FEES-{$admission}")
->initiate();
return Menu::end(
"STK push sent to {$phone}. Enter your M-Pesa PIN to complete payment."
);
}
}
Notice the call returns inside ~3 seconds well within the 10 s gateway window. The actual money movement happens asynchronously; the parent gets the STK prompt on their phone.
Telling the parent it worked
The webhook from M-Pesa fires PaymentSucceeded (per the MPS spec). A subscriber sends a confirmation SMS:
class SmsOnFeesPaid
{
public function handle(PaymentSucceeded $event): void
{
$payment = $event->payment;
SmsHandler::send(
$payment->payerMsisdn,
"Receipt: KES {$payment->amount} received for {$payment->reference}. Thank you."
);
}
}
SmsHandler is moffhub/sms-handler a small wrapper that supports several Kenyan SMS gateways. We'll cover it in a follow-up post.
What we left out
- Authentication. This menu trusts the admission number. In production, you'd require the registered guardian's phone to match. One extra
validate()and aWhereGuardianrule. - Bilingual menus. Kenyan parents often prefer Swahili. That's a config flip covered in post 4 of this series.
- Failed STK callbacks. If the parent's phone is off when STK arrives, M-Pesa retries. The
PaymentFailedevent is what you listen to. The MPS post above explains the event shape.
Try it locally
$tester = (new UssdTester)->dial('*123#');
$tester->respond('2')->assertPrompt('Enter admission number');
$tester->respond('AC0123')->assertPrompt('Enter amount');
$tester->respond('7500')->assertPrompt('M-Pesa number');
$tester->respond('0712345678')->assertSummary();
$tester->respond('1')->assertEnded('STK push sent');
No real M-Pesa call. The connector swaps to a fake in test environments.
Next in the series: post 3 resuming dropped USSD sessions cleanly.