This is post 3 in the moffhub/ussd series. Post 2 covered building a school-fees app; now we're going to make it survive a dropped session.
The problem
A parent dials *123#, picks "Pay fees", types the admission number, types the amount then the network drops. The session ID expires after ~3 minutes. The next time they dial, they're back at the main menu with all their input gone. After two retries they give up and call the school office, where the admin will helpfully retype it for them.
We want better.
What "resume" actually means
Two things, very different:
- Within-window resume. Same session ID, gateway re-delivers a missed input. Trivial every USSD library handles this.
- Across-session resume. Different session ID (it's a new dial), but we recognise the user from their MSISDN and offer to pick up where they left off. This is the interesting one.
Enabling it
use Moffhub\Ussd\Menu;
Menu::root('main')
->title('Welcome to ACME Academy')
->resumable(within: '15 minutes') // <- this line
->item('Check balance', fn ($m) => $m->goto('lookup_admission'))
->item('Pay fees', fn ($m) => $m->goto('lookup_admission_pay'));
resumable() instructs the package to checkpoint state on every transition into a longer-lived store keyed by MSISDN. When the same number dials within the window with an incomplete session, an extra menu item appears:
Welcome to ACME Academy
1) Check balance
2) Pay fees
3) View statement
4) Resume Pay fees (admission AC0123, amount 7500) ← new
What gets checkpointed
By default, just the node path and the validated form values. Sensitive bits (PINs, OTP codes) are excluded the package's SessionState::sensitive() flag marks fields as transient.
Menu::node('pin')
->prompt('Enter your PIN')
->validate('digits:4')
->saveSensitive('pin') // <- not persisted across sessions
->goto('confirm');
If a user resumes a session that included a sensitive field, they're routed back to that node they re-enter the PIN, but everything else is preserved.
Where state lives
Out of the box: Redis, key ussd:checkpoint:{msisdn}. TTL = the configured within. You can swap in any cache store the Laravel container resolves.
For high-traffic apps (>50 RPS sustained) we run a dedicated Redis cluster checkpoint writes can be chatty, and you don't want them competing with your queue's RPOPLPUSH.
When NOT to resume
- After a "confirm and pay" step. Once we've initiated an STK push, we never offer a resume that path could double-charge if the user picks resume after the push already succeeded.
- When the menu structure changed. Each menu definition gets a content hash. If the deployed hash differs from the checkpoint's hash, the resume offer is suppressed and the session starts fresh. Nobody wants to resume into a node that no longer exists.
- Across phone numbers. Resume is keyed by MSISDN; we don't try to be clever about SIM swaps.
Testing it
$tester = (new UssdTester)->dial('*123#')
->respond('2')
->respond('AC0123')
->respond('7500')
->dropSession(); // simulate timeout
$next = (new UssdTester)->dial('*123#');
$next->assertMenuContains('Resume Pay fees');
$next->respond('4')->assertPrompt('M-Pesa number');
dropSession() discards the in-flight session ID but keeps the checkpoint; dial() later starts a new one.
Next: part 4 multilingual USSD menus. We'll add Swahili and English variants without duplicating the menu definitions.