pmjones/auto-shell
AutoShell maps CLI command names to PHP command classes in a chosen namespace, reflecting on each class's main method to build argument and option values. Dependency-free and low-maintenance, it acts as the router for your console commands, returning an Exec object to invoke.
AutoShell is a convention-over-configuration CLI router: one PHP class per command, discovered by filesystem scan of a directory, parsed from $_SERVER['argv'], invoked with reflected arguments. There is no registration step, no service provider, no config file to learn. If you can write a class with __invoke(), you can ship a command.
Three files to get moving. First, install:
composer require pmjones/auto-shell
Second, create bin/console.php:
<?php
use AutoShell\Console;
require dirname(__DIR__) . '/vendor/autoload.php';
$console = Console::new(
namespace: 'Project\Cli\Command',
directory: dirname(__DIR__) . '/src/Cli/Command',
help: 'The console for my Project.' . PHP_EOL . PHP_EOL,
);
exit($console($_SERVER['argv']));
Third, drop a command class into that directory — nothing else:
<?php
namespace Project\Cli\Command;
class Hello
{
public function __invoke(string $name) : int
{
echo "Hello {$name}" . PHP_EOL;
return 0;
}
}
Now php bin/console.php hello world prints Hello world. That is the whole onboarding: install, wire a Console with namespace + directory, add a class.
Where to look first after that. Run php bin/console.php with no arguments — you get the command roster plus the resolved namespace and directory paths, which is the fastest way to debug a naming mismatch. Then run php bin/console.php help hello for the generated man page. Those two invocations are your primary feedback loop for the rest of your time with this package.
Your first real use case is almost always "take a script I currently run as php scripts/import.php --dry-run and make it a class." Create src/Cli/Command/Import.php with an int return, put the --dry-run flag in an Options class (see below), and delete the old script. The conversion is usually under ten minutes per command because the package owns all the argument parsing you had hand-rolled.
One class per command, named after the command. Command names map to PSR-4 class names: dash-separated words become CamelCase (create-article → CreateArticle) and colons become namespace separators (schema:dump → Schema\Dump). This is the single most important convention to internalize — it means your src/Cli/Command tree is your command namespace, and adding a subdirectory silently adds a command group. Interfaces, traits, abstract classes, and Options implementations are ignored by the scanner, so it is safe to keep shared base classes and helpers in the same directory.
Options as a marker interface plus attributes. To give a command flags, write a class implementing AutoShell\Options with constructor-promoted, nullable, ideally readonly properties, each carrying an #[Option] attribute:
class ImportOptions implements Options
{
public function __construct(
#[Option('d,dry-run', help: 'Parse and validate only.')]
public readonly ?bool $dryRun,
#[Option('f,format', mode: Option::VALUE_REQUIRED, default: 'csv')]
public readonly ?string $format,
#[Option('v,verbose', multiple: true)]
public readonly ?array $verbose,
) {}
}
Then typehint it as a parameter on the method:
public function __invoke(ImportOptions $options, string $file) : int
You do not parse argv yourself, you do not branch on flag presence, and you get type casting for free. VALUE_REJECTED (the default) means "flag only"; VALUE_REQUIRED forces --format=csv; VALUE_OPTIONAL permits both bare and valued forms. With multiple: true you collect repeats into an array — and note the special case in the README: VALUE_REJECTED combined with an int property gives you a count of occurrences, which is exactly what you want for -vvv verbosity ladders.
Compose a shared CommonOptions for cross-cutting flags. --verbose, --quiet, --no-interaction, --env belong on every command. Declare them once in a CommonOptions implements Options class and add it as the first parameter alongside the per-command options:
public function __invoke(CommonOptions $common, ImportOptions $options) : int
The constraint to remember: an option name may not be defined in an earlier options parameter and again in a later one — doing so throws OptionAlreadyDefined. Plan your flag namespace once, up front.
Wire a container factory as soon as you have two commands that share a service. The default new instantiation gets you started but cannot inject a database connection. Pass a factory callable:
$console = Console::new(
namespace: 'Project\Cli\Command',
directory: dirname(__DIR__) . '/src/Cli/Command',
factory: fn (string $class) => $container->get($class),
);
This is the natural integration point with any PSR-11 container — Laravel's, Symfony's, PHP-DI, whatever you already have. Because the factory receives the resolved fully-qualified class name, your container needs zero CLI-specific registration; autowiring handles it. Note the Console creates its own internal help classes directly and never routes them through your factory, so keep help generation dependency-free.
Return integers, and return them honestly. The main method's int return becomes the process exit code. 0 is success; anything else is failure. Wire this into your shell scripts and CI directly — php bin/console.php schema:dump || exit 1 works as expected. The README points at the FreeBSD sysexits(3) conventions; using 64 for usage errors and 69 for unavailable services costs nothing and makes scripts far more debuggable than a wall of 1s.
Use array and bool typehints to skip manual parsing. Argument typehints of int, float, string, bool, mixed, and array are all automatically cast. bool accepts 1/t/true/y/yes and 0/f/false/n/no case-insensitively; array runs str_getcsv() so php bin/console.php import a,b,c hands your command ['a','b','c'] with no splitting code. Trailing variadic parameters work too, which is the clean way to accept "one or more files."
Treat help text as part of the interface, not an afterthought. #[Help] goes on the class (synopsis line) and on each main-method parameter (argument description), and takes an optional second argument for long-form text with light *bold* / _underline_ markup. Write it at the same time you write the command — the man page is generated from these attributes, so a command with no #[Help] is a command nobody else on the team will use correctly.
Name-suffix your command classes if the bare names collide with your domain. Passing suffix: 'Command' makes the Console look for CreateArticleCommand while still exposing the command as create-article. Likewise method: 'exec' retargets the reflected main method away from __invoke(). Both are escape hatches for integrating with an existing codebase; reach for them only when the defaults genuinely conflict.
Redirect output when you need testability. stdout and stderr are injectable callables. In tests, pass closures that append to a buffer, invoke the Console with a synthetic argv array, and assert on both the buffer contents and the returned exit code. This is the only sane way to test CLI help output, and it is why the injection point exists.
Options properties must be nullable. This is not stylistic — AutoShell signals "the user did not pass this flag" by assigning null. A non-nullable bool property will blow up on every invocation where the flag is absent, which is most of them. Write ?bool, ?string, ?array and let the command logic handle the null case. The README recommends readonly as good practice but does not require it; readonly plus no default is the safest combination.
The command name and the class name are two different strings and they must agree. The most common first-day error is Class not found because someone wrote class CreateArticle in a file whose command is spelled create_article or Create-Article. Dash-separated words become CamelCase, colons become namespace separators, and nothing else is transformed. If a command "doesn't exist," check the filename/classname mapping before you check anything else.
A silently missing command is usually a scanner exclusion, not a bug. The Shell skips interfaces, traits, and abstract classes. If you put genuinely shared logic in an abstract BaseCommand inside the command directory and wonder why base-command never shows up — that is by design. The flip side is that you can safely keep abstractions there without polluting the roster.
OptionAlreadyDefined is a design-time error, not a runtime edge case. It fires because two Options parameters declare the same short or long name. Since the rule is directional (later parameters may not redefine earlier ones), ordering CommonOptions first means per-command options are the ones that get rejected. Resolve collisions by removing the flag from the command-specific class, not by reordering parameters — reordering just moves the exception.
Cast semantics for bool are permissive and worth knowing exactly. yes, y, t, 1, true become true; no, n, f, 0, false become false, all case-insensitively. Anything else is a parse error surfaced on STDERR. If you have users who habitually type --force=enabled, they will get an error, not true — document --force as a bare flag with VALUE_REJECTED and sidestep the ambiguity entirely.
str_getcsv() for array arguments means commas are structural. You cannot pass a,"b,c" and expect two elements unless you quote properly — it is real CSV parsing, which is a feature, but it also means a file named report,final.csv will arrive as two elements. For paths, prefer a variadic string ...$files parameter over a comma-separated array.
Debug from the output of the bare console invocation. Running with no arguments prints your resolved namespace, directory, and the command roster. Nine out of ten "why isn't my command showing up" cases are solved by reading those two paths and confirming they match your filesystem exactly — a trailing-slash or case mismatch on a case-sensitive filesystem will silently yield zero commands rather than an error.
exit($code) in the console script, always. If you forget to plumb the Console's return value into exit(), every command reports success to the shell and your CI pipelines will pass on failure. Keep the console script a three-line file with no logic so there is nothing to get wrong.
The extension points are deliberately few, and that is the point. Factory for construction, suffix/method for naming, stdout/stderr for I/O, attributes for help and options. There is no plugin system, no middleware, no event dispatcher. When you want cross-cutting behavior — logging every invocation, timing commands, enforcing a --env guard — implement it in the console script around the $console(...) call, or wrap command construction in the factory. Fighting this grain by trying to build middleware inside AutoShell will cost you more than it saves; the package is a router, and routers route.
Version pin loosely but read the changelog. The package advertises itself as dependency-free and low-maintenance, and the API surface is small enough that breaking changes are rare and usually mechanical. Still, #[Option] and #[Help] are attribute-based, so the PHP version floor matters — confirm your runtime supports attributes (PHP 8.0+) before adopting, and check the changelog on minor bumps since the option mode constants and casting rules are the most likely places for behavior drift.
How can I help you explore Laravel packages today?