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