rmb32 / menu
A generic, JSON-driven CLI menu engine — drives any menu-shaped flow
Requires
- php: ^8.5
- ext-ds: ^2.0
- php-ds/php-ds: ^2.0
- symfony/console: ^7.0
Requires (Dev)
- deptrac/deptrac: ^4.7
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^12.0
- squizlabs/php_codesniffer: ^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
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":
textasks a free-text question and stores the answer.selectoffers a numbered list of choices supplied by anOptionSource. It can also offer fixed "create new" and "back" choices.repeatasks "add another?" and runs itsbodymenu each time the answer is yes.forEachruns itsbodymenu once for each item anOptionSourcesupplies, 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:
| Interface | Named in the JSON by | What it does |
|---|---|---|
OptionSource | optionsFrom, itemsFrom | Supplies the choices for a select, or the items for a forEach. |
TextValidator | validates | Rejects a typed answer with a message, and the question is asked again. |
DispatchAction | dispatches | Does 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.