All posts

/5 min/Moffat Munene

Multilingual USSD that doesn't fall over moffhub/ussd

A USSD framework with translation keys, build-time length checking across every locale, intelligent session recovery, circuit breakers on external API calls, encrypted PIN fields and type-safe menu enums. Everything you wish you didn't have to write yourself.

  • #ussd
  • #php
  • #i18n
  • #kenya
  • #swahili
  • #tutorial

USSD is the protocol your app needs to speak when the customer's phone is a Nokia 105, the data bundle ran out three days ago, and the parent paying school fees is in Bungoma not Westlands. moffhub/ussd is what we built so that "ship USSD" stops meaning "rewrite the menu engine again."

Translation isn't a str_replace job

Inline strings work for single-language apps and break the day procurement asks for Swahili. The package resolves translation keys through Laravel's translator:

use Moffhub\Ussd\Menu;

Menu::root('main')
    ->translatable()
    ->title('ussd.main.title')
    ->item('ussd.main.balance', fn ($m) => $m->goto('lookup'))
    ->item('ussd.main.pay',     fn ($m) => $m->goto('lookup_pay'))
    ->item('ussd.main.lang',    fn ($m) => $m->goto('lang_switch'));

resources/lang/en/ussd.php and resources/lang/sw/ussd.php hold the strings. The framework picks the locale per-session by stored preference for the MSISDN, by first-dial heuristic, or by config fallback and resolves keys at render time.

Validation messages translate through the same path: a numeric|min:50 rule emits Swahili if the session locale is sw, English if en. No mismatched-language stitching.

Build-time length checking across every locale

USSD responses are capped at 182 bytes by the underlying SMSC. Swahili tends to be ~15–20% longer than the English equivalent. The classic failure mode is shipping fine in dev, then a parent dials, the message overflows, and the gateway truncates mid-word.

The package ships a build-time validator:

php artisan ussd:check

It walks every menu, renders each in every configured locale, and fails the build if any rendering exceeds 182 bytes. Catch it in CI; never in production.

Sessions that survive a dropped call

USSD sessions die when the radio link drops. The default behaviour everywhere else: "session expired, please dial again." Watch a customer reach step 4 of an 8-step fee payment, hit a dead spot, and rage-quit.

moffhub/ussd ships intelligent recovery:

'session_timeout' => 300,
'grace_period' => 600,
'enable_intelligent_recovery' => true,
'enable_context_preservation' => true,

Same MSISDN re-dials within 10 minutes? The framework reattaches the previous menu, the form data they'd already typed, and the user-data context (their resolved user_id, their selected child, their account balance lookup). No "start over."

Two layers, intentionally separate:

  • Form data is per-session typed values that should reset on a fresh start.
  • User data is per-MSISDN durable metadata you want available across sessions (resolved customer ID, last successful lookup, language preference).

Circuit breakers on external data

Menus that fetch from an HTTP API fee balances, account positions, KYC lookups are a tail-risk you usually don't notice until the upstream wobbles and your USSD app starts timing out 200 sessions a minute. ApiDataProvider wraps every call in a state-machine circuit breaker:

Menu::node('children')
    ->paginate(new ApiDataProvider(
        url: config('school.children_api'),
        circuitBreaker: ['threshold' => 5, 'cooldown_seconds' => 60],
    ))
    ->item('child.label', fn ($m, $child) => $m->save('child_id', $child['id']));

Five consecutive failures and the circuit opens. The next session gets fallback data instead of a hung HTTP request. After 60 seconds, the circuit half-opens and probes; if the probe succeeds, it closes. Your USSD app stays responsive while the API recovers.

Type-safe menu names

Strings as menu identifiers ("main", "balance", "lookup_pay") are an autocomplete dead-end. The package accepts enums implementing MenuNameInterface:

enum AppMenu: string implements MenuNameInterface {
    case Main = 'main';
    case Balance = 'balance';
    case PayFees = 'pay_fees';
}

$ussd->registerMenu(AppMenu::Main, $mainMenu);
$ussd->navigateToMenu(AppMenu::Balance);

Refactor the menu tree, the IDE catches every reference. Compile-time safety in a place that historically had none.

Encrypted fields for PINs

A USSD session storing a 4-digit PIN in cache as plaintext is a finding waiting to happen. The package supports field-level encryption in session storage:

'security' => [
    'encrypt_session_data' => true,
    'encrypted_fields' => ['form_data.pin', 'form_data.account'],
],

Listed paths are encrypted at write, decrypted transparently at read. Cache dumps and DB session rows show ciphertext.

Rate limiting that isn't just "10 per minute"

Three independent buckets:

'security.rate_limiting' => true,
'security.max_requests_per_minute' => 10,
'security.max_requests_per_hour' => 100,
'security.max_requests_per_day' => 500,

Plus database-backed whitelist/blacklist with metadata, expiry, and an audit log. A bad actor on +254700000000 can be blacklisted for 24 hours with a reason and an "added by" trail; the entry expires automatically.

Provider auto-detection

Safaricom calls a phone number phoneNumber. Africa's Talking calls it phoneNumber. Airtel calls it msisdn. MTN calls it msisdn. Inside vendor docs they all rename it differently. ProviderFactory::detect() inspects the request shape and routes to the right adapter; or you can register your own:

ProviderFactory::register('safaricom_v2', SafaricomV2Provider::class);

The framework normalises phone numbers to E.164 (config provider.country_code for the default) so your app code never sees 0712345678 and +254712345678 for the same human.

Testing locally without a gateway

php artisan ussd:simulate --phone=+254712345678

Interactive CLI session. Type input, see the response, dial deeper. No tunneling, no Africa's Talking sandbox round-trip.

There's also ussd:list-sessions, ussd:cleanup-sessions, ussd:health and ussd:manage-access for whitelist/blacklist administration.

What it doesn't do

Right-to-left languages. The gateways we work with strip RTL markers, so Arabic content renders LTR. We punted; if you have a use case for Arabic USSD that survives transcoding, get in touch we'd ship support but couldn't justify it without a real customer.

Install

composer require moffhub/ussd
php artisan vendor:publish --tag=ussd-config

Production in school-fees, microfinance, and healthcare USSD apps. The *123# you dialled last month there's a non-trivial chance it was running this.