Search by

korba / ussd-builder

korba

USSD application builder for Laravel 8-12 with session continuity

v1.0.5 2026-08-11 10:09 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 Menu builder
  • Simple switch-based routing between states
  • A Record store for collecting data across screens
  • Action classes 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:

PurposeAccepted request fields
Session IDsessionID, sessionId, session_id
Phone numbermsisdn, phoneNumber
User inputussdString, text
Service codeserviceCode

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.