Repository navigation
Decide the console reduced boot by the environment file, not by the core:env command name #575
Description
Summary
The console adapter decides how much of the app to boot by comparing the first CLI argument with the string 'core:env'. This ticket replaces that name check with two decisions that each use information available at the time they are made:
- At boot: does the expected environment file exist? If yes, every command (including
core:env) gets the normal strict boot. If no, the console gets the reduced boot (helpers only). - At run, only in the reduced case: may the requested command run without the environment? By then the commands are registered, so the run stage asks the command object itself through a new
CliCommand::requiresEnvironment().EnvCommanddeclaresfalse; every other command keeps the defaulttrueand fails with the same "file not found" error as today.
No command name is hardcoded anywhere, the environment stays strict for normal commands, and no reflection, registry or extra pipeline is introduced.
Background: why core:env is special
core:env(src/Console/Commands/EnvCommand.php) creates the environment file by copying.env.exampleto.env.- The normal console boot runs
LoadEnvironmentStage,LoadAppConfigStageandSetupErrorHandlerStagebefore any command runs.Environment::load()throwsEnvException::fileNotFound(...)when the env file (orshared/config/env.php) is missing.- Config files call
env(), which throwsenvironmentNotLoadedwhen the environment is not loaded. - The error handler needs the logger config.
- So without the env file the boot fails before
core:envcan create it. Since 2022 (commits99526c56,f5381038, then moved into the adapter) this has been worked around with a string check on the command name.
Current code on master (75127e91), src/App/Adapters/Console/ConsoleAppAdapter.php:
$commandName = $this->input->getFirstArgument();
$stages = [
new LoadHelpersStage(),
];
if ($commandName !== 'core:env') {
$stages[] = new LoadEnvironmentStage();
$stages[] = new LoadAppConfigStage();
$stages[] = new SetupErrorHandlerStage();
}
$pipeline = new BootPipeline($stages);
$pipeline->run($this->context);
if ($commandName !== 'core:env') {
environment()->setMutable(true);
}Current src/App/Stages/Console/RunCommandStage.php:
public function process(ConsoleContext $context): void
{
$commandName = $context->getInput()->getFirstArgument();
if ($commandName === null || !$context->getApplication()->has($commandName)) {
throw new Exception("Command `$commandName` is not defined");
}
$context->setExitCode(
$context->getApplication()->run($context->getInput(), $context->getOutput())
);
}Problems with the current approach
- The adapter hardcodes one command's name; the fact "this command does not need
.env" belongs to the command. - No other command can opt out.
core:envalways runs half-booted (no config,app.namefalls back toUNKNOWN, no error handler), even when.envexists and a full boot would work.- With
.envmissing, even Symfony's built-inlistandhelpfail with a fatal error.
Approaches already rejected (do not implement these)
- Reading a command property before the boot. Commands are discovered and instantiated in
start(), after the boot, so the property cannot be read when the boot is decided. Moving registration before the boot would instantiate every command before the environment is loaded for every run. - A reflection scan of the command directories before the boot (
ReflectionClass::getDefaultProperties()on every command class). It works but adds a directory scan, depends on the helpers being loaded (so it forces a second boot pipeline), and was rejected as too heavy. - A permissive environment for all console commands (Laravel
safeLoad()style). Almost every Quantum command needs.env; the early, clear "file.envnot found" error must stay. - A hardcoded list of "setup commands" in the entry point or adapter. It is the same name check moved elsewhere.
Design
1. Environment can tell whether its file exists, without throwing
File: src/Environment/Environment.php.
- Add a public, non-throwing method, for example
public function fileExists(): bool, that applies exactly the same resolution asresolveEnvironmentFile():- reads
shared/config/env.php(missing file →false); - takes
app_env(defaultEnv::PRODUCTION) and builds the file name with the existinggetEnvFileName()(.envfor production,.env.<app_env>otherwise); - returns whether that file exists in the base directory.
- reads
- Share the resolution logic between
fileExists()andresolveEnvironmentFile()(no copy-pasted path building).resolveEnvironmentFile()keeps throwing exactly as today. - The method must work before the helpers are loaded, because the adapter calls it while it builds the stage list.
resolveEnvironmentFile()currently uses thebase_dir()helper; replace it withApp::getBaseDir()(the helper returns exactly that value), so neither method depends on helpers.
2. Commands declare whether they need the environment
File: src/Console/CliCommand.php.
- Add, next to the other declarative properties (
$name,$description,$help,$args,$options):
/**
* Whether the command needs the environment file to run
*/
protected bool $requiresEnvironment = true;- Add a public getter
public function requiresEnvironment(): bool.
File: src/Console/Commands/EnvCommand.php:
/**
* The command creates the environment file, so it must be able to run without one
*/
protected bool $requiresEnvironment = false;3. The adapter decides the boot by the env file
File: src/App/Adapters/Console/ConsoleAppAdapter.php.
- Replace both
$commandName !== 'core:env'checks with one check of(new Environment())->fileExists(), evaluated once and reused for the stage list and thesetMutable(true)step. - Behavior when the file exists: identical to today's full boot, for every command, including
core:env. - Behavior when the file is missing: identical to today's
core:envreduced boot, for every command. - Do not add a second pipeline, a private method, or any other restructuring of the constructor. The constructor cleanup is a separate ticket that depends on this one.
4. The run stage blocks commands that need the environment when it is missing
File: src/App/Stages/Console/RunCommandStage.php.
After the existing "is not defined" guard (keep it unchanged) and before run():
- Get the requested command:
$context->getApplication()->get($commandName). - If it is a
CliCommand,requiresEnvironment()istrue, and(new Environment())->fileExists()isfalse, raise the same exception the normal boot raises. The simplest way is to call(new Environment())->load(), which throwsEnvException::fileNotFound(...)with the exact file name (.env,.env.<app_env>, or the missingshared/config/env.php). Do not invent a new message. - Commands that are not
CliCommand(Symfony's built-inlist,help,completion) are not blocked. - Nothing must run before the check: no output, exit code unchanged.
5. Fix in EnvCommand (small bug found during the investigation)
EnvCommand::exec() checks $fs->exists('.env') with a relative path, so the overwrite confirmation depends on the current working directory, while the copy goes to App::getBaseDir() . DS . '.env'. Check the same absolute path as the copy target.
Resulting behavior
| Situation | Before | After |
|---|---|---|
| Env file present, any command | full boot | full boot (unchanged) |
Env file present, core:env |
reduced boot | full boot, then the usual confirmation before overwriting |
Env file missing, core:env --yes |
reduced boot, file created | reduced boot, file created (unchanged) |
Env file missing, a normal command (for example migration:migrate, core:version) |
fatal EnvException: file not found |
the same EnvException with the same message, raised by RunCommandStage before the command runs |
Env file missing, list / help |
fatal EnvException |
works (built-in Symfony commands) |
Env file missing, an app command that sets $requiresEnvironment = false |
fatal | runs in the reduced boot |
Out of scope (note in the PR, do not change)
- File name mismatch: when
app_envis notproduction, the boot expects.env.<app_env>, butcore:envalways writes.env. Changing whatcore:envwrites is a behavior change for a separate ticket. EnvCommandusesFileSystemFactory::get(), which importsshared/config/fs.php. Harmless today (that file calls noenv()); leave it.- The redundant
setMutable(true)step (the only callers ofupdateRow(),KeyGenerateCommandandInstallToolkitCommand, set it themselves): keep it, with the new condition. - The constructor cleanup (console objects as boot stages, like
WebAppAdapter): separate ticket, after this one. - Exit code 0 for unknown commands, Symfony auto-exit, console lifecycle events (Introduce lifecycle hooks across boot, web runtime, console runtime, routing, and response flow #538).
Known trade-off
When the env file is missing, all commands are instantiated (during registration in start()) before the run stage blocks them. A third-party command whose constructor calls env() or config() values that need the environment would then fail with environmentNotLoaded instead of "file not found". This only happens in an already-broken setup; the framework's own commands and the sample project's commands do not do this (checked: the constructors of DebugBarCommand, OpenApiCommand, ResourceCacheClearCommand only call FileSystemFactory::get(), and DemoCommand only sets up Faker). Mention it in the docs: command constructors should not need the environment.
Tests (write them first, show them red, then implement)
Test app facts: tests/_root/shared/config/env.php sets app_env to testing, so the expected env file is tests/_root/.env.testing, created by createEnvFile() in tests/Helpers/functions.php. To simulate a missing file, rename it in the test and restore it in a finally block. Never delete it.
tests/Unit/Environment/EnvironmentTest.php:
fileExists()istruewhen the expected file exists.fileExists()isfalsewhen it is missing (rename.env.testingtemporarily).fileExists()resolves the env-specific name fromapp_env(the test app expects.env.testing, not.env).fileExists()works without the helpers loaded, or document why that cannot be unit-tested and cover it through the adapter test.load()still throwsfileNotFoundwith the same message when the file is missing (pins the unchanged strict behavior).
tests/Unit/Console/CliCommandTest.php and tests/Unit/Console/Commands/EnvCommandTest.php:
CliCommand::requiresEnvironment()istrueby default (use the existingTestCommandfixture).EnvCommand::requiresEnvironment()isfalse.EnvCommandasks for confirmation when the base-dir.envexists, independent of the current working directory.
tests/Unit/App/Stages/Console/RunCommandStageTest.php (uses ConsoleStageTestCase::createConsoleContext() with a real Symfony Application, auto-exit off):
- With the env file missing, a
CliCommandthat requires the environment is not run: theEnvExceptionis thrown, output is empty, exit code staysExitCode::SUCCESS. - With the env file missing, a
CliCommandwith$requiresEnvironment = falseruns. - With the env file missing, a plain Symfony
Command(not aCliCommand) runs. - With the env file present, a command that requires the environment runs as before.
- The existing guard tests (unknown command, missing command name) stay unchanged.
tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php:
- Existing tests keep their assertions. Exception, a deliberate behavior change:
testConsoleAppAdapterBootsReducedStagesForCoreEnvCommandasserts the reduced boot forcore:envwhile the env file exists. After this ticketcore:envboots fully when the file exists. Replace it with two tests and say so in the commit message:- env file present,
core:env→ full boot (Environmentregistered, error handler registered,appconfig loaded); - env file missing, any command name → reduced boot (none of the three).
- env file present,
- Env file missing and
start()with a command that requires the environment → theEnvExceptionis thrown before the command runs. - Env file missing and
start()withlist→ succeeds.
Validation
Follow AGENTS.md:
- The narrowest PHPUnit paths first (
tests/Unit/Environment,tests/Unit/Console,tests/Unit/App/Stages/Console,tests/Unit/App/Adapters/Console), thenvendor/bin/phpunit tests/Unit/App. composer cs:checkcomposer phpstan(level 8, PHP 8.0)- The full suite once at the end. Redis and Memcached tests fail locally when those services are not running; report them as environmental, not as regressions.
- Manual check in a throwaway copy of the sample project (never the real one;
core:envoverwrites.env): with.envpresent:php qt list,php qt core:version,php qt core:env(asks to confirm),php qt core:env --yes; with.envremoved:php qt core:env --yes(creates it),php qt core:version(same "file.envnot found" error as before),php qt list(works).
Acceptance criteria
- The string
'core:env'no longer appears inConsoleAppAdapter. Environment::fileExists()exists, does not throw, works before helpers are loaded, and shares its file resolution withresolveEnvironmentFile().CliCommandhas$requiresEnvironment(defaulttrue) andrequiresEnvironment();EnvCommandsetsfalse.RunCommandStageblocks environment-requiringCliCommands when the env file is missing, with the unchangedEnvExceptionmessage and before anything runs.- The behavior table above holds, verified by tests and by the manual check.
EnvCommand's overwrite check uses the base-dir path.CHANGELOG.mdhas an entry under 3.1.0 (Changed): the console decides the reduced boot by the presence of the env file;core:envboots fully when the file exists;listandhelpwork without it; commands can declare$requiresEnvironment = false.- Docs-repo pages to update are listed in the PR (at least
cli/qt-command-line-tool.md,cli/console-commands.md,packages/console/*for the new property,packages/environment/*forfileExists()); the docs PR goes to thestagingbranch of the docs repo.
Workflow notes for the implementer
- Branch from an up-to-date
master:issue/<this-ticket-number>-console-env-preflight. - Small, single-purpose commits with
[#<number>]subjects; tests first in each step. - Keep the change minimal: no new classes, no new pipelines, no changes to how or when commands are registered.
- Follow the repo style:
declare(strict_types=1);, the Quantum file docblock,useimports ordered by line length (longest first), PHP 8.0 compatible.
Related
- 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.
- Make the console adapter constructor a plain boot pipeline, like WebAppAdapter #576: make
ConsoleAppAdapter::__construct()a plain list of boot stages likeWebAppAdapter. It depends on this ticket. - Introduce lifecycle hooks across boot, web runtime, console runtime, routing, and response flow #538: lifecycle events. Note for it: Symfony's
Application::run()callsexit()(auto-exit on), so a console "after run" event cannot fire in real runs unless auto-exit is disabled.
Activity
armanist commentedon Oct 14, 2026
Update: implementation approach changed
While preparing the final validation I found a regression in the approach this ticket describes (decide the reduced boot by whether the env file exists). With an existing but broken
.env(an empty file, or a Dotenv syntax error),core:env --yescan no longer repair it. Onmasterit can, because the old name check never reads.env. The file-existence check givescore:envthe full boot whenever the file exists, and the full boot fails on the broken file.New approach (Django's mechanism): boot optimistically and record the failure.
try/catch, and the failure is stored onConsoleContext.RunCommandStagere-throws the original exception only for aCliCommandthat requires the environment.core:env,list,helpand the unknown-command message still run in failure states.CliCommand::$requiresEnvironmentand theEnvCommandoverwrite check using the base-dir path.Environment::fileExists()and the reduced boot. No command names are used anywhere.Verification: I compared
master, the file-based branch and the new approach across 7 project states (ok,.envmissing, empty, bad syntax, broken app config, broken logging config,fs.phpcallingenv()with no.env) and 5 commands. The new approach is never worse thanmaster, and it repairs wherevermasterdid. The file-based branch regressed in the empty, bad-syntax and broken-config states.Known limits:
core:envruns in whatever state a failed boot leaves, so it must not rely on config.fs.phpthat callsenv()with no.envstill cannot be repaired. This is the same as onmaster..envno longer crashes. The app runs with the nameUNKNOWN.The ticket body will be updated to match when the implementation lands.
[quantum-php#575] Keep an empty app name and version instead of repla…
[#575] Add Environment::fileExists() to ask whether the env file exists
[#575] Let a command declare that it does not need the environment
[#575] Stop commands that need the environment when the env file is m…
[#575] Pin the console boot behavior before changing how it is decided
[#575] Decide the console boot by the environment file, not by the co…
[#575] Make core:env look for the existing .env in the base dir
[#575] Let the console context carry a boot failure
[#575] Boot the console optimistically and enforce a boot failure per…
[#575] Remove Environment::fileExists(), nothing decides by it any more
[#575] Add the changelog entries for the console boot change