Skip to content

Make the console adapter constructor a plain boot pipeline, like WebAppAdapter #576

Closed

Description

@armanist

Summary

WebAppAdapter::__construct() only builds a BootPipeline from an inline list of stages and runs it. ConsoleAppAdapter::__construct() does much more, so the two adapters do not look alike. This ticket makes the console constructor consist of boot-pipeline items only, the same way as the web adapter.

Depends on #575 (the core:env / environment-file decision). Do this ticket after #575 is merged, because #575 changes the exact condition that is currently in the constructor.

Current state

ConsoleAppAdapter::__construct() on master (75127e91) does six things:

  1. creates the Symfony ArgvInput and ConsoleOutput and stores them in $this->input and $this->output;
  2. reads the command name from the input;
  3. assembles the boot stage list conditionally;
  4. runs the boot pipeline;
  5. calls environment()->setMutable(true) outside the pipeline, behind a second identical condition;
  6. creates the Symfony Application through the public createApplication(), using app.name and app.version from the config, and stores it in $this->application.

start() then builds a ConsoleContext from those three stored properties and runs the console pipeline.

WebAppAdapter for comparison: the constructor is new BootPipeline([...stages...]) followed by ->run($context), and nothing else. InitHttpStage shows the existing pattern for runtime objects: a boot stage registers Request and Response in the container (Di::register(...) behind an isRegistered guard), and the runtime stages read them from there.

Goal

The console constructor builds an inline list of boot stages and runs it, and does nothing else. The Symfony Application, the input and the output are provided by a new boot stage in the same way as InitHttpStage. start() reads them from the container.

Design

1. New boot stage InitConsoleStage

File: src/App/Stages/Boot/InitConsoleStage.php (implements BootStageInterface, declare(strict_types=1);, the Quantum file docblock, @package Quantum\App).

Registers three objects in the container, each only if not registered yet, so tests (and anything else) can provide their own first:

Container key Object
Symfony\Component\Console\Application new Application(config()->get('app.name', 'UNKNOWN'), config()->get('app.version', 'UNKNOWN'))
Symfony\Component\Console\Input\InputInterface new ArgvInput()
Symfony\Component\Console\Output\OutputInterface new ConsoleOutput()
  • Use the guard if (!Di::has($abstract)) { Di::set($abstract, $instance); }. Di::set() throws dependencyAlreadyRegistered when an instance already exists, so the guard is required. (Di::isRegistered() only checks the registry, not an instantiated object.)
  • Event names, following InitHttpStage: public const BEFORE = 'boot.console.before'; and public const AFTER = 'boot.console.after';. Add both to the list asserted in tests/Unit/App/BootPipelineTest.php::testPipelineStagesDeclareBootEventNames.
  • The stage must run after LoadAppConfigStage, because the application name and version come from the config. In the reduced boot (no config) it still runs, and the application is named UNKNOWN, exactly as today.

2. ConsoleAppAdapter

