laminas/laminas-cli
laminas-cli provides a Symfony Console-based command runner for Laminas and Mezzio applications, letting you register CLI commands as services and execute them through a unified laminas-cli binary.
What it actually is: a thin bin wrapper around Symfony Console that boots your Laminas MVC / Mezzio PSR-11 container and resolves commands from that container. It is the supported replacement for the abandoned laminas-mvc-console, and it is the reason your CLI code can reuse the exact same services your HTTP stack uses.
Minimal wiring (MVC or Mezzio, identical config):
composer require laminas/laminas-cli symfony/console
vendor/bin/laminas list
// config/autoload/laminas-cli.global.php (MVC) — or your ConfigProvider (Mezzio)
return [
'laminas-cli' => [
'commands' => [
'app:import' => App\Command\ImportCommand::class,
],
],
// MVC: 'service_manager' => ['factories' => [...]]
// Mezzio: 'dependencies' => ['factories' => [...]]
];
Register a factory for the command as soon as it has constructor dependencies (ServiceManager does not auto-wire by default). The array key is the command name users type; the value is the container service id.
Where to look first:
vendor/bin/laminas list — confirms the package sees your config. If a command is missing here, it's a config/module-registration problem, not a code problem.docs/book/ in the repo — the introduction page walks MVC users through the three steps; the params chapter covers interactive commands.Symfony\Component\Console\Command\Command docs — laminas-cli adds almost nothing on top; your knowledge transfers directly.First useful use case: pick the one-off script currently living at bin/import.php or scripts/fix-data.php that bootstraps the container by hand. Move it into src/Command/, inject the same service your HTTP handler uses, delete the script. You immediately get --help, argument validation, exit codes, and list discoverability for free.
Thin command, fat service (the pattern that matters). Commands are adapters. All logic goes in an injected service so the same code path runs from a Mezzio handler, a queue consumer, and a cron job.
#[AsCommand(name: 'app:import', description: 'Import products from a CSV file')]
final class ImportCommand extends Command
{
public function __construct(private readonly ProductImporter $importer) { parent::__construct(); }
protected function configure(): void
{
$this->addArgument('file', InputArgument::REQUIRED, 'CSV path');
$this->addOption('dry-run', null, InputOption::VALUE_NONE);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
try {
$stats = $this->importer->import($input->getArgument('file'), (bool) $input->getOption('dry-run'));
} catch (Throwable $e) {
$io->error($e->getMessage());
return Command::FAILURE;
}
$io->success(sprintf('%d rows', $stats->rows));
return Command::SUCCESS;
}
}
Since 1.15.0 the #[AsCommand] attribute is honoured, so you can drop the duplicated name from configure() and keep a single source of truth.
Param-aware commands for human-facing tooling. Laminas\Cli\Command\AbstractParamAwareCommand with Laminas\Cli\Input\*Param turns "which environment? which tenant?" into prompts, and silently becomes --env=prod --tenant=acme when flags are supplied — ideal for destructive tasks.
final class PurgeCommand extends AbstractParamAwareCommand
{
protected function configure(): void
{
$this->setName('app:purge')
->addParam(new ChoiceParam('env', 'Target environment', ['dev', 'staging'], 'dev'))
->addParam(new StringParam('tenant', 'Tenant slug'))
->addParam(new BoolParam('confirm', 'Really purge?'));
}
}
Ship commands from reusable packages. Put a ConfigProvider in your library that returns ['laminas-cli' => ['commands' => [...]]]. Any application that installs the package instantly gets yourvendor:sync — no app-side config edits, no doc page explaining a manual registration step.
Scheduling / workers. Point cron and supervisor at the same binary: vendor/bin/laminas app:sync --no-interaction. One entrypoint, one container bootstrap, one place to change config.
Alternative containers for testing. --container=path/to/container.php accepts a filename returning a PSR-11 instance. Use it to run the real CLI against a stub container (fixed clock, fake mailer), or to point the CLI at a different project checkout.
list, help, completion, _complete are Symfony built-ins. Always namespace as vendor:action; laminas-cli will not warn you about shadowing.Command::SUCCESS / Command::FAILURE. A command that returns null implicitly returns 0 and your pipeline goes green on failure. Uncaught exceptions give non-zero plus a stack trace — nicer for debugging, noisier for cron mail.ServiceNotFoundException when you invoke the command. Cause: class registered in laminas-cli.commands but no factory. Since 1.0.1 an unknown name gives a clean "command not found"; a broken factory does not.config/modules.config.php; in Mezzio Laminas\Cli\ConfigProvider must reach your config-aggregator (automatic with laminas-component-installer). Verify with vendor/bin/laminas list --raw.--no-interaction hangs or fails on param commands. Prompts require a TTY. Give every Param a usable default, or make the required ones explicit so the failure is loud rather than a silent hang in a container.$request, sessions, or cookies will explode under CLI. Guard those services ($container->has(RequestInterface::class)) before your command needs them.symfony/console deliberately and re-run vendor/bin/laminas list after every bump — attribute-command support (1.15) specifically exists because of a Symfony 7.x regression.list slow. Keep factories lazy; connect inside execute().EntityManager and identity map grow per iteration. clear() every N records, chunk your queries, and print progress via ProgressBar so a supervisor-restarted worker is diagnosable.-vvv for verbose output, --no-ansi for clean logs, and SymfonyStyle for consistent formatting. vendor/bin/laminas app:import -vvv will show the exception trace Symfony hides at default verbosity.ConsoleEvents::COMMAND / TERMINATE listeners are the right place for cross-cutting concerns (timing, audit logging, memory reporting). Attach them as subscribers rather than one-by-one listeners — this is the pattern the docs corrected in 1.5.0.How can I help you explore Laravel packages today?