Skip to content

Native console Application and Command (replace Symfony's) #652

Open

Description

@armanist

Summary

Replace the Symfony Application and the Symfony Command base class of CliCommand with native Quantum classes, so the console app lines up with the web app: Quantum owns the run, the exit code and the error path, and the stages (ConsoleAppAdapter) are in full control of the lifecycle.

Symfony's input, output and helper classes (ArgvInput, ArrayInput, InputDefinition, OutputInterface, Table, ProgressBar, QuestionHelper, and so on) stay, by design. symfony/console remains a dependency as the input, output and helper layer.

Milestone: 3.3.0. This is a breaking change for apps that extend Symfony Command methods in their own commands.

Why

The console adapter already runs on the same pipeline model as the web adapter (src/App/Adapters/Console/ConsoleAppAdapter.php: boot stages, one runtime stage, ConsoleContext with stop() and an exit code). What is not under Quantum's control is the run itself. src/App/Adapters/Console/Stages/Runtime/RunCommandStage.php hands everything to Symfony\Component\Console\Application::run(), which:

  • parses the input and finds the command;
  • runs Symfony's built-in list and help commands;
  • catches and prints a command's exception itself (setCatchExceptions is on by default), so the Quantum ErrorHandler is bypassed for command errors; tests/Helpers/NonExitingApplication.php exists only to switch this off in tests;
  • calls exit(), so console.command.after can never fire in a real run (noted in CLAUDE.md and guarded in PipelineTest).

Consequences today:

  • ResolveEnvironmentStage has to special-case Symfony's list and help because they are not CliCommands and cannot say they need no environment.
  • The command layer cannot be changed without changing Symfony's classes: CliCommand is a 244-line adapter that maps Quantum's $args and $options arrays onto Symfony's InputArgument and InputOption.
  • Tests of commands need Symfony's CommandTester, which only accepts a Symfony Command.

Laravel does the same split and keeps Symfony underneath: Illuminate\Console\Application extends SymfonyApplication with setAutoExit(false) and setCatchExceptions(false), and its Kernel catches the exception and renders it through the app's exception handler. This ticket goes one step further than Laravel by also owning the command base class, so that the command concept (name, description, help, arguments, options, exec()) is Quantum's own and not a thin layer over Symfony's.

Current state (what depends on Symfony Console)

11 files in src/ import Symfony Console (14 distinct classes) and 23 test files.

Symfony class Used by
Application RunCommandStage, SetupConsoleApplicationStage, InitConsoleStage, RegisterCoreCommandsStage, RegisterAppCommandsStage, ResolveEnvironmentStage, ConsoleContext, CommandRegistrar, InstallToolkitCommand (getApplication()->find())
Command base class of src/Console/CliCommand.php
InputInterface, ArgvInput, ArrayInput, InputArgument, InputOption InitConsoleStage, ConsoleContext, ResolveEnvironmentStage, CliCommand, InstallToolkitCommand
OutputInterface, ConsoleOutput, NullOutput InitConsoleStage, ConsoleContext, CliCommand, InstallToolkitCommand, src/Tracer/ErrorHandler.php
Table src/Console/Commands/RouteListCommand.php
QuestionHelper, ConfirmationQuestion CliCommand::confirm()
Exception\ExceptionInterface InstallToolkitCommand

Command-side API actually used (all 13 core commands and the 10 commands of the quantum/project skeleton): exec(), the declared properties ($name, $description, $help, $args, $options, $requiresEnvironment), and getArgument(), getOption(), output(), info(), error(), comment(), question(), confirm(). Only InstallToolkitCommand (core) and DemoCommand (skeleton) use getApplication()->find($name)->run(new ArrayInput(...), new NullOutput()) to run another command. The skeleton's UserShowCommand, PostShowCommand and DemoCommand use Table and ProgressBar on $this->output.

Scope

In scope

  1. A native Quantum\Console\Application.
  2. A rewritten Quantum\Console\CliCommand that no longer extends Symfony Command, and an extended Quantum\Console\Contracts\CommandInterface.
  3. Native list and help commands.
  4. Global flags: -q/--quiet, -v/-vv/-vvv, -n/--no-interaction, --ansi/--no-ansi, -h/--help.
  5. Changes to the console stages and ConsoleContext so the runtime owns the exit code and the error path.
  6. A Quantum command tester shipped in src/ to replace Symfony's CommandTester.
  7. Command-name abbreviations (mig:gen), see below.
  8. Tests, CHANGELOG.md BREAKING entry with the migration note.

Out of scope (see Follow-ups): shell completion; command aliases.

Design

Quantum\Console\Application

  • Holds the registered commands by name: add(), has(), get(), find(), all().
  • find($name): exact match first; otherwise resolve an abbreviation the way Symfony does (each :-separated segment is a prefix: mig:gen finds migration:generate). An ambiguous abbreviation throws a clear exception listing the candidates; an unknown name throws "Command x is not defined" (keep the current message).
  • run(InputInterface, OutputInterface): int:
    1. apply the global flags to the input and output;
    2. read the command name (getFirstArgument()); no name or list renders the list; help <name> or --help renders the help of a command;
    3. otherwise find($name)->run($input, $output) and return its exit code.
  • It does not call exit() and does not catch exceptions: a command's exception reaches RunCommandStage and the pipeline, and from there the Quantum ErrorHandler, as in the web app. ConsoleAppAdapter::start() keeps returning the exit code to qt, which calls exit().
  • Name and version (SetupConsoleApplicationStage) are kept and used by the list and --version.

Quantum\Console\CliCommand and CommandInterface

  • No longer extends Symfony\Component\Console\Command\Command.
  • Keeps the declaration style exactly as it is: $name, $description, $help, $args, $options, $requiresEnvironment, exec(). Existing core and skeleton commands need no change.
  • Keeps getArgument(), getOption(), output(), info(), comment(), question(), error(), confirm(), requiresEnvironment(), and the $input and $output properties (so new Table($this->output) keeps working).
  • Adds getName(), getDescription(), getHelp(), getDefinition() (an InputDefinition built from $args and $options, the mapping code in CliCommand::setArguments() and setOptions() moves here), getApplication() (for chaining), and run(InputInterface, OutputInterface): int.
  • run() builds the full definition (the command-name argument first, the global options, then the command's own), binds and validates the input, sets $input and $output, calls exec() and returns the exit code. Binding errors (missing argument, unknown option) surface as Symfony's input exceptions with their existing messages.
  • CommandInterface gains the methods above so that CommandRegistrar and the application depend on the interface and not on a class.

list and help

  • Native commands with requiresEnvironment = false, rendered from the command definition (name, description, usage line from InputDefinition::getSynopsis(), arguments, options, help text), using the Symfony Table helper for the list.
  • This removes the "Symfony's list/help do not need the environment" special case from ResolveEnvironmentStage.

Stages and context

  • InitConsoleStage: register the Quantum Application instead of the Symfony one; the input and output registrations stay.
  • ConsoleContext::getApplication() returns the Quantum Application.
  • RegisterCoreCommandsStage, RegisterAppCommandsStage, CommandRegistrar: type changes only.
  • ResolveEnvironmentStage: look the command up in the Quantum application; drop the list/help special case.
  • RunCommandStage: call the Quantum application. The console.command.before/after events keep their names. Re-check whether console.command.after can now fire in a real run (it could not with Symfony's exit()), and update CLAUDE.md/the changelog and PipelineTest accordingly.
  • Exit code: a command's exec() returns nothing today; keep 0 on success. A thrown exception ends the run through the pipeline and the error handler; decide and document the exit code for that case (non-zero).

Command tester

  • Add Quantum\Console\Testing\CommandTester in src/ (not tests/) so apps can test their own commands with it. Same methods as Symfony's: execute(array $input), getDisplay(), getStatusCode(), setInputs(array) (answers for confirm() via an in-memory input stream), assertCommandIsSuccessful().
  • Built on the Symfony ArrayInput and StreamOutput that stay in the project; it adds the command name argument automatically.
  • The 14 existing test files that use Symfony's CommandTester (50 uses) switch to it with a one-line use change. tests/Helpers/NonExitingApplication.php is removed.

Compatibility and migration

Breaking for apps and packages that:

  • call Symfony Command methods on $this inside their own CliCommand subclasses (getHelper(), addArgument(), addOption(), getDefinition() as a Symfony definition, setHidden(), and so on);
  • type-hint or resolve Symfony\Component\Console\Application from the container, or use ConsoleContext::getApplication() as a Symfony application;
  • test their commands with Symfony's CommandTester.

Not affected: commands that only use exec(), the declared properties and the helper methods listed above, which is the documented way. The quantum/project skeleton's commands fall in this group; its DemoCommandTest uses Symfony's CommandTester and must be moved to the Quantum tester.

CHANGELOG.md gets a BREAKING entry with the migration note for apps and custom commands (what to replace, how to declare the same command, how to switch tests).

Implementation plan (one PR, several commits)

  1. Native Application and the list and help commands, with tests.
  2. Rewritten CliCommand and CommandInterface, with tests.
  3. Stages, ConsoleContext, CommandRegistrar, InstallToolkitCommand; simplify ResolveEnvironmentStage.
  4. Quantum CommandTester; migrate the existing tests; remove NonExitingApplication.
  5. CHANGELOG.md, CLAUDE.md architecture notes (local, not committed), documentation ticket.

Feasibility

A throwaway prototype (outside the repository) of a native application, a native command base declared the Quantum way and a native tester passed 16 checks on PHP 8.1 against the real Symfony input, output and helper classes: ArgvInput with arguments, shortcuts, array options and flags; missing-argument and unknown-option errors; an exception thrown inside a command reaching the caller; -q, -v, -n and --ansi; confirm() with a standalone QuestionHelper; Table and ProgressBar on $this->output; chaining with find()->run(ArrayInput, NullOutput); abbreviations; list and help from the definition; the tester. Two details to carry into the implementation: the command name has to be the first argument of every command's definition, and the global options have to be defined on every command, otherwise binding rejects them.

Still to verify during implementation (not proven by the prototype): the help <command> form; a real run through php qt with the boot stages and ErrorHandler; PHP 8.0 and 8.2 (CI matrix); the exit code after an exception.

Acceptance criteria

  • php qt <command> runs through the native application; arguments, options, shortcuts, array arguments/options and flags behave as before.
  • A missing argument or an unknown option gives a clear error and a non-zero exit code.
  • An exception in a command reaches the ErrorHandler and is rendered like other errors; no exit() inside the framework; start() returns the exit code.
  • list, help, help <command> and <command> --help work and do not require the environment.
  • -q, -v/-vv/-vvv, -n and --ansi/--no-ansi work.
  • Abbreviations resolve when unambiguous and give a clear error when ambiguous or unknown.
  • confirm() works interactively, with -n, and in tests with setInputs().
  • InstallToolkitCommand can still run another command.
  • No file in src/ imports Symfony\Component\Console\Application or Symfony\Component\Console\Command\Command.
  • ResolveEnvironmentStage has no special case for list/help.
  • The Quantum CommandTester exists, is documented, and all existing command tests use it.
  • composer cs:check, composer phpstan (level 8) and the PHPUnit suite pass; CI passes on PHP 8.0, 8.1 and 8.2.

Follow-ups (separate tickets, not blocking this one)

  • Shell completion. Symfony's Application ships completion and _complete today. The native application needs a completion command that prints the shell script (Symfony's bash, zsh and fish scripts in vendor/symfony/console/Resources can be reused as files) and a _complete command built on CompletionInput and CompletionSuggestions, which work without an Application. Include per-command value suggestions. (Do it in this ticket if it turns out to be cheap.)
  • Command aliases.
  • Starter project (quantum/project): move DemoCommandTest to the Quantum tester and adjust anything that resolves the Symfony application.
  • Documentation (quantum-php/docs): console and command pages, testing commands, the migration note, the upgrade guide.

Activity

  1. added this to the 3.3.0 milestone on Nov 3, 2026
  2. added
    enhancementNew feature or request
    breaking changeIntroduces or coordinates a breaking change
    consoleConsole commands and CLI behavior
    on Nov 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    breaking changeIntroduces or coordinates a breaking changeconsoleConsole commands and CLI behaviorenhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions