Complete Example
This guide walks you through building a complete USSD application from scratch. We'll create a simple money transfer service that demonstrates all the core concepts.
Step 1: Installation
First, install the package via Composer:
composer require catalysteria/laravel-ussd
Publish the configuration:
php artisan vendor:publish --provider="Vendor\LaravelUssd\Providers\LaravelUssdServiceProvider"
Step 2: Register the Route
Add the USSD route to routes/api.php:
use Illuminate\Support\Facades\Route;
Route::post('/ussd', \Vendor\LaravelUssd\Http\Controllers\UssdController::class)
->middleware('ussd.normalize');
Step 3: Create the Welcome State
Create the initial state that users see when they dial the USSD code:
php artisan ussd:state WelcomeState
Now implement the WelcomeState:
<?php
namespace App\Ussd\States;
use Vendor\LaravelUssd\Support\AbstractState;
use Vendor\LaravelUssd\Support\Context;
use Vendor\LaravelUssd\Support\UssdResponse;
use Vendor\LaravelUssd\Menu\Menu;
class WelcomeState extends AbstractState
{
protected function buildMenu(Context $context): Menu
{
return (new Menu())
->text('Welcome to Money Transfer Service')
->line('')
->option('1', 'Send Money')
->option('2', 'Check Balance')
->option('3', 'Exit')
->expectsInput();
}
public function next(Context $context, string $input): ?string
{
$this->setContext($context);
return $this->decision($input)
->equal('1', SendMoneyState::class)
->equal('2', CheckBalanceState::class)
->equal('3', null) // End session
->any(WelcomeState::class); // Invalid input, show menu again
}
}
Step 4: Create the Send Money Flow
Create states for the money transfer flow. First, create the recipient state:
php artisan ussd:state SendMoneyState
php artisan ussd:state RecipientState
php artisan ussd:state AmountState
php artisan ussd:state ConfirmTransferState
Step 5: Implement SendMoneyState
This state initiates the transfer flow:
<?php
namespace App\Ussd\States;
use Vendor\LaravelUssd\Support\AbstractState;
use Vendor\LaravelUssd\Support\Context;
use Vendor\LaravelUssd\Support\UssdResponse;
use Vendor\LaravelUssd\Menu\Menu;
class SendMoneyState extends AbstractState
{
protected function buildMenu(Context $context): Menu
{
return (new Menu())
->text('Send Money')
->line('')
->text('Enter recipient phone number:')
->expectsInput();
}
public function next(Context $context, string $input): ?string
{
$this->setContext($context);
// Validate phone number and move to next state
return $this->decision($input)
->phoneNumber(RecipientState::class)
->any(SendMoneyState::class); // Invalid phone, try again
}
}
Step 6: Implement RecipientState
Store the recipient and ask for amount:
<?php
namespace App\Ussd\States;
use Vendor\LaravelUssd\Support\AbstractState;
use Vendor\LaravelUssd\Support\Context;
use Vendor\LaravelUssd\Support\UssdResponse;
use Vendor\LaravelUssd\Menu\Menu;
class RecipientState extends AbstractState
{
protected function buildMenu(Context $context): Menu
{
// Retrieve recipient from previous state
$recipient = $this->record->get('recipient', '');
return (new Menu())
->text("Recipient: {$recipient}")
->line('')
->text('Enter amount to send:')
->text('Minimum: 10')
->text('Maximum: 5000')
->expectsInput();
}
public function next(Context $context, string $input): ?string
{
$this->setContext($context);
// Store recipient from previous input
// Note: In a real app, you'd get this from the previous state's input
// For this example, we'll store it when transitioning
if (!$this->record->has('recipient')) {
// This would come from the previous state in a real implementation
$this->record->set('recipient', $context->data['previous_input'] ?? '');
}
// Validate amount
return $this->decision($input)
->amount(AmountState::class)
->between(10, 5000, AmountState::class)
->numeric(SendMoneyState::class) // Valid number but out of range
->any(SendMoneyState::class); // Invalid input
}
}
Actually, let's fix the flow. The recipient should be stored when we transition from SendMoneyState:
Step 7: Complete Flow Implementation
Here's the complete implementation with proper data flow:
SendMoneyState (Updated)
<?php
namespace App\Ussd\States;
use Vendor\LaravelUssd\Support\AbstractState;
use Vendor\LaravelUssd\Support\Context;
use Vendor\LaravelUssd\Support\UssdResponse;
use Vendor\LaravelUssd\Menu\Menu;
class SendMoneyState extends AbstractState
{
protected function buildMenu(Context $context): Menu
{
return (new Menu())
->text('Send Money')
->line('')
->text('Enter recipient phone number:')
->expectsInput();
}
public function next(Context $context, string $input): ?string
{
$this->setContext($context);
// Validate phone number using decision
$nextState = $this->decision($input)
->phoneNumber(RecipientState::class)
->any(SendMoneyState::class); // Invalid phone, show menu again
// If valid, store the recipient before transitioning
if ($nextState === RecipientState::class) {
$this->record->set('recipient', $input);
}
return $nextState;
}
}
RecipientState
<?php
namespace App\Ussd\States;
use Vendor\LaravelUssd\Support\AbstractState;
use Vendor\LaravelUssd\Support\Context;
use Vendor\LaravelUssd\Support\UssdResponse;
use Vendor\LaravelUssd\Menu\Menu;
class RecipientState extends AbstractState
{
protected function buildMenu(Context $context): Menu
{
$this->setContext($context);
$recipient = $this->record->get('recipient', '');
return (new Menu())
->text("Recipient: {$recipient}")
->line('')
->text('Enter amount to send:')
->text('Minimum: 10')
->text('Maximum: 5000')
->expectsInput();
}
public function next(Context $context, string $input): ?string
{
$this->setContext($context);
// Validate amount using decision chain
$nextState = $this->decision($input)
->amount(ConfirmTransferState::class)
->between(10, 5000, ConfirmTransferState::class)
->numeric(RecipientState::class) // Valid number but out of range
->any(RecipientState::class); // Invalid input
// If valid amount, store it before transitioning
if ($nextState === ConfirmTransferState::class) {
$this->record->set('amount', $input);
}
return $nextState;
}
}
ConfirmTransferState
<?php
namespace App\Ussd\States;
use Vendor\LaravelUssd\Support\AbstractState;
use Vendor\LaravelUssd\Support\Context;
use Vendor\LaravelUssd\Support\UssdResponse;
use Vendor\LaravelUssd\Menu\Menu;
class ConfirmTransferState extends AbstractState
{
protected function buildMenu(Context $context): Menu
{
$this->setContext($context);
// Retrieve stored data
$recipient = $this->record->get('recipient');
$amount = $this->record->get('amount');
return (new Menu())
->text('Confirm Transfer')
->line('')
->text("To: {$recipient}")
->text("Amount: {$amount}")
->line('')
->option('1', 'Confirm')
->option('2', 'Cancel')
->expectsInput();
}
public function next(Context $context, string $input): ?string
{
$this->setContext($context);
// Handle confirmation
if ($input === '1') {
// Call an action to process the transfer
// Actions are called (invoked) to perform business logic
$action = new \App\Ussd\Actions\ProcessTransferAction();
$result = $action->handle($context, $input);
// IMPORTANT: Return a STATE class name based on action result
// Never return the Action class name (e.g., ProcessTransferAction::class)
return $result['success'] ?? false
? TransferSuccessState::class // ✅ State class name
: TransferErrorState::class; // ✅ State class name
}
// Handle cancellation
if ($input === '2') {
return WelcomeState::class; // ✅ State class name
}
// Invalid input - show again
return ConfirmTransferState::class; // ✅ State class name
}
// Alternative using decision() helper (when you don't need to call actions):
// public function next(Context $context, string $input): ?string
// {
// $this->setContext($context);
//
// return $this->decision($input)
// ->equal('1', TransferSuccessState::class)
// ->equal('2', WelcomeState::class)
// ->any(ConfirmTransferState::class);
// }
}
TransferSuccessState
<?php
namespace App\Ussd\States;
use Vendor\LaravelUssd\Support\AbstractState;
use Vendor\LaravelUssd\Support\Context;
use Vendor\LaravelUssd\Support\UssdResponse;
use Vendor\LaravelUssd\Menu\Menu;
class TransferSuccessState extends AbstractState
{
protected function buildMenu(Context $context): Menu
{
$this->setContext($context);
$amount = $this->record->get('amount');
$recipient = $this->record->get('recipient');
return (new Menu())
->text('Transfer Successful!')
->line('')
->text("You sent {$amount} to {$recipient}")
->line('')
->text('Thank you for using our service.')
->expectsInput(false); // End session
}
public function next(Context $context, string $input): ?string
{
// This state doesn't expect input, so this shouldn't be called
return null;
}
}
Step 8: Using Actions for Business Logic
To process the transfer, create an Action to handle the business logic. Actions are called from states, not returned as navigation targets.
Create the ProcessTransferAction
php artisan ussd:action ProcessTransferAction
<?php
namespace App\Ussd\Actions;
use Vendor\LaravelUssd\Support\AbstractAction;
use Vendor\LaravelUssd\Support\Context;
class ProcessTransferAction extends AbstractAction
{
public function handle(Context $context, string $input): mixed
{
// Get transfer details from session
$record = new \Vendor\LaravelUssd\Support\Record($context);
$recipient = $record->get('recipient');
$amount = $record->get('amount');
// Perform the transfer (API call, database update, etc.)
// $result = $this->transferService->process($recipient, $amount);
// Return result that the state can use to determine next step
return [
'success' => true,
'transaction_id' => 'TXN123',
'message' => 'Transfer processed successfully'
];
}
}
Update ConfirmTransferState to Use the Action
Now update the ConfirmTransferState::next() method to call the action and return the appropriate State:
public function next(Context $context, string $input): ?string
{
$this->setContext($context);
if ($input === '1') {
// ✅ CORRECT: Call the action to perform business logic
$action = new \App\Ussd\Actions\ProcessTransferAction();
$result = $action->handle($context, $input);
// ✅ CORRECT: Return a STATE class name based on the result
// Never return ProcessTransferAction::class (that would be wrong!)
return ($result['success'] ?? false)
? TransferSuccessState::class // State class name
: TransferErrorState::class; // State class name
}
if ($input === '2') {
return WelcomeState::class; // Cancel - return to main menu
}
return ConfirmTransferState::class; // Invalid input - show again
}
Critical: Never Return Action Class Names
The next() method must return a State class name, never an Action class name. Common mistakes:
-
❌
return ProcessTransferAction::class;- Wrong! Returns Action, not State -
❌
return new ProcessTransferAction();- Wrong! Returns instance, not class name -
✅
$action->handle(); return TransferSuccessState::class;- Correct! Call action, return State
Step 9: Create CheckBalanceState
Create a simple balance check state:
<?php
namespace App\Ussd\States;
use Vendor\LaravelUssd\Support\AbstractState;
use Vendor\LaravelUssd\Support\Context;
use Vendor\LaravelUssd\Support\UssdResponse;
use Vendor\LaravelUssd\Menu\Menu;
class CheckBalanceState extends AbstractState
{
protected function buildMenu(Context $context): Menu
{
// In a real app, you'd fetch balance from database/API
$balance = 1000.00; // Example balance
return (new Menu())
->text('Account Balance')
->line('')
->text("Your balance: {$balance}")
->line('')
->text('Thank you for using our service.')
->expectsInput(false); // End session
}
public function next(Context $context, string $input): ?string
{
return null; // End session
}
}
Step 10: Update Configuration
Set the initial state in config/ussd.php:
return [
'initial_state' => 'App\\Ussd\\States\\WelcomeState',
// ... other configuration
];
Step 11: Testing the Flow
Test your USSD application by sending POST requests to your endpoint:
// Initial request (no input)
POST /api/ussd
{
"sessionId": "12345",
"msisdn": "233241234567",
"text": ""
}
// User selects option 1 (Send Money)
POST /api/ussd
{
"sessionId": "12345",
"msisdn": "233241234567",
"text": "1"
}
// User enters recipient phone
POST /api/ussd
{
"sessionId": "12345",
"msisdn": "233241234567",
"text": "233241234568"
}
// User enters amount
POST /api/ussd
{
"sessionId": "12345",
"msisdn": "233241234567",
"text": "100"
}
// User confirms transfer
POST /api/ussd
{
"sessionId": "12345",
"msisdn": "233241234567",
"text": "1"
}
Complete File Structure
Your final file structure should look like this:
app/
└── Ussd/
└── States/
├── WelcomeState.php
├── SendMoneyState.php
├── RecipientState.php
├── AmountState.php
├── ConfirmTransferState.php
├── TransferSuccessState.php
└── CheckBalanceState.php
routes/
└── api.php
config/
└── ussd.php
Key Concepts Demonstrated
States
Each screen in the USSD flow is a state. States handle menu display and user input processing.
Menus
Menus are built using the fluent Menu API to create user-friendly USSD interfaces.
Decisions
The Decision class validates user input and routes to the appropriate next state.
Records
Records store data across states, allowing you to collect information throughout the session.
Next Steps
- Add error handling and validation for edge cases
- Create Actions for business logic (e.g., ProcessTransferAction)
- Integrate with payment gateways or APIs
- Add session continuity for better user experience
- Write tests for your states and flows