korba / ussd-builder
USSD application builder for Laravel 8-12 with session continuity
Requires
- php: ^8.1
- illuminate/cache: ^8.0|^9.0|^10.0|^11.0|^12.0
- illuminate/console: ^8.0|^9.0|^10.0|^11.0|^12.0
- illuminate/contracts: ^8.0|^9.0|^10.0|^11.0|^12.0
- illuminate/database: ^8.0|^9.0|^10.0|^11.0|^12.0
- illuminate/events: ^8.0|^9.0|^10.0|^11.0|^12.0
- illuminate/filesystem: ^8.0|^9.0|^10.0|^11.0|^12.0
- illuminate/http: ^8.0|^9.0|^10.0|^11.0|^12.0
- illuminate/routing: ^8.0|^9.0|^10.0|^11.0|^12.0
- illuminate/support: ^8.0|^9.0|^10.0|^11.0|^12.0
Requires (Dev)
- orchestra/testbench: ~6|~7|~8|~9|~10|~11
- phpunit/phpunit: ^9.6
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is not auto-updated.
Last update: 2026-11-04 16:00:34 UTC
README
Build rich, interactive USSD applications quickly, with less code.
Laravel USSD gives you a state-driven engine, a fluent menu builder, expressive input decisions, and a simple record store on top of Laravel — so you can focus on your flow instead of wiring gateways and juggling session state. It ships with built-in session continuity, so a user whose session dies (they cancel, the network times out, the handset drops, the app crashes, or the gateway disconnects) can dial back and pick up where they left off — even when the operator issues a brand-new session ID.
- State-oriented flow with a fluent
Menubuilder - Simple
switch-based routing between states - A
Recordstore for collecting data across screens Actionclasses for side effects and business logic- Database or cache session drivers with MSISDN-based cross-session recovery
- Request normalization for common gateway formats
- Artisan generators for states, actions, and full flows
Requirements
- PHP
^8.1 - Laravel
8–12
Installation
Install the package via Composer:
composer require korba/ussd-builder
The service provider is auto-discovered. Publish the configuration:
php artisan vendor:publish --tag=ussd-config
Migrations for the ussd_sessions table are loaded automatically. Run them (only needed for the default database session driver):
php artisan migrate
You can optionally publish the migration to customize it, and the stubs used by the generators:
php artisan vendor:publish --tag=ussd-migrations
php artisan vendor:publish --tag=ussd-stubs
Configuration
Set your service code and (optionally) the session driver in your .env:
USSD_SERVICE_CODE=*920#
USSD_SESSION_DRIVER=database # or "cache"
USSD_CACHE_STORE=redis # used when the driver is "cache"
The published config/ussd.php looks like this:
return [
// The first state entered on a fresh dial-in.
'initial_state' => 'App\\Ussd\\States\\WelcomeState',
// Where generated states and actions are created.
'state_namespace' => 'App\\Ussd\\States',
'action_namespace' => 'App\\Ussd\\Actions',
// "database" (default, uses the ussd_sessions table) or "cache".
'session_driver' => env('USSD_SESSION_DRIVER', 'database'),
'cache_store' => env('USSD_CACHE_STORE', null),
// Your USSD short code, e.g. *920#.
'service_code' => env('USSD_SERVICE_CODE'),
// Appended to every rendered menu (e.g. "\n0. Back").
'default_response_suffix' => '',
// Optional fallback state and retry budget for input errors.
'error_state' => null,
'max_retries' => 3,
// Session continuity / cross-session recovery.
'continuity' => [
'enabled' => true,
'cross_session' => true, // recover progress across a new session ID
'snapshot_prefix' => 'ussd:continuity',
'timeout' => 900, // seconds a snapshot stays resumable
'active_session_seconds' => 3, // suppress the prompt for an in-flight session
'non_recoverable_states' => [], // states that should never be resumed
'resume_prompt' => 'We noticed you have an unfinished session. Choose an option:',
'resume_option_key' => '1',
'resume_option_text' => 'Resume previous session',
'restart_option_key' => '2',
'restart_option_text' => 'Start over',
],
// States that end the session when reached.
'terminal_states' => [],
];
Handling Requests
The package registers a single route:
POST /ussd -> name: ussd.handle
Point your gateway's callback URL at /ussd. The controller normalizes the common gateway fields for you:
| Purpose | Accepted request fields |
|---|---|
| Session ID | sessionID, sessionId, session_id |
| Phone number | msisdn, phoneNumber |
| User input | ussdString, text |
| Service code | serviceCode |
Responses are returned as JSON in the shape {"ussdServiceOp":"2","message":"..."}, where 2 continues the session (CON) and 17 ends it (END).
Building a USSD
A USSD application is a set of states. Each state renders a screen (buildMenu) and decides where to go next based on the user's input (next).
Generate one with Artisan:
php artisan ussd:state WelcomeState
namespace App\Ussd\States;
use Vendor\LaravelUssd\Support\AbstractState;
use Vendor\LaravelUssd\Support\Context;
use Vendor\LaravelUssd\Menu\Menu;
class WelcomeState extends AbstractState
{
protected function buildMenu(Context $context): Menu
{
return (new Menu())
->text('Welcome to Acme Bank')
->option('1', 'Check balance')
->option('2', 'Send money');
}
public function next(Context $context, string $input): ?string
{
switch ($input) {
case '1':
return BalanceState::class;
case '2':
return AmountState::class;
default:
return WelcomeState::class; // re-prompt on invalid input
}
}
}
Set initial_state in config/ussd.php to your entry screen (e.g. App\Ussd\States\WelcomeState).
The Menu builder
Menu builds the text sent to the handset:
(new Menu())
->text('Choose an account') // a line of text
->line() // a blank line
->option('1', 'Savings') // "1. Savings"
->listing(['Airtime', 'Data']) // auto-numbered from 1
->paginate($items, perPage: 4) // adds a "0. More" option when needed
->expectsInput(false); // force an END (no further input)
By default a menu expects input and produces a CON response. Returning null from next() (or calling expectsInput(false)) ends the session.
Routing input
Inside next(), select the next state with a switch statement on the user's input. Return the state's class name to move on, or null to end the session:
public function next(Context $context, string $input): ?string
{
switch ($input) {
case '1':
return TopUpFloatState::class;
case '2':
return CheckFloatBalanceState::class;
default:
return null; // end the session on invalid input
}
}
Use the default case as your fallback — return the same state's class to re-prompt the user, or null to end the session.
Records (collecting data)
Use the record store to persist values across screens. It is backed by the session, so it survives every step of the flow:
public function next(Context $context, string $input): ?string
{
$this->setContext($context); // required before using $this->record in next()
$this->record->set('amount', $input);
// $this->record->setMultiple(['a' => 1, 'b' => 2]);
// $value = $this->record->get('amount', 'default');
return ConfirmState::class;
}
In buildMenu() the context is already set, so you can read straight away:
protected function buildMenu(Context $context): Menu
{
return (new Menu())
->text('Confirm amount: ' . $this->record->get('amount'));
}
Actions
Actions keep side effects (API calls, DB writes, balance lookups) out of your states. Generate one with php artisan ussd:action ChargeAction:
namespace App\Ussd\Actions;
use Vendor\LaravelUssd\Support\AbstractAction;
use Vendor\LaravelUssd\Support\Context;
class ChargeAction extends AbstractAction
{
public function handle(Context $context, string $input): mixed
{
// Perform work and return an outcome the calling state can branch on.
return PaymentApi::charge($context->msisdn, $context->data['amount']);
}
}
Actions are invokable, so a state can call one and route on the result:
public function next(Context $context, string $input): ?string
{
$ok = (new ChargeAction())($context, $input);
return $ok ? SuccessState::class : FailureState::class;
}
Scaffold a whole flow
Generate a matching state and action in one step:
php artisan ussd:flow Transfer
# creates App\Ussd\States\TransferState and App\Ussd\Actions\TransferAction
Session Continuity
When a session dies and the user redials, the operator typically issues a new session ID, so progress keyed only by session ID is lost. This package additionally stores a recovery snapshot keyed by the MSISDN, so on redial the user is offered:
We noticed you have an unfinished session. Choose an option:
1. Resume previous session
2. Start over
Choosing Resume restores both the previous screen and the data collected before the session died. This is enabled by default (continuity.enabled + continuity.cross_session). See docs/session-continuity.md for the full details, including the persistent-cache requirement and upgrade notes for published configs.
Testing
The package uses PHPUnit with Orchestra Testbench:
composer test
# or
vendor/bin/phpunit
Development
Auto-Update Packagist
Released under the MIT license.