Repository navigation
Make the console adapter constructor a plain boot pipeline, like WebAppAdapter #576
Description
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:
- creates the Symfony
ArgvInputandConsoleOutputand stores them in$this->inputand$this->output; - reads the command name from the input;
- assembles the boot stage list conditionally;
- runs the boot pipeline;
- calls
environment()->setMutable(true)outside the pipeline, behind a second identical condition; - creates the Symfony
Applicationthrough the publiccreateApplication(), usingapp.nameandapp.versionfrom 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()throwsdependencyAlreadyRegisteredwhen 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';andpublic const AFTER = 'boot.console.after';. Add both to the list asserted intests/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 namedUNKNOWN, 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 withnew InitConsoleStage(). Nothing else is in the constructor. - Remove the properties
$input,$outputand$application, and removecreateApplication()(it is public only so the adapter tests could stub it; a search ofsrcshows nothing else calls it). start()buildsConsoleContextfrom the container:Di::get(Application::class),Di::get(InputInterface::class),Di::get(OutputInterface::class), then runs the console pipeline exactly as today.ConsoleContext,ConsolePipelineand 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:envcommand 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
Applicationcreated by the adapter. Symfony'sApplication::run()callsexit()(auto-exit is on by default), which kills the PHPUnit process. Tests must register their ownApplicationin the container first, withsetAutoExit(false)andsetCatchExceptions(false)(a real one) or a Mockery partial whoserun()is stubbed. ConsoleAppAdapterTest::setUp()must overrideAppTestCase::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, soDi::set(...)beforecreateContext()is lost. A small private helpercreateAdapter(?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
ApplicationthroughInitConsoleStagewhen nothing is pre-registered. That is fine as long as they never callstart(). - Commands registered in the test app: the framework commands directory (
vendor/quantum/framework/src/Console/Commands) does not exist undertests/_root; tests that need a core command create a temporary one there with the existingwithTemporaryCommand()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
Applicationnamed fromapp.name/app.versionwhen the config is loaded; - falls back to
UNKNOWN/UNKNOWNwithout the app config; - registers an
ArgvInputasInputInterfaceand aConsoleOutputasOutputInterface; - keeps objects that are already registered (pre-register an
Application, anArrayInputand aBufferedOutput, process the stage, assert the same instances come back).
tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php:
- new: after construction the
Application,InputInterfaceandOutputInterfaceare in the container (full boot and reduced boot; in the reduced boot the application is namedUNKNOWN); - new: on a full boot the application is named from
app.name(provesInitConsoleStageruns 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
- Narrowest PHPUnit paths first (
tests/Unit/App/Stages/Boot,tests/Unit/App/Adapters/Console), thenvendor/bin/phpunit tests/Unit/Appandtests/Unit/Console. composer cs:checkcomposer phpstan(level 8, PHP 8.0)- The full suite once at the end. Redis and Memcached tests fail locally without those services; report them as environmental.
- 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(changesAPP_KEY),core:env --yes; plus the web smoke test (/,/en,/api/posts, a 404 in HTML and JSON,OPTIONSreturns 204, the debugbar renders).
Acceptance criteria
ConsoleAppAdapter::__construct()contains only the stage list and the pipeline run, and the list ends withInitConsoleStage.createApplication()and the$input,$outputand$applicationproperties are gone from the adapter;start()reads the three objects from the container.InitConsoleStageexists with tests, and its event names are covered inBootPipelineTest.- 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,ConsolePipelineor the console stages. CHANGELOG.mdentry under 3.1.0 (Changed): the console adapter gets its application, input and output from the newInitConsoleStage;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 thestagingbranch of the docs repo.
Out of scope
- The environment-file /
core:envdecision (Decide the console reduced boot by the environment file, not by thecore:envcommand name #575). - Console lifecycle events (Introduce lifecycle hooks across boot, web runtime, console runtime, routing, and response flow #538). Note for that ticket: Symfony's
Application::run()callsexit()while auto-exit is on, so a console "after run" event cannot fire in real runs unless auto-exit is disabled. - Making the request stages stop reading the global
request()andresponse()helpers. - Sharing code between
BootPipeline,RequestPipelineandConsolePipeline.
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,useimports ordered by line length (longest first), PHP 8.0 compatible.
Related
- Decide the console reduced boot by the environment file, not by the
core:envcommand name #575: decide the console reduced boot by the environment file, not by the command name (prerequisite). - Refactor WebAppAdapter::start() and ConsoleAppAdapter::start() into structured runtime pipelines #537 (merged, PR Issue/537 structured runtime pipelines for web and console #574): runtime pipelines for web and console.
- Introduce lifecycle hooks across boot, web runtime, console runtime, routing, and response flow #538: lifecycle events.
Activity
[quantum-php#576] Create the helpers folder in the broken helper test…
[quantum-php#576] Fix the changelog entries about createApplication()
[quantum-php#576] Name the console application only after the whole b…
[quantum-php#576] Create the console application before the helpers a…
[#576] Stop making the environment mutable for every console command
[#576] Add the boot stage that provides the console application, inpu…
[#576] Make the console adapter constructor a boot pipeline
[#576] Let the console context read its objects from the container
[#576] Add the changelog entry for the console constructor change