Search by

rmb32 / menu

rogerbarnfather

A generic, JSON-driven CLI menu engine — drives any menu-shaped flow

v1.0.2 2026-10-08 18:26 UTC

This package is auto-updated.

Last update: 2026-10-27 12:19:09 UTC


README

Build interactive command-line wizards from a JSON file instead of hand-written control flow.

A setup script or a "create a new thing" command is usually a chain of questions: ask something, check the answer, choose what to ask next, and maybe loop ("add another?"). Written by hand, that becomes a tangle of ifs and whiles. With Menu you describe each question as a named menu in a JSON file, saying where each answer goes and which menu comes next. Menu runs the flow: it prompts, validates, branches, loops and saves the answers. Your PHP code only supplies what the JSON can't: lists of choices, answer checks, and the work to do with the answers.

Requirements

  • PHP 8.5+
  • Symfony Console 7 (installed with the package; it reads the answers and prints the questions)

Installation

composer require rmb32/menu

Example: a project setup wizard

This wizard asks for a project name, a licence and any number of authors, then saves them:

Project name?

> my-project

Which licence for my-project?

[0] - MIT
[1] - Apache 2.0
[2] - Quit

> 1

Add an author?

[0] - Yes
[1] - No
[2] - Quit

> 0

Author's name?

> Ada Lovelace

Add an author?
...
> 1

Saving my-project (Apache-2.0).

The flow is described in project-setup.json:

{
  "name": {
    "type": "text",
    "prompt": "Project name?",
    "storeAs": "name",
    "validates": "slug",
    "on": { "type": "forward", "target": "licence" }
  },
  "licence": {
    "type": "select",
    "needs": ["name"],
    "prompt": "Which licence for <name>?",
    "optionsFrom": "licences",
    "storeAs": "licence",
    "target": "add-author"
  },
  "add-author": {
    "type": "repeat",
    "prompt": "Add an author?",
    "body": "author",
    "exitTarget": "save"
  },
  "author": {
    "type": "text",
    "prompt": "Author's name?",
    "appendTo": "authors",
    "on": { "type": "forward", "target": "add-author" }
  },
  "save": {
    "type": "text",
    "auto": true,
    "needs": ["name", "licence"],
    "optionalNeeds": ["authors"],
    "prompt": "Saving <name> (<licence>).",
    "storeAs": "saved",
    "dispatches": "saveProject",
    "on": { "type": "finish" }
  }
}

The three names the JSON refers to (slug, licences and saveProject) are small classes you write:

use Rmb32\Menu\Api\Contract\DispatchAction;
use Rmb32\Menu\Api\Contract\OptionSource;
use Rmb32\Menu\Api\Contract\TextValidator;
use Rmb32\Menu\Api\ValueObject\ValidationResult;

final class Slug implements TextValidator
{
    public function validate(string $raw, array $data): ValidationResult
    {
        return preg_match('/^[a-z0-9-]+$/', $raw) === 1
            ? ValidationResult::ok()
            : ValidationResult::fail('Use lower-case letters, digits and dashes.');
    }
}

final class Licences implements OptionSource
{
    public function options(array $data): array
    {
        return [
            ['label' => 'MIT', 'value' => 'MIT'],
            ['label' => 'Apache 2.0', 'value' => 'Apache-2.0'],
        ];
    }
}

final class SaveProject implements DispatchAction
{
    public function perform(array $data): array
    {
        file_put_contents('project.json', json_encode([
            'name' => $data['name'],
            'licence' => $data['licence'],
            'authors' => $data['authors'] ?? [],
        ], JSON_PRETTY_PRINT));

        return [];
    }
}

Then build the flow and run it from its first menu:

use Rmb32\Menu\Api\Factory\AskerFactory;
use Rmb32\Menu\Api\Factory\MenuEngineFactory;
use Rmb32\Menu\Api\Factory\StateMachineFactory;
use Rmb32\Menu\Api\ValueObject\AppState;

$answers = AppState::new();

$flow = new MenuEngineFactory()->create()->build(
    'project-setup.json',
    $answers,
    new AskerFactory()->create($input, $output), // a Symfony Console input and output
    optionSources: ['licences' => new Licences()],
    dispatchActions: ['saveProject' => new SaveProject()],
    textValidators: ['slug' => new Slug()],
);

$result = new StateMachineFactory()->create()->run($flow, 'name', $answers);

$result->finished;    // true: the flow reached {"type": "finish"}; false if the user chose Quit
$answers->toArray();  // ['name' => 'my-project', 'licence' => 'Apache-2.0', 'authors' => ['Ada Lovelace'], ...]

If a name in the JSON has no matching class, or a menu points at a menu that doesn't exist, build() throws a MenuGraphException before any question is asked.

How it works

The JSON file maps menu ids to menus. Each menu has a "type":

  • text asks a free-text question and stores the answer.
  • select offers a numbered list of choices supplied by an OptionSource. It can also offer fixed "create new" and "back" choices.
  • repeat asks "add another?" and runs its body menu each time the answer is yes.
  • forEach runs its body menu once for each item an OptionSource supplies, without asking.

Answers are kept in an AppState, under the key named by storeAs (or added to a list with appendTo). A menu sees only the keys it lists in needs (which must be set) and optionalNeeds (which may not be). Those keys can appear in its prompt as <key>, and they are all its OptionSource, TextValidator or DispatchAction receives.

Your code plugs in at three points:

InterfaceNamed in the JSON byWhat it does
OptionSourceoptionsFrom, itemsFromSupplies the choices for a select, or the items for a forEach.
TextValidatorvalidatesRejects a typed answer with a message, and the question is asked again.
DispatchActiondispatchesDoes the work with an answer, such as saving it. Whatever it returns is stored in the AppState.

A text menu can also route to different menus depending on what's already been answered. See the menu schema reference for every field, the routing rules and a second worked example.

Questions are printed to standard error, so standard output stays free for your command's own output. In a non-interactive session the first question throws a RuntimeException.

Used in Barn

This package is used in Barn. Learn about Barn.

More docs

Menu schema reference · Internals · History · Known issues

License

Proprietary. See LICENSE. Copyright (c) Roger Barnfather.