nette/command-line
Nette Command Line is a lightweight PHP library for parsing command-line arguments, options, and commands. It offers automatic help generation, input validation, and a simple API for building CLI tools and console applications.
Install the package and create a single executable entry point. The whole point is that a plain PHP script becomes a self-documenting CLI tool with almost no ceremony.
composer require nette/command-line
chmod +x bin/ops
#!/usr/bin/env php
<?php
require __DIR__ . '/../vendor/autoload.php';
$console = new Nette\CommandLine\Console();
$console->addCommand('greet', 'Say hello to someone', function (array $args, array $options) use ($console): void {
$name = $args[0] ?? 'world';
$console->say("Hello, {$name}");
});
$console->run();
php bin/ops greet John
php bin/ops --help
php bin/ops greet --help
Look first at Nette\CommandLine\Console (addCommand(), run(), say()) and the README examples. The API surface is deliberately tiny: you register commands, each handler receives the positional arguments and the parsed options, and run() dispatches. Help output, usage errors, and ANSI color handling come from the package.
The first realistic use case in a Laravel app is replacing an ad-hoc php scripts/fix-something.php with a properly named subcommand that has --help, documented arguments, and sane error output.
One dispatcher, many commands. Keep a single bin/ops entry point and register each task. This gives you a discoverable tool instead of a directory of one-off scripts.
$console->addCommand('users:prune', 'Delete inactive users', function (array $args, array $options) use ($console): void {
$days = (int) ($options['days'] ?? 365);
$dryRun = isset($options['dry-run']);
$query = User::where('last_seen_at', '<', now()->subDays($days));
if ($dryRun) {
$console->say("Would delete {$query->count()} users.");
return;
}
$console->say("Deleted {$query->delete()} users.");
});
php bin/ops users:prune --days=180 --dry-run
Bootstrap Laravel before touching facades or Eloquent. A bin script does not get the framework for free.
$app = require __DIR__ . '/../bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
$console = new Nette\CommandLine\Console();
// ... register commands that use DB, config, facades ...
$console->run();
Keep handlers thin. The handler should parse/validate inputs, delegate to a service class, and report the result. That makes the business logic testable without spawning a process.
$console->addCommand('export:users', 'Export users to CSV', function (array $args, array $options) use ($console): void {
$path = $args[0] ?? null;
if ($path === null) {
$console->say('Usage: export:users <path> [--format=csv]');
exit(1);
}
$options += ['format' => 'csv']; // user-supplied value wins
$format = (string) $options['format'];
try {
app(UserExporter::class)->export($path, $format);
$console->say("Exported to {$path}");
} catch (Throwable $e) {
$console->say('Export failed: ' . $e->getMessage());
exit(1);
}
});
Handle argument validation yourself. There is no Symfony-style InputArgument::REQUIRED; check $args count and option values at the top of the handler and exit non-zero with a usage line.
Use it for operational tooling, not as an Artisan replacement. Good fits: deploy hooks, data migrations, backfills, cron jobs, local developer utilities, and small internal binaries. If the CLI is large, interactive, or needs command auto-discovery, use Artisan or Symfony Console instead.
Add a bin entry in composer.json so the tool is installed into vendor/bin and available without remembering the path:
{
"bin": ["bin/ops"]
}
Test through the process boundary. Because colors turn off when output is piped, assertions are stable:
$process = new Symfony\Component\Process\Process([PHP_BINARY, 'bin/ops', 'greet', 'John']);
$process->run();
$this->assertSame(0, $process->getExitCode());
$this->assertSame("Hello, John\n", $process->getOutput());
(array $args, array $options). The command name has already been consumed; $args contains only positional arguments.--option=value over --option value. The space-separated form can be ambiguous with positional arguments. Use = for values that start with -, and -- to stop option parsing when passing through arguments.isset($options['force']) is the safe check; do not assume $options['force'] === true.(int), filter_var(), allow-lists. A missing option is null/absent, not an exception.return from a handler does not set an exit code. If a cron job or CI step depends on failure, call exit(1) explicitly or throw and confirm how your version reports uncaught exceptions.say() lines for progress. For long-running workers that need SIGTERM, wire pcntl_signal() yourself.--help is generated from your command descriptions. Write short, accurate descriptions and include a usage line in the handler error path; otherwise the tool documents itself poorly.artisan from inside the script. After bootstrapping, use Artisan::call() or call the underlying service directly.echo with say(). Pick one output path.php bin/ops ... or a .bat shim; shebang execution is Unix-only.NOASSERTION; Nette projects are typically BSD-3-Clause, but verify the repository’s license file rather than trusting the registry entry.composer.json. The API is stable but minimal, and this is a niche package with a small maintainer/community footprint compared with Symfony Console.How can I help you explore Laravel packages today?