pastweb / plugable
Plugin managers, hook runners, and plugin-driven command systems
Requires
- php: >=8.1
- react/async: ^4.3
Requires (Dev)
- phpunit/phpunit: ^10.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A Composer-installable utility for building plugin managers, hook runners, and plugin-driven command systems.
plugable is inspired by the Vite plugin model, but it stays framework-neutral and small enough to use in CLIs, build tools, PHP-FPM, ReactPHP servers, or any system that needs public plugin contracts.
This package is the PHP port of @pastweb/plugable. Function names, hook order, command lifecycle, and the plugin contract match the TypeScript package.
Features
- Plugin manager primitives — Load plugins, group hooks by order, and run them consistently.
- Hook execution modes — Supports sequential, parallel, and waterfall hooks. Parallel uses the ReactPHP event loop.
- Async / await — The same async flow as
@pastweb/plugable:await(loadPlugins(...)),await(runCommand(...)),await(runPluginsHooks(...)). Built onreact/async. - Command registration — Plugins can expose command factories for CLI-like tools.
- Command lifecycle clarity — Command input is parsed once, passed to plugin command factories, and then used by
runCommandto select the command to execute. - Ordering controls — Use
'enforce' => 'pre' | 'post'to control plugin hook order. - Command-scoped hooks — Use
'apply'to run plugin hooks only for matching command input. - Platform-neutral — Plugins are associative arrays. No framework container is required.
- PHP 8.1+ — Namespaced functions, enums, and structured
CommandError.
Installation
composer require pastweb/plugable
PHP 8.1 or later is required. react/async is a hard dependency so await(loadPlugins(...)) matches the TypeScript package. Composer shares that package with pastweb/php-service when both are installed.
Documentation Overview
The documentation is organized around the main pieces needed to build a plugin manager.
- Basic usage — Parse command input, load plugins, run a command, and then run hooks.
- Command lifecycle — Understand the two-step command flow: plugin command factory first, command function later.
- Async —
await(),async(), anddelay()match JSawait/asyncon the ReactPHP loop. - Hooks — Define hook behavior and choose sequential, parallel, or waterfall execution.
- Plugins — Author plugin arrays with hook functions,
apply,enforce, and optional command factories. - Commands — Parse command input and register command definitions from plugins.
- API reference — Function signatures, parameters, returns, and examples for each exported primitive.
This project is distributed under the MIT license.
Command and hook helpers are not all in the root namespace. Import command helpers from Pastweb\Plugable\Command and hook helpers from Pastweb\Plugable\Hooks.
Summary
- Basic Usage
- Complete CLI Example
- Command Lifecycle
- When To Use Plugable
- Async
- Hooks
- Plugins
- Commands
- Printing Helpers
- License
Basic Usage
Use getCommandsInput to parse CLI input, loadPlugins to collect commands and hooks, runCommand to execute the selected command, and runPluginsHooks to run plugin hooks when the command allows it.
use function Pastweb\Plugable\await; use function Pastweb\Plugable\loadPlugins; use function Pastweb\Plugable\Command\getCommandsInput; use function Pastweb\Plugable\Command\runCommand; use function Pastweb\Plugable\Hooks\createHooks; use function Pastweb\Plugable\Hooks\runPluginsHooks; $commandsInput = getCommandsInput(array_slice($argv, 1)); $hooks = createHooks([ 'buildStart' => [ 'type' => 'sequential', ], ]); ['commands' => $commands, 'pluginsOrder' => $pluginsOrder] = await(loadPlugins([ // plugins ], ['hooks' => $hooks, 'commandsInput' => $commandsInput])); if (await(runCommand($commands, $commandsInput))) { await(runPluginsHooks($hooks, $pluginsOrder)); }
When command support is not needed, skip runCommand and run hooks directly.
use function Pastweb\Plugable\await; use function Pastweb\Plugable\loadPlugins; use function Pastweb\Plugable\Hooks\createHooks; use function Pastweb\Plugable\Hooks\runPluginsHooks; $hooks = createHooks([ 'setup' => [ 'type' => 'parallel', ], ]); ['pluginsOrder' => $pluginsOrder] = await(loadPlugins([ // plugins ], ['hooks' => $hooks])); await(runPluginsHooks($hooks, $pluginsOrder));
Complete CLI Example
This example shows the recommended command authoring flow:
- Define commands with
createCommand. - Expose them from a plugin with
createCommands($commandsInput, $definitions). - Parse argv with
getCommandsInput. - Load plugins with
loadPlugins. - Run the selected command with
runCommand. - Run hooks only when the command allows it.
use Pastweb\Plugable\Command\CommandError; use Pastweb\Plugable\Command\Option; use function Pastweb\Plugable\await; use function Pastweb\Plugable\loadPlugins; use function Pastweb\Plugable\Command\createCommand; use function Pastweb\Plugable\Command\createCommands; use function Pastweb\Plugable\Command\getCommandsInput; use function Pastweb\Plugable\Command\runCommand; use function Pastweb\Plugable\Hooks\createHooks; use function Pastweb\Plugable\Hooks\runPluginsHooks; $buildCommand = createCommand([ 'name' => 'build', 'description' => 'Build the project.', 'aliases' => ['b'], 'examples' => ['pastweb build --config=./config.php --watch'], 'options' => [ 'config' => Option::string([ 'alias' => '-c', 'default' => './config.php', 'description' => 'Path to the config file.', ]), 'watch' => Option::boolean([ 'description' => 'Keep the build running.', ]), ], 'run' => static function (array $context): bool { $options = $context['options']; $config = $options['config']; $watch = $options['watch'] ?? false; // run the build using config and watch return true; }, ]); $buildPlugin = [ 'name' => 'build-plugin', 'commands' => static fn (array $commandsInput, mixed ...$args): array => createCommands( $commandsInput, [$buildCommand], ['args' => $args], ), 'buildStart' => static function (): void { // run before or after the command, depending on your CLI flow }, ]; $commandsInput = getCommandsInput(array_slice($argv, 1)); $hooks = createHooks([ 'buildStart' => [ 'type' => 'sequential', ], ]); try { ['commands' => $commands, 'pluginsOrder' => $pluginsOrder] = await(loadPlugins( [$buildPlugin], ['hooks' => $hooks, 'commandsInput' => $commandsInput], )); if (await(runCommand($commands, $commandsInput))) { await(runPluginsHooks($hooks, $pluginsOrder)); } } catch (CommandError $error) { fwrite(STDERR, $error->getMessage() . PHP_EOL); exit(1); }
Command Lifecycle
Commands use a two-step lifecycle:
loadPluginscalls each plugincommands($commandsInput, ...$args)factory.runCommandexecutes the selected command function returned by that factory.
This means $commandsInput is available while commands are registered, not as a runtime argument passed to the returned command function. If a command needs parsed options, read them in the plugin commands factory and close over them in the returned command function.
$plugin = [ 'name' => 'example', 'commands' => static function (array $commandsInput): array { $buildOptions = $commandsInput['build'] ?? []; return [ 'build' => [ 'command' => static function () use ($buildOptions): void { if (!empty($buildOptions['watch'])) { // run build in watch mode } }, ], ]; }, ];
The command factory receives the complete parsed input, so a plugin can inspect the selected command and any sibling command segments before it returns command definitions.
Command input is semantic and segment-based. Every non-option token starts a new command segment, and options belong to the nearest command segment before them.
pastweb build --opt1 website --opt2
$commandsInput = getCommandsInput('build --opt1 website --opt2'); // [ // 'build' => [ // 'opt1' => true, // ], // 'website' => [ // 'opt2' => true, // ], // ]
In this model, website is not a positional argument of build. It is a semantic command segment with its own options.
Lifecycle steps:
| Step | Function | Purpose |
|---|---|---|
| 1 | getCommandsInput($argv) |
Parses CLI input into a commands-input array. |
| 2 | await(loadPlugins($plugins, ['hooks' => $hooks, 'commandsInput' => $commandsInput])) |
Registers plugin commands and groups plugin hooks. |
| 3 | commands($commandsInput, ...$args) |
Lets each plugin inspect parsed input and return command definitions. |
| 4 | await(runCommand($commands, $commandsInput)) |
Selects the first parsed command and runs its command() function. |
| 5 | await(runPluginsHooks($hooks, $pluginsOrder)) |
Runs plugin hooks when the command returns true or null. |
runCommand uses $commandsInput only to find the command name to execute. It does not pass $commandsInput to command().
Returning false from a command function tells the caller to stop before running plugin hooks.
if (await(runCommand($commands, $commandsInput))) { await(runPluginsHooks($hooks, $pluginsOrder)); }
Command registration and plugin hook filtering are separate. The plugin commands factory registers commands; the plugin apply property filters plugin hooks for the current command input.
$plugin = [ 'name' => 'docs-plugin', 'apply' => 'build', 'commands' => static fn (): array => [ 'docs' => [ 'command' => static function (): void { // This command is still registered even when the current input is not "build". }, ], ], 'buildStart' => static function (): void { // This hook only runs when apply matches the current command input. }, ];
When To Use Plugable
Use plugable directly when you are building a plugin contract, hook runner, build workflow, or CLI-like tool where commands are only one part of a larger extension system.
Use a full CLI framework or an adapter around one when you need batteries-included CLI features such as generated shell completion scripts, interactive prompts, deeply nested positional parsing, terminal UI, automatic command discovery, or mature help formatting out of the box.
plugable stays small on purpose: it gives you plugin loading, hook ordering, command registration, typed command helpers, and parser primitives without owning your whole CLI architecture.
Async
PHP has no async / await keywords. Plugable matches the JS package by returning ReactPHP promises from the same functions that are async in TypeScript. Unwrap them with await(), the same way JS uses await loadPlugins(...).
This uses Composer package react/async (and with it react/promise + react/event-loop). There is no extra PHP extension. Composer installs one copy for the whole app, so php-service and plugable share it.
| JS | PHP |
|---|---|
await loadPlugins(...) |
await(loadPlugins(...)) |
await runCommand(...) |
await(runCommand(...)) |
await runPluginsHooks(...) |
await(runPluginsHooks(...)) |
await createCli(...) |
await(createCli(...)) |
async () => { ... } |
async(static function () { ... }) |
Inside an async() fiber, await() suspends that fiber so the event loop can run other work (parallel hooks, other delay() calls). Outside a fiber (PHPUnit, Apache / nginx PHP-FPM) await() blocks the current process until the promise settles.
A long-running ReactPHP process (php-service standalone, or a CLI that keeps the loop running) is where overlapping I/O actually happens across tasks. Under FPM, await() still unwraps promises and can overlap I/O inside that one request (for example parallel hooks that delay() or return HTTP promises). It does not free the worker for other HTTP clients.
await
Unwraps a ReactPHP promise, or returns a non-promise value unchanged.
Syntax
await(mixed $value): mixed
Parameters
$value:PromiseInterfaceor any other value- Promises are unwrapped. Other values pass through, like JS
awaiton a non-promise.
- Promises are unwrapped. Other values pass through, like JS
Returns
- The fulfillment value, or
$valuewhen it is not a promise.
Example
use function Pastweb\Plugable\await; use function Pastweb\Plugable\loadPlugins; use function React\Promise\resolve; ['pluginsOrder' => $pluginsOrder] = await(loadPlugins([$plugin], ['hooks' => $hooks])); $row = await(resolve(['ok' => true]));
Notes
- Call
await()on every JS-async plugable API:loadPlugins,normalizeArgs,runPluginsHooks,runCommand,createCli. - Plugin hook functions, hook
callback/final, arg factories, andcommand()may return aPromiseInterface. Plugable awaits them. - A rejected promise throws. Catch it like any other exception.
async
Wraps a callable so it runs on a ReactPHP fiber and returns a promise. Use it when a hook or command calls await() or delay().
Syntax
async(callable $function): callable
Example
use function Pastweb\Plugable\async; use function Pastweb\Plugable\await; use function Pastweb\Plugable\delay; $plugin = [ 'name' => 'build-plugin', 'buildStart' => async(static function (): void { delay(0.05); $row = await(loadConfig()); }), ];
A hook may also return a promise directly, without async():
use function React\Promise\resolve; $plugin = [ 'name' => 'build-plugin', 'buildStart' => static fn () => resolve(null), ];
Use async() when the function body itself calls await() / delay().
delay
Suspends the current async() fiber for $seconds without blocking the event loop.
Syntax
delay(float $seconds): void
Example
use function Pastweb\Plugable\async; use function Pastweb\Plugable\delay; 'buildStart' => async(static function (): void { delay(0.1); }),
Notes
- Outside a fiber,
delay()blocks the current process until the timer fires. - Do not use
sleep()insideasync()hooks: it freezes the whole loop.
Hooks
Hooks describe named extension points that plugins can implement.
| Type | Execution | Behavior |
|---|---|---|
sequential |
sync or async | Runs each plugin hook in order with the configured args. Promises are awaited. |
parallel |
async | Starts all plugin hooks for the group on the event loop (React\Promise\all), then continues. |
waterfall |
sync or async | Runs hooks in order and passes each awaited return value into the next hook. |
Use Pastweb\Plugable\HookType (Sequential, Parallel, Waterfall) or the matching strings 'sequential', 'parallel', 'waterfall'.
createHooks
Normalizes hook definitions and fills default callbacks.
Syntax
function createHooks(array $hooks): array;
Parameters
$hooks:array- Object keyed by hook name.
- Each value can define
type,args,callback, andfinal. - The private names
name,apply, andenforcecannot be used as hook names.
Returns
array- The normalized hook definition array.
Example
use Pastweb\Plugable\HookType; use function Pastweb\Plugable\Hooks\createHooks; $hooks = createHooks([ 'buildStart' => [ 'type' => HookType::Sequential, 'args' => ['app'], ], 'resolveConfig' => [ 'type' => HookType::Waterfall, 'args' => static fn (): array => ['root' => getcwd()], ], ]);
Hook args
argscan be a single value, a list of values, or a function that returns either shape.- List values are spread into hook functions.
- Args functions are evaluated before the hook function runs.
- Waterfall hooks receive only the first normalized arg as the initial value.
The special commands hook config can pass extra args to plugin command factories.
$hooks = createHooks([ 'commands' => [ 'args' => 'sharedArg', 'pluginA' => ['pluginArg'], 'pluginB' => static fn (): array => ['lazyPluginArg'], ], ]);
In this example:
- every plugin
commandsfactory receivescommands($commandsInput, 'sharedArg') pluginAreceivescommands($commandsInput, 'sharedArg', 'pluginArg')pluginBreceivescommands($commandsInput, 'sharedArg', 'lazyPluginArg')
runPluginsHooks
Runs grouped plugin hooks by order: pre, default, then post.
Syntax
function runPluginsHooks( array $hooks, array $pluginOrder, string|array|null $includeHook = [], string|array|null $skipOrder = [], ): PromiseInterface;
Parameters
$hooks:array- Hook definitions returned by
createHooks.
- Hook definitions returned by
$pluginOrder:array- Grouped plugin hooks returned by
loadPlugins.
- Grouped plugin hooks returned by
$includeHook:string|string[]|null(optional)- Run only specific hook names. Runs all hooks when omitted.
$skipOrder:string|string[]|null(optional)- Skip one or more plugin order groups:
pre,default, orpost.
- Skip one or more plugin order groups:
Returns
PromiseInterface<void>- Await with
await(runPluginsHooks(...)).
- Await with
Example
await(runPluginsHooks($hooks, $pluginsOrder)); await(runPluginsHooks($hooks, $pluginsOrder, ['buildStart'])); await(runPluginsHooks($hooks, $pluginsOrder, null, ['post']));
Plugins
Plugins are associative arrays with a required name and optional hook functions, apply, enforce, and commands.
Plugin Object
[
'name' => 'string',
'apply' => 'build' | ['build', 'serve'] | static fn (array $commandsInput): bool,
'commands' => static fn (array $commandsInput, mixed ...$args): array,
'enforce' => 'pre' | 'post',
// plus named hook callables
]
Properties
name:string- Unique plugin name.
apply:string | string[] | callable(optional)- Filters plugin hooks for specific command input.
- String values can use
/for nested command matching, such asbuild/website.
commands:callable(optional)- Registers commands during
loadPlugins.
- Registers commands during
enforce:'pre' | 'post'(optional)- Places hook functions before or after default-order plugins.
Example
$plugin = [ 'name' => 'docs', 'apply' => 'build/docs', 'enforce' => 'post', 'commands' => static function (array $commandsInput): array { $docsOptions = $commandsInput['docs'] ?? []; return [ 'docs' => [ 'command' => static function () use ($docsOptions): ?bool { if (!empty($docsOptions['help'])) { return false; } return true; }, ], ]; }, 'buildStart' => static function (mixed $root): void { // plugin hook }, ];
Notes
applyfilters plugin hooks, not command registration.commandsregistration is independent fromenforce.- If two plugins register the same command name, the later duplicate is registered as
<pluginName>:<commandName>. - Multi-stage plugins can be represented as an array of plugin arrays.
Authoring Plugin Commands
Prefer createCommand and createCommands when a plugin exposes commands.
createCommand defines a reusable command description. createCommands belongs inside the plugin commands($commandsInput, ...$args) factory and converts command definitions into the raw commands array expected by loadPlugins.
Single-command plugin
use Pastweb\Plugable\Command\Option; use function Pastweb\Plugable\Command\createCommand; use function Pastweb\Plugable\Command\createCommands; $buildCommand = createCommand([ 'name' => 'build', 'description' => 'Build the project.', 'options' => [ 'config' => Option::string([ 'alias' => '-c', 'default' => './config.php', 'description' => 'Path to the config file.', ]), 'watch' => Option::boolean([ 'description' => 'Keep the build running.', ]), ], 'run' => static function (array $context): void { $options = $context['options']; $options['config']; $options['watch'] ?? false; }, ]); $buildPlugin = [ 'name' => 'build-plugin', 'commands' => static fn (array $commandsInput): array => createCommands($commandsInput, [ $buildCommand, ]), ];
Multi-command plugin
$previewCommand = createCommand([ 'name' => 'preview', 'description' => 'Preview the project.', 'options' => [ 'port' => Option::number([ 'default' => 3000, 'description' => 'Preview server port.', ]), ], 'run' => static function (array $context): void { $context['options']['port']; }, ]); $projectPlugin = [ 'name' => 'project-plugin', 'commands' => static fn (array $commandsInput): array => createCommands($commandsInput, [ $buildCommand, $previewCommand, ]), ];
Plugin-specific injected command args
$hooks = createHooks([ 'commands' => [ 'args' => ['workspace'], 'build-plugin' => ['build-cache'], ], ]); $buildPlugin = [ 'name' => 'build-plugin', 'commands' => static function (array $commandsInput, mixed ...$args) use ($buildCommand): array { return createCommands($commandsInput, [ $buildCommand, ], [ 'args' => $args, ]); }, ];
Inside run($context), injected values are available as $context['args'].
Raw command arrays remain supported. Use them when you need full manual control, and use createCommand when you want option helpers, generated help metadata, and validation.
loadPlugins
Loads plugin commands and groups plugin hooks by order.
Syntax
function loadPlugins(array $plugins, array $options = []): PromiseInterface;
Parameters
$plugins:array- Mandatory plugin arrays or arrays of plugin arrays.
$options['hooks']:array(optional)- Hook definitions.
$options['commandsInput']:array(optional)- Parsed command input passed to plugin
commandsfactories andapplyfunctions.
- Parsed command input passed to plugin
$options['includePluginCommands']:string|string[](optional)- Include command factories only from matching plugin names.
Returns
PromiseInterface<array{commands: array, pluginsOrder: array}>commands: collected command definitions.pluginsOrder: hook functions grouped bypre,default, andpost.
Example
['commands' => $commands, 'pluginsOrder' => $pluginsOrder] = await(loadPlugins( [$plugin], [ 'hooks' => $hooks, 'commandsInput' => $commandsInput, ], ));
groupPlugins
Groups plugins by enforce order without loading command definitions.
Syntax
function groupPlugins(array $plugins, array $commandsInput = []): array;
Returns
array{pre: array, default: array, post: array}
applyPlugin
Checks whether a plugin should apply to the current command input.
Syntax
function applyPlugin(string|array|callable|null $apply, array $commandsInput): bool;
Example
applyPlugin('build/site', [ 'build' => [], 'site' => [], ]); // true
Commands
Commands are registered by plugin commands factories and executed by runCommand.
The recommended API is createCommand plus createCommands. It keeps command authoring structured, generates help metadata, validates options, and still returns the raw commands array used by the rest of the library.
Use raw command arrays only when you need full manual control over command registration and execution.
getCommandsInput
Parses a command-line string or argv array into a semantic commands-input array. Each non-option token creates a command segment, and each option is attached to the closest previous command segment.
Syntax
function getCommandsInput(string|array $argv = []): array;
Parameters
$argv:string|string[](optional) (default:[])- Command-line input as a string or token array.
Returns
array- Array keyed by command name, with parsed options as values.
Example
myCli build --config=./config.php --env=[ true, mpa, 1 ] website --static
$commandInput = getCommandsInput( 'build --config=./config.php --env=[ true, mpa, 1 ] website --static', ); // [ // 'build' => [ // 'config' => './config.php', // 'env' => [true, 'mpa', 1], // ], // 'website' => [ // 'static' => true, // ], // ]
Parsing rules
- Options start with
--or-. - Options without values resolve to
true. - Every non-option token starts a new command segment.
- Options belong to the nearest command segment before them.
- Positional arguments are intentionally not represented by the raw parser.
- Quoted string values can contain spaces.
- Values after
=can be strings, numbers, booleans, arrays, or object-likekey:valuepairs. - Numeric values are parsed only when the complete value is numeric. For example,
123becomes a number, while123abcremains a string. - Values can contain additional
=characters after the first separator. - Arrays and objects are not nestable.
- Unclosed arrays, unclosed objects, and malformed object entries throw parse errors.
- Repeated options are overwritten by the latest value.
- If the first input item is an option, options are stored under the
nonecommand key.
createCommand / createCommands
Defines commands and binds them to the raw command lifecycle.
Use createCommand to describe a command and createCommands inside a plugin commands($commandsInput, ...$args) factory to turn those definitions into the raw commands array consumed by runCommand.
Syntax
function createCommand(array $definition): array; function createCommands(array $commandsInput, array $commandDefinitions, array $options = []): array;
Example
use Pastweb\Plugable\Command\Option; use function Pastweb\Plugable\Command\createCommand; use function Pastweb\Plugable\Command\createCommands; $buildCommand = createCommand([ 'name' => 'build', 'description' => 'Build the project.', 'aliases' => ['b'], 'examples' => ['pastweb build --watch'], 'options' => [ 'config' => Option::string([ 'alias' => '-c', 'default' => './config.php', 'description' => 'Path to the config file.', ]), 'watch' => Option::boolean([ 'description' => 'Keep the build running.', ]), ], 'run' => static function (array $context): void { $options = $context['options']; $options['config']; $options['watch'] ?? false; }, ]); $plugin = [ 'name' => 'build-plugin', 'commands' => static function (array $commandsInput, mixed ...$args) use ($buildCommand): array { return createCommands($commandsInput, [ $buildCommand, ], [ 'args' => $args, ]); }, ];
Notes
createCommanddoes not change the raw command shape.createCommandsreturns regular commands, soloadPluginsandrunCommandkeep working as before.run($context)receives a command context array:commandName,options,commandsInput,args,metadata.aliasesregisters runtime aliases. Ifbuildhas aliasb, thenb --watchruns the same command and reads options from thebcommand segment.- Option defaults are applied to
$context['options']when the parsed input does not provide a value. Option::string,Option::number,Option::boolean,Option::array, andOption::objectcreate option definitions.- Option helpers support
required,default,alias,aliases, anddescription. - Option defaults are validated against their declared type.
- Required options and option value types are validated before
run($context)executes. - Unknown options are rejected by default when a command has an explicit option schema.
- Set
'allowUnknownOptions' => trueincreateCommandsoptions to preserve unknown parsed options. - Validation throws a structured
CommandErrorand does not print, so callers decide how to report errors. The JS fieldcodeiserrorCodein PHP becauseException::$codeis an int. - Command help metadata is generated from
name,description,aliases,examples,options, andargs. - Raw command arrays are still supported for backward compatibility.
createCommandContext
Builds a command registration context from parsed command input.
Syntax
function createCommandContext(array $commandsInput, array $options = []): array;
Returns
arraycommandName: selected semantic command segment.options: options for the selected command segment.commandsInput: full parsed command input.args: values injected through the plugincommandshook.metadata: optional command help metadata.
createCli
Runs the common parse, load, command, and hook flow in one optional helper.
Use this when you want a small batteries-included runner. Keep using getCommandsInput, loadPlugins, runCommand, and runPluginsHooks directly when your CLI needs custom ordering, error handling, or lifecycle decisions.
Syntax
function createCli(array $options): PromiseInterface;
Example
use function Pastweb\Plugable\await; use function Pastweb\Plugable\Command\createCli; $result = await(createCli([ 'hooks' => $hooks, 'plugins' => [$buildPlugin], 'argv' => array_slice($argv, 1), ])); if ($result['commandResult']) { // Command succeeded and hooks already ran. }
Set 'runHooks' => false when you want createCli to load plugins and execute the command but leave hook execution to your application.
createCommandManifest
Creates a machine-readable command manifest from createCommand definitions.
Use this optional helper for documentation generation, static docs pages, schema exports, or tests that need a serializable view of command definitions. The manifest does not include run functions.
Syntax
function createCommandManifest(array $commandDefinitions): array;
createCompletionMetadata
Creates shell-agnostic completion metadata from createCommand definitions.
This helper intentionally does not generate Bash, Zsh, Fish, or PowerShell scripts. It returns structured metadata that an adapter can turn into the shell format your CLI supports.
Syntax
function createCompletionMetadata(array $commandDefinitions): array;
runCommand
Runs the first command found in $commandsInput.
Syntax
function runCommand(array $commands, array $commandsInput): PromiseInterface;
Parameters
$commands:array- Command definitions returned by
loadPlugins.
- Command definitions returned by
$commandsInput:array- Parsed command input used to select the first command.
Returns
PromiseInterface<mixed>truewhen the command returnsnull.- The command return value when it returns
trueorfalse. falsewhen no command is found. Await withawait(runCommand(...)).
Example
$shouldRunHooks = await(runCommand($commands, $commandsInput)); if ($shouldRunHooks) { await(runPluginsHooks($hooks, $pluginsOrder)); }
Advanced Raw Commands
Raw command arrays are still supported. They are useful when a plugin wants to own parsing, validation, help metadata, or command execution manually.
$plugin = [ 'name' => 'raw-build-plugin', 'commands' => static function (array $commandsInput): array { $buildOptions = $commandsInput['build'] ?? []; return [ 'build' => [ 'help' => [ 'commandName' => 'build', 'summary' => 'Build the project.', ], 'command' => static function () use ($buildOptions): bool { if (!empty($buildOptions['watch'])) { // run in watch mode } return true; }, ], ]; }, ];
Raw command callbacks receive no arguments. Read parsed input in the plugin commands($commandsInput, ...$args) factory and close over the values you need.
Raw commands do not get createCommand option validation or generated option help. Prefer createCommand for new plugin commands unless you need this lower level control.
Printing Helpers
plugable includes small CLI-oriented printing helpers for common command output.
Colors follow the JS picocolors names (red, yellow, cyan, whiteBright, …). They are applied only when stdout is a TTY, unless FORCE_COLOR is set. NO_COLOR disables them.
printHelp
Prints command help metadata.
Syntax
function printHelp(array $help, ?array $colors = null, ?string $header = null): void;
Example
use function Pastweb\Plugable\printHelp; printHelp([ 'commandName' => 'build <target>', 'summary' => 'Build a project target.', 'aliases' => ['b'], 'args' => [ [ 'name' => '<target>', 'description' => 'Target to build.', 'required' => true, ], ], 'options' => [ [ 'name' => '--config <path>', 'aliases' => ['-c'], 'description' => 'Path to the config file.', 'default' => './config.php', ], [ 'name' => '--watch', 'description' => 'Keep the build running.', ], ], 'examples' => [ [ 'command' => 'pastweb build website --watch', 'description' => 'Build the website target in watch mode.', ], ], ]);
Help metadata
commandName: command usage line, such asbuild <target>.summary: short command description.details: longer description as a string or string array.aliases: command aliases.args: documented positional arguments for generated help output.options: documented command options for generated help output.examples: example command usages.description: deprecated legacy description field. Usesummary,details,args,options, andexamplesinstead. It is still rendered for backward compatibility during the metadata migration.
printInfo / printWarning / printError
Prints formatted messages for info, warning, and error output.
Syntax
function printInfo(mixed $info, ?string $color = null, ?string $header = null): void; function printWarning(mixed $warning, ?string $color = null, ?string $header = null): void; function printError(mixed $error, ?string $color = null, ?string $header = null): void;
Notes
- Object and array messages are rendered as nested
key: valuelines. printInfodefaults to cyan output.printWarningdefaults to yellow output.printErrordefaults to red output.printErrorexits the process with code1for non-object errors.
Tests can replace Pastweb\Plugable\Io::$write and Pastweb\Plugable\Io::$exit so print helpers do not write to the real stdout or call exit().
License
MIT License (c) 2026 Domenico Pasto