Search by

pastweb / plugable

pastweb

Plugin managers, hook runners, and plugin-driven command systems

Package info

github.com/pastweb/plugable-php

Issues

pkg:composer/pastweb/plugable

Statistics

Installs: 6

Dependents: 0

Suggesters: 0

Stars: 0

v3.0.0 2026-08-20 13:23 UTC

This package is auto-updated.

Last update: 2026-10-27 09:52:14 UTC


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 on react/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 runCommand to 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(), and delay() match JS await / async on 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

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:

  1. Define commands with createCommand.
  2. Expose them from a plugin with createCommands($commandsInput, $definitions).
  3. Parse argv with getCommandsInput.
  4. Load plugins with loadPlugins.
  5. Run the selected command with runCommand.
  6. 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:

  1. loadPlugins calls each plugin commands($commandsInput, ...$args) factory.
  2. runCommand executes 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: PromiseInterface or any other value
    • Promises are unwrapped. Other values pass through, like JS await on a non-promise.

Returns

  • The fulfillment value, or $value when 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, and command() may return a PromiseInterface. 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() inside async() 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, and final.
    • The private names name, apply, and enforce cannot 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

  • args can 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 commands factory receives commands($commandsInput, 'sharedArg')
  • pluginA receives commands($commandsInput, 'sharedArg', 'pluginArg')
  • pluginB receives commands($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.
  • $pluginOrder: array
    • Grouped plugin hooks returned by loadPlugins.
  • $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, or post.

Returns

  • PromiseInterface<void>
    • Await with await(runPluginsHooks(...)).

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 as build/website.
  • commands: callable (optional)
    • Registers commands during loadPlugins.
  • 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

  • apply filters plugin hooks, not command registration.
  • commands registration is independent from enforce.
  • 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 commands factories and apply functions.
  • $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 by pre, default, and post.

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-like key:value pairs.
  • Numeric values are parsed only when the complete value is numeric. For example, 123 becomes a number, while 123abc remains 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 none command 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

  • createCommand does not change the raw command shape.
  • createCommands returns regular commands, so loadPlugins and runCommand keep working as before.
  • run($context) receives a command context array: commandName, options, commandsInput, args, metadata.
  • aliases registers runtime aliases. If build has alias b, then b --watch runs the same command and reads options from the b command 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, and Option::object create option definitions.
  • Option helpers support required, default, alias, aliases, and description.
  • 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' => true in createCommands options to preserve unknown parsed options.
  • Validation throws a structured CommandError and does not print, so callers decide how to report errors. The JS field code is errorCode in PHP because Exception::$code is an int.
  • Command help metadata is generated from name, description, aliases, examples, options, and args.
  • 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

  • array
    • commandName: selected semantic command segment.
    • options: options for the selected command segment.
    • commandsInput: full parsed command input.
    • args: values injected through the plugin commands hook.
    • 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.
  • $commandsInput: array
    • Parsed command input used to select the first command.

Returns

  • PromiseInterface<mixed>
    • true when the command returns null.
    • The command return value when it returns true or false.
    • false when no command is found. Await with await(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 as build <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. Use summary, details, args, options, and examples instead. 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: value lines.
  • printInfo defaults to cyan output.
  • printWarning defaults to yellow output.
  • printError defaults to red output.
  • printError exits the process with code 1 for 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