moffhub/ussd
Enterprise-grade Laravel USSD framework for building scalable menus and flows across African providers (Safaricom/Africa’s Talking, Airtel, MTN, generic). Includes menu/forms/wizards, session recovery, security, analytics, caching, and pluggable data providers.
Installation:
composer require moffhub/ussd
php artisan vendor:publish --provider="Moffhub\Ussd\UssdServiceProvider"
php artisan migrate # Optional (for session/analytics persistence)
Route Definition (in routes/api.php):
Route::post('/ussd', [UssdController::class, 'handle']);
Basic Controller (app/Http/Controllers/UssdController.php):
use Moffhub\Ussd\UssdFramework;
use Moffhub\Ussd\Menus\SimpleMenu;
public function handle(Request $request) {
$framework = new UssdFramework(['default_menu' => 'main']);
$framework->registerMenu('main', new SimpleMenu('Welcome', ['1' => 'Option 1'], [
'1' => fn($session, $framework) => $framework->navigateToMenuWithResponse('balance')
]));
return response($framework->handle($request)->formatForNetwork());
}
Create a 2-step menu (main → balance) with:
SimpleMenu for the main menunavigateToMenuWithResponse()Request Handling:
$framework = new UssdFramework(config());
$response = $framework->handle($request);
return response($response->formatForNetwork());
Menu Registration:
$framework->registerMenu('menu_name', new SimpleMenu('Title', ['1' => 'Option'], [
'1' => function($session, $framework) {
// Handle selection
}
]));
Session Management:
$session->set('user_id', 123);
$userId = $session->get('user_id');
new SimpleMenu('Title', ['1' => 'Option'], ['1' => callback])
$form = new FormMenu('Registration');
$form->addField(new FormField('name', 'Enter name:', [Validators::required()]));
$form->setOnComplete(function($session, $data) { ... });
new PaginatedMenu('Products', $products, ['items_per_page' => 5])
$framework->navigateToMenu('menu_name');
$response = $framework->navigateToMenuWithResponse('menu_name');
$menu = new ConditionalMenu($defaultMenu);
$menu->addCondition(fn($session) => $session->getUserData('is_admin'), $adminMenu);
$provider = new DatabaseDataProvider(Product::class);
$menu = new PaginatedMenu('Products', $provider);
$provider = new ApiDataProvider('https://api.example.com', [], ['token' => '...']);
$provider = ProviderFactory::detect($request);
ProviderFactory::register('custom', CustomProvider::class);
enum AppMenu { case Main; case Balance; }
$framework->registerMenu(AppMenu::Main, ...);
(new MenuBuilder('menu'))
->title('Welcome')
->option('1', 'Option', fn($s, $f) => $f->navigateToMenuWithResponse('next'))
->build();
// In menu action
$user = User::find($session->getUserData('user_id'));
Session Recovery:
grace_period is set appropriately (default: 600s)enable_intelligent_recovery for complex flowsProvider Field Mappings:
phoneNumber, text, sessionIdmsisdn, input/text, transactionIdmsisdn, UserAnswer, sessionIdRate Limiting:
Input Sanitization:
strict_mode in production to block suspicious input$framework->setLogger(new SingleChannelLogger('ussd.log'));
dd($session->getAll());
if (!$framework->hasMenu('menu_name')) {
throw new \InvalidArgumentException("Menu not registered");
}
$framework = new UssdFramework(['cache_enabled' => false]);
ussd_analytics table$framework = new UssdFramework(['analytics_enabled' => false]);
Accept-Language headeren if not specifiedValidators::custom(fn($value) => $value > 100, 'Must be > 100');
class CustomProvider extends AbstractUssdProvider {
public function getSessionId(Request $request) {
return $request->input('custom_session_id');
}
}
$response->setHeader('X-Custom-Header', 'value');
DatabaseDataProvider with eager loading:
new DatabaseDataProvider(Product::class, fn() => Product::with('category')->whereActive(true))
$menu = new PaginatedMenu('Products', $provider, ['cache_ttl' => 300]);
$rateLimiter->addToWhitelist('+254712345678', 'Admin access');
audit_logging in configUssdAuditLog model for admin panelssession_expiry (default: 3600s) to match business needs| Provider | Quirks | Tips |
|---|---|---|
| Safaricom | Strict session ID format | Use sessionId field |
| Airtel | Transaction ID required | Validate transactionId in responses |
| MTN | UserAnswer may be empty |
Handle null values in input |
| Generic | Field name flexibility | Configure field_mappings in provider |
$provider = Mockery::mock(AbstractUssdProvider::class);
$framework->setProvider($provider);
$session = $framework->getSession();
$session->set('test', 'value');
$this->assertEquals('value', $session->get('test'));
$this->assertTrue($framework->hasMenu('test'));
$this->assertInstanceOf
How can I help you explore Laravel packages today?