File: src/App/Adapters/Console/ConsoleAppAdapter.php.

  • The constructor builds the stage list and runs the BootPipeline. The list ends with new InitConsoleStage(). Nothing else is in the constructor.
  • Remove the properties $input, $output and $application, and remove createApplication() (it is public only so the adapter tests could stub it; a search of src shows nothing else calls it).
  • start() builds ConsoleContext from the container: Di::get(Application::class), Di::get(InputInterface::class), Di::get(OutputInterface::class), then runs the console pipeline exactly as today.
  • ConsoleContext, ConsolePipeline and all the console stages stay unchanged.
  • The one condition that decides full versus reduced boot stays (after Decide the console reduced boot by the environment file, not by the core:env command name #575 it is the check of the environment file, not a command name). Do not hide it in a private method and do not split the boot into two pipelines. If a way to express it as plain stage items without hiding logic is found, propose it in the PR description instead of implementing it silently.

3. The setMutable(true) step

The adapter-wide environment()->setMutable(true) is redundant: the only two callers of Environment::updateRow() (KeyGenerateCommand and InstallToolkitCommand) already call setMutable(true) themselves immediately before it. Recommended: remove the step, and document it as a behavior change (a command outside the framework that calls environment()->updateRow() without setting the environment mutable would then get EnvException::environmentImmutable()). Update packages/app/adapters.md in the docs repo, which describes the old rule.

Pitfalls found in an earlier attempt (read before starting)

  • Do not run the adapter tests with a real Symfony Application created by the adapter. Symfony's Application::run() calls exit() (auto-exit is on by default), which kills the PHPUnit process. Tests must register their own Application in the container first, with setAutoExit(false) and setCatchExceptions(false) (a real one) or a Mockery partial whose run() is stubbed.
  • ConsoleAppAdapterTest::setUp() must override AppTestCase::setUp() without calling the parent. The parent boots a web app, which breaks these tests ("AppContext is not initialized"). Keep an empty override with a comment.
  • Register the test objects after createContext(). The context creates a fresh container, so Di::set(...) before createContext() is lost. A small private helper createAdapter(?Application $application = null) in the test class that creates the context, registers the application and constructs the adapter keeps this in one place.
  • The existing characterization tests stay, assertions unchanged, only their setup changes (they currently stub createApplication() through a Mockery partial of the adapter). This is the proof that behavior is unchanged: command registration order, an app command overriding a core command, a missing app commands directory, only instantiable commands registered, unknown and missing command name, full boot versus reduced boot.
  • Tests that only construct the adapter (the boot tests) now also create a real Application through InitConsoleStage when nothing is pre-registered. That is fine as long as they never call start().
  • Commands registered in the test app: the framework commands directory (vendor/quantum/framework/src/Console/Commands) does not exist under tests/_root; tests that need a core command create a temporary one there with the existing withTemporaryCommand() helper and clean it up.

Tests (write them first; show them red where they can be)

tests/Unit/App/Stages/Boot/InitConsoleStageTest.php (new):

  • declares boot.console.before / boot.console.after;
  • registers an Application named from app.name / app.version when the config is loaded;
  • falls back to UNKNOWN / UNKNOWN without the app config;
  • registers an ArgvInput as InputInterface and a ConsoleOutput as OutputInterface;
  • keeps objects that are already registered (pre-register an Application, an ArrayInput and a BufferedOutput, process the stage, assert the same instances come back).

tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php:

  • new: after construction the Application, InputInterface and OutputInterface are in the container (full boot and reduced boot; in the reduced boot the application is named UNKNOWN);
  • new: on a full boot the application is named from app.name (proves InitConsoleStage runs after the config stage);
  • existing tests: unchanged assertions, setup moved to the container as described above;
  • if the setMutable(true) step is removed: delete the assertions that expected the environment to be mutable after a console boot, and say so in the commit message.

Tests that cannot be shown red against the old code (they would call exit()) are proven by running them green after the change plus a mutation check in a throwaway copy of the repo (leave InitConsoleStage out of the list, run it before the config stage, make it ignore an already registered Application; each must make a test fail).

Validation

  1. Narrowest PHPUnit paths first (tests/Unit/App/Stages/Boot, tests/Unit/App/Adapters/Console), then vendor/bin/phpunit tests/Unit/App and tests/Unit/Console.
  2. composer cs:check
  3. composer phpstan (level 8, PHP 8.0)
  4. The full suite once at the end. Redis and Memcached tests fail locally without those services; report them as environmental.
  5. Manual check in a throwaway copy of the sample project (never the real one): php qt list, core:version, route:list, help core:key, an unknown command and no command (message and exit code 0 unchanged, only the stack trace file names differ), core:key --yes (changes APP_KEY), core:env --yes; plus the web smoke test (/, /en, /api/posts, a 404 in HTML and JSON, OPTIONS returns 204, the debugbar renders).

Acceptance criteria

  • ConsoleAppAdapter::__construct() contains only the stage list and the pipeline run, and the list ends with InitConsoleStage.
  • createApplication() and the $input, $output and $application properties are gone from the adapter; start() reads the three objects from the container.
  • InitConsoleStage exists with tests, and its event names are covered in BootPipelineTest.
  • Console behavior is unchanged: same output and exit codes for the commands listed under Validation, same command registration order and precedence.
  • No new pipelines, no private helper methods in the adapter, no change to ConsoleContext, ConsolePipeline or the console stages.
  • CHANGELOG.md entry under 3.1.0 (Changed): the console adapter gets its application, input and output from the new InitConsoleStage; ConsoleAppAdapter::createApplication() was removed; the adapter no longer marks the environment mutable for every command (if that step is removed).
  • The docs pages that describe the console boot are listed in the PR (cli/qt-command-line-tool.md, packages/app/adapters.md, packages/console/architecture.md); the docs PR goes to the staging branch of the docs repo.

Out of scope

Workflow notes for the implementer

  • Branch from an up-to-date master: issue/<this-ticket-number>-console-constructor-pipeline.
  • Small, single-purpose commits with [#<number>] subjects; tests first in each step.
  • Keep the change minimal. If a step needs a new class, a new public method or a change outside the files listed here, stop and ask in the ticket first.
  • Follow the repo style: declare(strict_types=1);, the Quantum file docblock, use imports ordered by line length (longest first), PHP 8.0 compatible.

Related

Activity

  1. added this to the 3.1.0 milestone on Oct 13, 2026
  2. self-assigned this
    on Oct 13, 2026
  3. added 4 commits that reference this issue on Oct 15, 2026
  4. added 5 commits that reference this issue on Oct 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

runtimeApp bootstrap and runtime pipeline

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions