Skip to content

Decide the console reduced boot by the environment file, not by the core:env command name #575

Closed

Description

@armanist

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:

  1. 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).
  2. 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(). EnvCommand declares false; every other command keeps the default true and 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.example to .env.
  • The normal console boot runs LoadEnvironmentStage, LoadAppConfigStage and SetupErrorHandlerStage before any command runs.
    • Environment::load() throws EnvException::fileNotFound(...) when the env file (or shared/config/env.php) is missing.
    • Config files call env(), which throws environmentNotLoaded when the environment is not loaded.
    • The error handler needs the logger config.
  • So without the env file the boot fails before core:env can create it. Since 2022 (commits 99526c56, 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:env always runs half-booted (no config, app.name falls back to UNKNOWN, no error handler), even when .env exists and a full boot would work.
  • With .env missing, even Symfony's built-in list and help fail 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 .env not 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 as resolveEnvironmentFile():
    • reads shared/config/env.php (missing file → false);
    • takes app_env (default Env::PRODUCTION) and builds the file name with the existing getEnvFileName() (.env for production, .env.<app_env> otherwise);
    • returns whether that file exists in the base directory.
  • Share the resolution logic between fileExists() and resolveEnvironmentFile() (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 the base_dir() helper; replace it with App::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 the setMutable(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:env reduced 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() is true, and (new Environment())->fileExists() is false, raise the same exception the normal boot raises. The simplest way is to call (new Environment())->load(), which throws EnvException::fileNotFound(...) with the exact file name (.env, .env.<app_env>, or the missing shared/config/env.php). Do not invent a new message.
  • Commands that are not CliCommand (Symfony's built-in list, 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_env is not production, the boot expects .env.<app_env>, but core:env always writes .env. Changing what core:env writes is a behavior change for a separate ticket.
  • EnvCommand uses FileSystemFactory::get(), which imports shared/config/fs.php. Harmless today (that file calls no env()); leave it.
  • The redundant setMutable(true) step (the only callers of updateRow(), KeyGenerateCommand and InstallToolkitCommand, 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() is true when the expected file exists.
  • fileExists() is false when it is missing (rename .env.testing temporarily).
  • fileExists() resolves the env-specific name from app_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 throws fileNotFound with 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() is true by default (use the existing TestCommand fixture).
  • EnvCommand::requiresEnvironment() is false.
  • EnvCommand asks for confirmation when the base-dir .env exists, 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 CliCommand that requires the environment is not run: the EnvException is thrown, output is empty, exit code stays ExitCode::SUCCESS.
  • With the env file missing, a CliCommand with $requiresEnvironment = false runs.
  • With the env file missing, a plain Symfony Command (not a CliCommand) 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: testConsoleAppAdapterBootsReducedStagesForCoreEnvCommand asserts the reduced boot for core:env while the env file exists. After this ticket core:env boots fully when the file exists. Replace it with two tests and say so in the commit message:
    • env file present, core:env → full boot (Environment registered, error handler registered, app config loaded);
    • env file missing, any command name → reduced boot (none of the three).
  • Env file missing and start() with a command that requires the environment → the EnvException is thrown before the command runs.
  • Env file missing and start() with list → succeeds.

Validation

Follow AGENTS.md:

  1. The narrowest PHPUnit paths first (tests/Unit/Environment, tests/Unit/Console, tests/Unit/App/Stages/Console, tests/Unit/App/Adapters/Console), then vendor/bin/phpunit tests/Unit/App.
  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 when those services are not running; report them as environmental, not as regressions.
  5. Manual check in a throwaway copy of the sample project (never the real one; core:env overwrites .env): with .env present: php qt list, php qt core:version, php qt core:env (asks to confirm), php qt core:env --yes; with .env removed: php qt core:env --yes (creates it), php qt core:version (same "file .env not found" error as before), php qt list (works).

Acceptance criteria

  • The string 'core:env' no longer appears in ConsoleAppAdapter.
  • Environment::fileExists() exists, does not throw, works before helpers are loaded, and shares its file resolution with resolveEnvironmentFile().
  • CliCommand has $requiresEnvironment (default true) and requiresEnvironment(); EnvCommand sets false.
  • RunCommandStage blocks environment-requiring CliCommands when the env file is missing, with the unchanged EnvException message 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.md has an entry under 3.1.0 (Changed): the console decides the reduced boot by the presence of the env file; core:env boots fully when the file exists; list and help work 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/* for fileExists()); the docs PR goes to the staging branch 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, 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 theissue type on Oct 13, 2026
  4. armanist commented on Oct 14, 2026

    @armanist
    CollaboratorAuthor

    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 --yes can no longer repair it. On master it can, because the old name check never reads .env. The file-existence check gives core:env the 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.

    • The console adapter runs the helpers stage first, and a failure there stays fatal.
    • The env, app config and error handler stages run inside a try/catch, and the failure is stored on ConsoleContext.
    • RunCommandStage re-throws the original exception only for a CliCommand that requires the environment. core:env, list, help and the unknown-command message still run in failure states.
    • If anything else throws while a failure is recorded (for example a command constructor), the recorded failure is thrown instead, so the real cause is not masked.
    • Kept from the original plan: CliCommand::$requiresEnvironment and the EnvCommand overwrite check using the base-dir path.
    • Dropped: 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, .env missing, empty, bad syntax, broken app config, broken logging config, fs.php calling env() with no .env) and 5 commands. The new approach is never worse than master, and it repairs wherever master did. The file-based branch regressed in the empty, bad-syntax and broken-config states.

    Known limits:

    • core:env runs in whatever state a failed boot leaves, so it must not rely on config.
    • An fs.php that calls env() with no .env still cannot be repaired. This is the same as on master.
    • An empty .env no longer crashes. The app runs with the name UNKNOWN.
    • PHP fatals cannot be caught.

    The ticket body will be updated to match when the implementation lands.

  5. added 9 commits that reference this issue on Oct 14, 2026
  6. added 2 commits that reference this issue on Oct 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or requestruntimeApp bootstrap and runtime pipeline

Type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions