All posts

/3 min/Moffat Munene

Build a school-fees USSD app with moffhub/ussd

A complete walkthrough from the menu tree to the M-Pesa STK push for a USSD app that lets parents check balances and pay school fees. The shape we deploy to several Kenyan schools.

  • #ussd
  • #php
  • #fintech
  • #kenya
  • #mpesa
  • #tutorial

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 a WhereGuardian rule.
  • 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 PaymentFailed event 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.