sebastian/cli-parser
sebastian/cli-parser is a lightweight PHP library for parsing $_SERVER['argv'], extracted from PHPUnit. It gives command-line tools a simple way to read and validate arguments and options without a full CLI framework.
Install (declare it, don't free-ride). PHPUnit already pulls this in transitively in most Laravel apps, so vendor/sebastian/cli-parser may already exist before you ever run:
composer require sebastian/cli-parser
# or, if only bin/ scripts and local tooling use it:
composer require --dev sebastian/cli-parser
Do not rely on the transitive copy — PHPUnit's constraint can change under you on a composer update.
Where to look first. The README is badges only; there is no usage documentation. Your real documentation is:
vendor/sebastian/cli-parser/src/Parser.php — one small file, the entire public surface (Parser::parse()).vendor/sebastian/cli-parser/src/Exceptions/ — UnknownOptionException, AmbiguousOptionException, RequiredOptionArgumentMissingException, OptionDoesNotAcceptArgumentException, all under the SebastianBergmann\CliParser\Exception hierarchy.phpunit/phpunit's src/TextUI/Application.php — the battle-tested, real-world call site. If you are unsure how a spec string behaves, copy PHPUnit's.First use case — a bin/ script that must not boot Laravel. The whole point of this package in a Laravel codebase is startup cost: it lets you interpret arguments before (or entirely without) the framework, the container, or Symfony Console.
#!/usr/bin/env php
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use SebastianBergmann\CliParser\Exception;
use SebastianBergmann\CliParser\Parser;
try {
$options = (new Parser)->parse(
$_SERVER['argv'],
[
'env=', // --env=staging (trailing '=' => requires a value)
'force', // --force (no '=' => boolean flag)
'dry-run',
'path=',
],
'f' // short options string; '-' prefixed results come back as '-f'
);
} catch (Exception $e) {
fwrite(STDERR, $e->getMessage() . PHP_EOL);
exit(1);
}
// [['--env', 'staging'], ['--force', null], ['--path', '/tmp']]
$opts = collect($options)->mapWithKeys(fn (array $o) => [ltrim($o[0], '-'), $o[1]]);
$env = $opts->get('env', 'production');
$dryRun = $opts->has('force') ? false : (bool) $opts->has('dry-run');
That is the entire mental model: a spec of long options plus a short-option string in, a list of [option-with-dashes, value-or-null] tuples out.
Sanity-check the spec syntax once, in your own codebase. The =/: suffix conventions are the whole contract and they are not documented in the README. Write one throwaway test per option spec (--flag, --opt=value, --opt value, -f, bundled shorts) and assert the tuples. Five minutes here prevents a silent mis-parse in a deploy script.
Parser is final and has no interface, so mocking it is impossible. Wrap it once:
namespace App\Support\Cli;
use SebastianBergmann\CliParser\Parser;
final class RawArgs
{
/** @param list<string> $argv */
public static function parse(array $argv, array $long, string $short = ''): array
{
return (new Parser)->parse($argv, $long, $short);
}
/** @param list<array{0: string, 1: string|null}> $tuples */
public static function toMap(array $tuples): array
{
$map = [];
foreach ($tuples as [$name, $value]) {
$map[ltrim($name, '-')] = $value ?? true;
}
return $map;
}
}
Bind it in a service provider and your application code only ever deals with array{name: string|true} — swappable later for Symfony's ArgvInput if the tool grows up.
Deciding which environment or config to load happens before the kernel exists, so Artisan and Symfony Console are not available yet. Put this in a bootstrap file or a bin/ entrypoint:
// bootstrap/cli-context.php
$flags = RawArgs::toMap(RawArgs::parse($_SERVER['argv'], ['env=', 'no-cache']));
$envFile = match ($flags['env'] ?? null) {
'staging' => '.env.staging',
'local' => '.env.local',
default => '.env',
};
Dotenv::createImmutable(base_path(), $envFile)->safeLoad();
Keep the option list tiny and stable — you are now maintaining an argument grammar outside the framework's validation and --help.
Useful when your command is a thin wrapper that forwards flags to another binary (PHPUnit, Pest, a node script). Symfony Console owns the outer grammar; cli-parser owns the tail after --:
public function handle(): int
{
$argv = $_SERVER['argv'];
$sep = array_search('--', $argv, true);
$tail = $sep === false ? [] : array_slice($argv, $sep); // keep '--' as argv[0]
$forwarded = RawArgs::parse($tail, ['filter=', 'testsuite=', 'stop-on-failure'], 'd');
$this->line('Forwarding: ' . json_encode($forwarded));
// exec/shell_exec the underlying binary with the reconstructed argv...
return self::SUCCESS;
}
Draw the boundary explicitly and document it. Two parsers in one command is a debugging hazard the moment someone adds --filter to the Artisan signature as well.
Never read $_SERVER['argv'] in tests. Feed fabricated argv:
it('parses the deploy spec', function () {
$tuples = RawArgs::parse(
['deploy', '--env=staging', '--force', '--path', '/tmp'],
['env=', 'force', 'path=']
);
expect(RawArgs::toMap($tuples))->toBe(['env' => 'staging', 'force' => true, 'path' => '/tmp']);
});
This is the cheapest way to pin down the spec syntax, and it doubles as the documentation the upstream README does not provide.
try {
$opts = RawArgs::toMap(RawArgs::parse($argv, $long, $short));
} catch (\SebastianBergmann\CliParser\UnknownOptionException $e) {
$this->error($e->getMessage()); // "Unknown option --envr" + a "Did you mean?" hint
return self::FAILURE;
} catch (\SebastianBergmann\CliParser\Exception $e) {
$this->error($e->getMessage());
return self::INVALID;
}
Since 4.1/4.2 the exceptions suggest the most similar known option, which is genuinely good UX for internal tooling — surface the message verbatim rather than replacing it.
bin/ scripts shipped with the app or a package → require.require-dev.--help generation, InputOption validation, prompts, autocompletion, and $this->option() ergonomics. If you catch yourself writing a spec array inside a Command, stop and use the signature string.= silently becomes a boolean flag; a short option without : silently refuses a value (you'll get OptionDoesNotAcceptArgumentException). There is no config file, no schema, no validation of the spec itself.src/Parser.php for your installed version before you build on it. The long-option = suffix and the short-option string are the de facto contract used by PHPUnit, but the README never states it.composer.json rather than letting the PHPUnit constraint float.SebastianBergmann\CliParser\Exception, not Symfony\Component\Console\Exception. They are unrelated hierarchies; an uncaught parser exception in a bin/ script produces a fatal stack trace instead of a clean error line.$option[0] is --env or -f, not env. Normalize once (see the adapter) and never ltrim at call sites.deploy staging) must be picked out of $_SERVER['argv'] yourself. If your tool needs real positionals, that is a strong signal to use Symfony Console.$_SERVER['argv']. The parser skips argv[0] itself. Slicing first shifts everything by one and produces mystifying UnknownOptionExceptions on your script name.$_SERVER['argv'] may be absent or meaningless. if (PHP_SAPI !== 'cli') { ... } before touching it, or scope the code to CLI-only entrypoints.null. No integer casting, no arrays (--tag=a --tag=b keeps only the last), no --opt=value escaping beyond what the shell already did. Cast at the boundary and document that repeated options are unsupported.--path=/tmp/My Dir breaks before PHP sees it; quote it or use --path "/tmp/My Dir".var_dump($_SERVER['argv']) and the raw tuple list before mapping. Nine times out of ten the spec array is wrong, not the input.--help is yours to write. There is no help generation and no usage text; hand-write it next to the spec so the two cannot drift, and consider a single constant holding both.getopt() for new internal scripts — the exception messages are better, it handles --opt value and --opt=value, and it is maintained. But prefer Symfony Console over both the moment the tool leaves your team.How can I help you explore Laravel packages today?