From aa14d191853a2e5263179fed78b8f6f5d1a39895 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:50:14 +0400 Subject: [PATCH 1/7] [#589] End the pipeline run on a stop and add a closing stage The stop flag and the exit code move to AppContext. A stage that stops the context ends the run; the optional closing stage still runs, and the web runtime uses it to send the response. --- src/App/Adapters/Console/ConsoleContext.php | 13 -- src/App/Adapters/Web/WebAppAdapter.php | 3 +- src/App/Adapters/Web/WebContext.php | 18 +-- src/App/AppContext.php | 32 +++++ src/App/Pipeline.php | 34 +++++- tests/Unit/App/AppContextTest.php | 33 +++++ tests/Unit/App/PipelineTest.php | 129 +++++++++++++++++++- 7 files changed, 220 insertions(+), 42 deletions(-) diff --git a/src/App/Adapters/Console/ConsoleContext.php b/src/App/Adapters/Console/ConsoleContext.php index 764d85f1..03253ee8 100644 --- a/src/App/Adapters/Console/ConsoleContext.php +++ b/src/App/Adapters/Console/ConsoleContext.php @@ -13,7 +13,6 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Application; -use Quantum\App\Enums\ExitCode; use Quantum\App\AppContext; use Throwable; @@ -30,8 +29,6 @@ class ConsoleContext extends AppContext { private ?Throwable $bootFailure; - private int $exitCode = ExitCode::SUCCESS; - public function __construct(AppContext $appContext, ?Throwable $bootFailure = null) { parent::__construct($appContext->getBaseDir(), $appContext->getContainer()); @@ -61,14 +58,4 @@ public function getBootFailure(): ?Throwable { return $this->bootFailure; } - - public function getExitCode(): int - { - return $this->exitCode; - } - - public function setExitCode(int $exitCode): void - { - $this->exitCode = $exitCode; - } } diff --git a/src/App/Adapters/Web/WebAppAdapter.php b/src/App/Adapters/Web/WebAppAdapter.php index 0a1fe968..254467ec 100644 --- a/src/App/Adapters/Web/WebAppAdapter.php +++ b/src/App/Adapters/Web/WebAppAdapter.php @@ -61,8 +61,7 @@ public function start(): ?int new HandleRouteNotFoundStage(), new PrepareRequestStage(), new DispatchRequestStage(), - new SendResponseStage(), - ]); + ], new SendResponseStage()); $pipeline->run(new WebContext($this->context)); diff --git a/src/App/Adapters/Web/WebContext.php b/src/App/Adapters/Web/WebContext.php index cff8acff..6d0e5bf2 100644 --- a/src/App/Adapters/Web/WebContext.php +++ b/src/App/Adapters/Web/WebContext.php @@ -21,7 +21,7 @@ * Class WebContext * * Carries the state of a single web request through the web stages. - * It is an AppContext (same base directory and container) whose response and stopped flag are set by the stages; + * It is an AppContext (same base directory and container) whose response is set by the stages; * the response the stages set is the one that is sent. * The request, the routes and the view cache are read from the container, and the matched route from the request. * @@ -31,8 +31,6 @@ class WebContext extends AppContext { private ?Response $response = null; - private bool $stopped = false; - public function __construct(AppContext $appContext) { parent::__construct($appContext->getBaseDir(), $appContext->getContainer()); @@ -67,18 +65,4 @@ public function getViewCache(): ViewCache { return $this->resolveFromContainer(ViewCache::class); } - - /** - * Marks the request as finished early, so stages that compute a response skip their work. - * The stage that sends the response still runs. - */ - public function stop(): void - { - $this->stopped = true; - } - - public function isStopped(): bool - { - return $this->stopped; - } } diff --git a/src/App/AppContext.php b/src/App/AppContext.php index 6897d63c..33ab4362 100644 --- a/src/App/AppContext.php +++ b/src/App/AppContext.php @@ -11,11 +11,16 @@ namespace Quantum\App; use Quantum\Environment\Environment; +use Quantum\App\Enums\ExitCode; use Quantum\Di\DiContainer; use Quantum\Config\Config; /** * Class AppContext + * + * Carries the base directory and the container of an application, and the state of the run it is passed through: + * the stopped flag, which ends the pipeline run early, and the exit code. + * * @package Quantum\App */ class AppContext @@ -24,6 +29,10 @@ class AppContext private DiContainer $container; + private bool $stopped = false; + + private int $exitCode = ExitCode::SUCCESS; + public function __construct(string $baseDir, DiContainer $container) { $this->baseDir = $baseDir; @@ -50,6 +59,29 @@ public function getConfig(): Config return $this->resolveFromContainer(Config::class); } + /** + * Ends the pipeline run after the current stage; only the closing stage still runs + */ + public function stop(): void + { + $this->stopped = true; + } + + public function isStopped(): bool + { + return $this->stopped; + } + + public function getExitCode(): int + { + return $this->exitCode; + } + + public function setExitCode(int $exitCode): void + { + $this->exitCode = $exitCode; + } + /** * @template T of object * @param class-string $class diff --git a/src/App/Pipeline.php b/src/App/Pipeline.php index f4cbc9a7..5c86ec09 100644 --- a/src/App/Pipeline.php +++ b/src/App/Pipeline.php @@ -18,6 +18,8 @@ * * Runs stages in order with a context. The event a stage declares in its BEFORE / AFTER * constants is dispatched around it, with the context as the payload. + * A stage that stops the context ends the run: no later stage runs or dispatches events. + * The optional closing stage runs last, also after a stop, but not after a stage throws. * * @template TContext of AppContext * @package Quantum\App @@ -29,10 +31,16 @@ final class Pipeline */ private array $stages; + /** + * @var StageInterface|null + */ + private ?StageInterface $closingStage; + /** * @param array> $stages + * @param StageInterface|null $closingStage */ - public function __construct(array $stages = []) + public function __construct(array $stages = [], ?StageInterface $closingStage = null) { foreach ($stages as $stage) { if (!$stage instanceof StageInterface) { @@ -41,6 +49,7 @@ public function __construct(array $stages = []) } $this->stages = $stages; + $this->closingStage = $closingStage; } /** @@ -49,14 +58,31 @@ public function __construct(array $stages = []) public function run(AppContext $context): void { foreach ($this->stages as $stage) { - $this->dispatchStageEvent($stage, 'BEFORE', $context); + $this->runStage($stage, $context); - $stage->process($context); + if ($context->isStopped()) { + break; + } + } - $this->dispatchStageEvent($stage, 'AFTER', $context); + if ($this->closingStage !== null) { + $this->runStage($this->closingStage, $context); } } + /** + * @param StageInterface $stage + * @param TContext $context + */ + private function runStage(StageInterface $stage, AppContext $context): void + { + $this->dispatchStageEvent($stage, 'BEFORE', $context); + + $stage->process($context); + + $this->dispatchStageEvent($stage, 'AFTER', $context); + } + /** * Dispatches the event a stage declares for the given point, if any. * Skipped while the event() helper is not loaded yet, since nothing can listen then. diff --git a/tests/Unit/App/AppContextTest.php b/tests/Unit/App/AppContextTest.php index a075bb4c..d999812c 100644 --- a/tests/Unit/App/AppContextTest.php +++ b/tests/Unit/App/AppContextTest.php @@ -4,6 +4,7 @@ use Quantum\Environment\Environment; use PHPUnit\Framework\TestCase; +use Quantum\App\Enums\ExitCode; use Quantum\Di\DiContainer; use Quantum\App\AppContext; use Quantum\Config\Config; @@ -47,4 +48,36 @@ public function testAppContextGetConfig(): void $this->assertSame($config, $context->getConfig()); } + + public function testAppContextIsNotStoppedByDefault(): void + { + $context = new AppContext('/tmp', new DiContainer()); + + $this->assertFalse($context->isStopped()); + } + + public function testAppContextStop(): void + { + $context = new AppContext('/tmp', new DiContainer()); + + $context->stop(); + + $this->assertTrue($context->isStopped()); + } + + public function testAppContextExitCodeDefaultsToSuccess(): void + { + $context = new AppContext('/tmp', new DiContainer()); + + $this->assertSame(ExitCode::SUCCESS, $context->getExitCode()); + } + + public function testAppContextSetExitCode(): void + { + $context = new AppContext('/tmp', new DiContainer()); + + $context->setExitCode(ExitCode::INVALID); + + $this->assertSame(ExitCode::INVALID, $context->getExitCode()); + } } diff --git a/tests/Unit/App/PipelineTest.php b/tests/Unit/App/PipelineTest.php index 15fd1315..ffac2687 100644 --- a/tests/Unit/App/PipelineTest.php +++ b/tests/Unit/App/PipelineTest.php @@ -212,20 +212,137 @@ public function testPipelineSkipsStageEventsWhileEventHelperIsNotLoaded(): void $this->assertTrue($processed); } - public function testPipelineCarriesTheStoppedFlagOfAWebContextBetweenStages(): void + public function testPipelineEndsTheRunWhenAStageStopsTheContext(): void { - $stoppedSeenBySecondStage = null; + $log = []; (new Pipeline([ - $this->createStage(function (WebContext $context): void { + $this->createStage(function (AppContext $context) use (&$log): void { + $log[] = 'first'; $context->stop(); }), - $this->createStage(function (WebContext $context) use (&$stoppedSeenBySecondStage): void { - $stoppedSeenBySecondStage = $context->isStopped(); + $this->createStage(function () use (&$log): void { + $log[] = 'second'; }), ]))->run(new WebContext($this->context)); - $this->assertTrue($stoppedSeenBySecondStage); + $this->assertSame(['first'], $log); + } + + public function testPipelineDispatchesNoEventsOfTheStagesAfterAStop(): void + { + $this->loadHelpers(); + + $log = []; + + foreach (['test.first.before', 'test.first.after', 'test.second.before', 'test.second.after'] as $name) { + event()->listen($name, function () use (&$log, $name): void { + $log[] = $name; + }); + } + + (new Pipeline([ + $this->createFirstEventStage(function (AppContext $context): void { + $context->stop(); + }), + $this->createSecondEventStage(function (): void { + }), + ]))->run($this->context); + + $this->assertSame(['test.first.before', 'test.first.after'], $log); + } + + public function testPipelineRunsTheClosingStageAfterTheStages(): void + { + $log = []; + + (new Pipeline( + [ + $this->createStage(function () use (&$log): void { + $log[] = 'first'; + }), + $this->createStage(function () use (&$log): void { + $log[] = 'second'; + }), + ], + $this->createStage(function () use (&$log): void { + $log[] = 'closing'; + }) + ))->run($this->context); + + $this->assertSame(['first', 'second', 'closing'], $log); + } + + public function testPipelineRunsTheClosingStageAfterAStop(): void + { + $log = []; + + (new Pipeline( + [ + $this->createStage(function (AppContext $context) use (&$log): void { + $log[] = 'first'; + $context->stop(); + }), + $this->createStage(function () use (&$log): void { + $log[] = 'second'; + }), + ], + $this->createStage(function () use (&$log): void { + $log[] = 'closing'; + }) + ))->run($this->context); + + $this->assertSame(['first', 'closing'], $log); + } + + public function testPipelineDispatchesTheEventsOfTheClosingStageAfterAStop(): void + { + $this->loadHelpers(); + + $log = []; + + foreach (['test.second.before', 'test.second.after'] as $name) { + event()->listen($name, function () use (&$log, $name): void { + $log[] = $name; + }); + } + + (new Pipeline( + [ + $this->createStage(function (AppContext $context): void { + $context->stop(); + }), + ], + $this->createSecondEventStage(function () use (&$log): void { + $log[] = 'closing'; + }) + ))->run($this->context); + + $this->assertSame(['test.second.before', 'closing', 'test.second.after'], $log); + } + + public function testPipelineSkipsTheClosingStageWhenAStageThrows(): void + { + $closed = false; + + try { + (new Pipeline( + [ + $this->createStage(function (): void { + throw new RuntimeException('Stage failed'); + }), + ], + $this->createStage(function () use (&$closed): void { + $closed = true; + }) + ))->run($this->context); + + $this->fail('Expected the stage exception to propagate.'); + } catch (RuntimeException $exception) { + $this->assertSame('Stage failed', $exception->getMessage()); + } + + $this->assertFalse($closed); } public function testPipelineCarriesTheExitCodeOfAConsoleContextBetweenStages(): void From b3185992b949710054019ea653a2c55db577e311 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:02:14 +0400 Subject: [PATCH 2/7] [#589] Boot the app adapters in an explicit boot() step AppInterface gains boot(). The adapter constructors only store the context, and AppFactory boots the adapter right after creating it. --- .../Adapters/Console/ConsoleAppAdapter.php | 3 ++ src/App/Adapters/Web/WebAppAdapter.php | 3 ++ src/App/Contracts/AppInterface.php | 5 +++ src/App/Factories/AppFactory.php | 5 ++- .../Console/ConsoleAppAdapterTest.php | 33 ++++++++++++++++--- .../App/Adapters/Web/WebAppAdapterTest.php | 30 ++++++++++++++--- tests/Unit/App/AppTest.php | 20 ++++++++--- tests/Unit/App/Factories/AppFactoryTest.php | 11 +++++++ 8 files changed, 97 insertions(+), 13 deletions(-) diff --git a/src/App/Adapters/Console/ConsoleAppAdapter.php b/src/App/Adapters/Console/ConsoleAppAdapter.php index 21e9d0e6..9ab41f3a 100644 --- a/src/App/Adapters/Console/ConsoleAppAdapter.php +++ b/src/App/Adapters/Console/ConsoleAppAdapter.php @@ -42,7 +42,10 @@ class ConsoleAppAdapter implements AppInterface public function __construct(AppContext $context) { $this->context = $context; + } + public function boot(): void + { try { $pipeline = new Pipeline([ new InitConsoleStage(), diff --git a/src/App/Adapters/Web/WebAppAdapter.php b/src/App/Adapters/Web/WebAppAdapter.php index 254467ec..ee94c4ba 100644 --- a/src/App/Adapters/Web/WebAppAdapter.php +++ b/src/App/Adapters/Web/WebAppAdapter.php @@ -39,7 +39,10 @@ class WebAppAdapter implements AppInterface public function __construct(AppContext $context) { $this->context = $context; + } + public function boot(): void + { $pipeline = new Pipeline([ new LoadHelpersStage(), new LoadEnvironmentStage(), diff --git a/src/App/Contracts/AppInterface.php b/src/App/Contracts/AppInterface.php index c87f8dd9..ad16adb0 100644 --- a/src/App/Contracts/AppInterface.php +++ b/src/App/Contracts/AppInterface.php @@ -16,6 +16,11 @@ */ interface AppInterface { + /** + * Boots the app + */ + public function boot(): void; + /** * Starts the app */ diff --git a/src/App/Factories/AppFactory.php b/src/App/Factories/AppFactory.php index becfd518..58e00e79 100644 --- a/src/App/Factories/AppFactory.php +++ b/src/App/Factories/AppFactory.php @@ -68,6 +68,9 @@ private static function createInstance(string $type, string $baseDir): App $adapterClass = self::ADAPTERS[$type]; - return new App(new $adapterClass($context)); + $adapter = new $adapterClass($context); + $adapter->boot(); + + return new App($adapter); } } diff --git a/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php b/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php index 07aa0d75..3d7bf96e 100644 --- a/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php +++ b/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php @@ -196,11 +196,33 @@ public function testConsoleAppAdapterBootsAllStagesForCoreEnvCommandWhenTheEnvFi $this->assertTrue(config()->has('app')); } + public function testConsoleAppAdapterConstructorDoesNotBoot(): void + { + $_SERVER['argv'] = ['qt', 'list', '--quiet']; + + new ConsoleAppAdapter($this->createContext()); + + $this->assertFalse(Di::isRegistered(Application::class)); + $this->assertFalse(Di::has(Environment::class)); + } + + public function testConsoleAppAdapterBootRunsTheBootStages(): void + { + $_SERVER['argv'] = ['qt', 'list', '--quiet']; + + $adapter = new ConsoleAppAdapter($this->createContext()); + $adapter->boot(); + + $this->assertTrue(Di::isRegistered(Application::class)); + $this->assertTrue(Di::has(Environment::class)); + $this->assertTrue(config()->has('app')); + } + public function testConsoleAppAdapterRegistersTheConsoleObjectsInTheContainerAfterASuccessfulBoot(): void { $_SERVER['argv'] = ['qt', 'core:env', '--yes']; - new ConsoleAppAdapter($this->createContext()); + (new ConsoleAppAdapter($this->createContext()))->boot(); $this->assertTrue(Di::isRegistered(Application::class)); $this->assertTrue(Di::isRegistered(OutputInterface::class)); @@ -213,7 +235,7 @@ public function testConsoleAppAdapterRegistersTheConsoleObjectsInTheContainerAft $_SERVER['argv'] = ['qt', 'core:env', '--yes']; $this->withoutEnvironmentFile(function (): void { - new ConsoleAppAdapter($this->createContext()); + (new ConsoleAppAdapter($this->createContext()))->boot(); $this->assertTrue(Di::isRegistered(Application::class)); $this->assertTrue(Di::isRegistered(OutputInterface::class)); @@ -592,7 +614,7 @@ private function withFileContent(string $file, string $content, callable $test): } /** - * Creates the adapter on a fresh context (or the given one) with a test Application in the container: + * Creates and boots the adapter on a fresh context (or the given one) with a test Application in the container: * the given one, or one that does not exit and is built, and named, when start() first fetches it */ private function createAdapter(?Application $application = null, ?AppContext $context = null): ConsoleAppAdapter @@ -605,7 +627,10 @@ private function createAdapter(?Application $application = null, ?AppContext $co Di::register(NonExitingApplication::class, Application::class); } - return new ConsoleAppAdapter($context); + $adapter = new ConsoleAppAdapter($context); + $adapter->boot(); + + return $adapter; } /** diff --git a/tests/Unit/App/Adapters/Web/WebAppAdapterTest.php b/tests/Unit/App/Adapters/Web/WebAppAdapterTest.php index 47236e5a..5e35b03d 100644 --- a/tests/Unit/App/Adapters/Web/WebAppAdapterTest.php +++ b/tests/Unit/App/Adapters/Web/WebAppAdapterTest.php @@ -4,6 +4,7 @@ use Quantum\Router\Exceptions\RouteException; use Quantum\App\Adapters\Web\WebAppAdapter; +use Quantum\Environment\Environment; use Quantum\ResourceCache\ViewCache; use Quantum\Tests\Unit\AppTestCase; use Quantum\Http\Enums\ContentType; @@ -20,6 +21,7 @@ class WebAppAdapterTest extends AppTestCase public function setUp(): void { $this->webAppAdapter = new WebAppAdapter($this->createContext()); + $this->webAppAdapter->boot(); } public function tearDown(): void @@ -246,12 +248,32 @@ public function testWebAppAdapterStartPropagatesDispatchExceptionWithoutCleanup( $this->assertNotNull(request()->getUri()); } + public function testWebAppAdapterConstructorDoesNotBoot(): void + { + $this->clearAppContext(); + + new WebAppAdapter($this->createContext()); + + $this->assertFalse(Di::has(Environment::class)); + } + + public function testWebAppAdapterBootRunsTheBootStages(): void + { + $this->clearAppContext(); + + $adapter = new WebAppAdapter($this->createContext()); + $adapter->boot(); + + $this->assertTrue(Di::has(Environment::class)); + $this->assertTrue(config()->has('app')); + } + public function testWebAppAdapterBootFiresAppHelperListenerAtModulesBeforeEvent(): void { $this->withTemporaryAppHelper(function (): void { $context = $this->createContext(); - new WebAppAdapter($context); + (new WebAppAdapter($context))->boot(); $this->assertCount(1, $GLOBALS['bootEventPayloads']); $this->assertSame($context, $GLOBALS['bootEventPayloads'][0]['context']); @@ -261,7 +283,7 @@ public function testWebAppAdapterBootFiresAppHelperListenerAtModulesBeforeEvent( public function testWebAppAdapterBootRegistersAppHelperListenerBeforeAppConfigIsLoaded(): void { $this->withTemporaryAppHelper(function (): void { - new WebAppAdapter($this->createContext()); + (new WebAppAdapter($this->createContext()))->boot(); $this->assertFalse($GLOBALS['bootEventConfigLoadedAtRegistration']); $this->assertTrue(config()->has('app')); @@ -271,11 +293,11 @@ public function testWebAppAdapterBootRegistersAppHelperListenerBeforeAppConfigIs public function testWebAppAdapterSecondBootDoesNotRegisterAppHelperListenerAgain(): void { $this->withTemporaryAppHelper(function (): void { - new WebAppAdapter($this->createContext()); + (new WebAppAdapter($this->createContext()))->boot(); $this->assertCount(1, $GLOBALS['bootEventPayloads']); // Helper files load with require_once, so a new container does not get the listener again. - new WebAppAdapter($this->createContext()); + (new WebAppAdapter($this->createContext()))->boot(); $this->assertCount(1, $GLOBALS['bootEventPayloads']); }); } diff --git a/tests/Unit/App/AppTest.php b/tests/Unit/App/AppTest.php index f6e48438..be313d6c 100644 --- a/tests/Unit/App/AppTest.php +++ b/tests/Unit/App/AppTest.php @@ -28,7 +28,10 @@ public function tearDown(): void public function testAppGetAdapter(): void { - $app = new App(new WebAppAdapter($this->createContext())); + $adapter = new WebAppAdapter($this->createContext()); + $adapter->boot(); + + $app = new App($adapter); $this->assertInstanceOf(WebAppAdapter::class, $app->getAdapter()); @@ -36,7 +39,10 @@ public function testAppGetAdapter(): void config()->flush(); - $app = new App(new ConsoleAppAdapter($this->createContext())); + $adapter = new ConsoleAppAdapter($this->createContext()); + $adapter->boot(); + + $app = new App($adapter); $this->assertInstanceOf(ConsoleAppAdapter::class, $app->getAdapter()); @@ -45,7 +51,10 @@ public function testAppGetAdapter(): void public function testAppCallingValidMethod(): void { - $app = new App(new WebAppAdapter($this->createContext())); + $adapter = new WebAppAdapter($this->createContext()); + $adapter->boot(); + + $app = new App($adapter); request()->create('GET', '/test/am/tests'); @@ -56,7 +65,10 @@ public function testAppCallingValidMethod(): void public function testAppCallingInvalidMethod(): void { - $app = new App(new WebAppAdapter($this->createContext())); + $adapter = new WebAppAdapter($this->createContext()); + $adapter->boot(); + + $app = new App($adapter); $this->expectException(AppException::class); diff --git a/tests/Unit/App/Factories/AppFactoryTest.php b/tests/Unit/App/Factories/AppFactoryTest.php index 1dc30f9c..b216b6a3 100644 --- a/tests/Unit/App/Factories/AppFactoryTest.php +++ b/tests/Unit/App/Factories/AppFactoryTest.php @@ -7,6 +7,7 @@ use Quantum\App\Contracts\AppInterface; use Quantum\App\Adapters\Web\WebAppAdapter; use Quantum\App\Factories\AppFactory; +use Quantum\Environment\Environment; use PHPUnit\Framework\TestCase; use Quantum\App\Enums\AppType; use Quantum\App\App; @@ -49,6 +50,16 @@ public function testAppFactoryWebAdapter(): void $this->assertInstanceOf(AppInterface::class, $app->getAdapter()); } + public function testAppFactoryBootsTheCreatedApp(): void + { + AppFactory::destroy(AppType::WEB); + + AppFactory::create(AppType::WEB, PROJECT_ROOT); + + $this->assertTrue(Di::has(Environment::class)); + $this->assertTrue(config()->has('app')); + } + public function testAppFactoryInvalidAdapter(): void { $this->expectException(AppException::class); From 29e7d132285b08c43c2237fefaa49cf4d5480d88 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:10:48 +0400 Subject: [PATCH 3/7] [#589] Drop the stop checks from the web stages The pipeline ends the run on a stop, so ResolveRouteStage, HandleRouteNotFoundStage, PrepareRequestStage and DispatchRequestStage no longer check isStopped() themselves. --- src/App/Stages/Web/DispatchRequestStage.php | 4 ---- src/App/Stages/Web/HandleRouteNotFoundStage.php | 2 +- src/App/Stages/Web/PrepareRequestStage.php | 4 ---- src/App/Stages/Web/ResolveRouteStage.php | 4 ---- src/App/Stages/Web/SendResponseStage.php | 2 +- .../Unit/App/Stages/Web/DispatchRequestStageTest.php | 11 ----------- .../App/Stages/Web/HandleRouteNotFoundStageTest.php | 11 ----------- .../Unit/App/Stages/Web/PrepareRequestStageTest.php | 10 ---------- tests/Unit/App/Stages/Web/ResolveRouteStageTest.php | 12 ------------ 9 files changed, 2 insertions(+), 58 deletions(-) diff --git a/src/App/Stages/Web/DispatchRequestStage.php b/src/App/Stages/Web/DispatchRequestStage.php index e87a493a..9c3303aa 100644 --- a/src/App/Stages/Web/DispatchRequestStage.php +++ b/src/App/Stages/Web/DispatchRequestStage.php @@ -39,10 +39,6 @@ class DispatchRequestStage implements StageInterface */ public function process(AppContext $context): void { - if ($context->isStopped()) { - return; - } - $matchedRoute = $context->getMatchedRoute(); if ($matchedRoute === null) { diff --git a/src/App/Stages/Web/HandleRouteNotFoundStage.php b/src/App/Stages/Web/HandleRouteNotFoundStage.php index 1b68f950..f7d27c8a 100644 --- a/src/App/Stages/Web/HandleRouteNotFoundStage.php +++ b/src/App/Stages/Web/HandleRouteNotFoundStage.php @@ -32,7 +32,7 @@ class HandleRouteNotFoundStage implements StageInterface */ public function process(AppContext $context): void { - if ($context->isStopped() || $context->getMatchedRoute() !== null) { + if ($context->getMatchedRoute() !== null) { return; } diff --git a/src/App/Stages/Web/PrepareRequestStage.php b/src/App/Stages/Web/PrepareRequestStage.php index 7e080d93..f19989ca 100644 --- a/src/App/Stages/Web/PrepareRequestStage.php +++ b/src/App/Stages/Web/PrepareRequestStage.php @@ -38,10 +38,6 @@ class PrepareRequestStage implements StageInterface */ public function process(AppContext $context): void { - if ($context->isStopped()) { - return; - } - LangFactory::get(); $this->logDebugInfo(); diff --git a/src/App/Stages/Web/ResolveRouteStage.php b/src/App/Stages/Web/ResolveRouteStage.php index 2e802cb4..a261877c 100644 --- a/src/App/Stages/Web/ResolveRouteStage.php +++ b/src/App/Stages/Web/ResolveRouteStage.php @@ -32,10 +32,6 @@ class ResolveRouteStage implements StageInterface */ public function process(AppContext $context): void { - if ($context->isStopped()) { - return; - } - $request = $context->getRequest(); $request->setMatchedRoute((new RouteFinder($context->getRoutes()))->find($request)); diff --git a/src/App/Stages/Web/SendResponseStage.php b/src/App/Stages/Web/SendResponseStage.php index e3874e5b..66c06d6c 100644 --- a/src/App/Stages/Web/SendResponseStage.php +++ b/src/App/Stages/Web/SendResponseStage.php @@ -27,7 +27,7 @@ * Class SendResponseStage * * Sends the response of the request and always clears the request state afterwards, - * even when sending fails. This stage never checks WebContext::isStopped(). + * even when sending fails. It is the closing stage of the web runtime, so it also runs after a stop. * * @implements StageInterface * @package Quantum\App diff --git a/tests/Unit/App/Stages/Web/DispatchRequestStageTest.php b/tests/Unit/App/Stages/Web/DispatchRequestStageTest.php index 0ba2d180..55a50775 100644 --- a/tests/Unit/App/Stages/Web/DispatchRequestStageTest.php +++ b/tests/Unit/App/Stages/Web/DispatchRequestStageTest.php @@ -176,17 +176,6 @@ public function testDispatchRequestStageFailsWithoutViewCache(): void (new DispatchRequestStage())->process($this->webContext); } - public function testDispatchRequestStageSkipsWhenContextIsStopped(): void - { - $this->prepareRequest('/test/am/tests'); - - $this->webContext->stop(); - - (new DispatchRequestStage())->process($this->webContext); - - $this->assertNull($this->webContext->getResponse()); - } - public function testDispatchRequestStagePassesTheContextRequestToTheMiddlewares(): void { $this->prepareRequest('/test/am/tests'); diff --git a/tests/Unit/App/Stages/Web/HandleRouteNotFoundStageTest.php b/tests/Unit/App/Stages/Web/HandleRouteNotFoundStageTest.php index af6223f7..aa6ed2df 100644 --- a/tests/Unit/App/Stages/Web/HandleRouteNotFoundStageTest.php +++ b/tests/Unit/App/Stages/Web/HandleRouteNotFoundStageTest.php @@ -88,15 +88,4 @@ public function testHandleRouteNotFoundStageIgnoresMatchedRoute(): void $this->assertNull($this->webContext->getResponse()); $this->assertFalse($this->webContext->isStopped()); } - - public function testHandleRouteNotFoundStageSkipsWhenContextIsStopped(): void - { - request()->create('GET', '/non-existing-uri'); - - $this->webContext->stop(); - - (new HandleRouteNotFoundStage())->process($this->webContext); - - $this->assertNull($this->webContext->getResponse()); - } } diff --git a/tests/Unit/App/Stages/Web/PrepareRequestStageTest.php b/tests/Unit/App/Stages/Web/PrepareRequestStageTest.php index 2fd13f97..93e341cc 100644 --- a/tests/Unit/App/Stages/Web/PrepareRequestStageTest.php +++ b/tests/Unit/App/Stages/Web/PrepareRequestStageTest.php @@ -159,14 +159,4 @@ public function testPrepareRequestStageSetsUpTheViewCacheOfTheContextContainer() } } } - - public function testPrepareRequestStageSkipsWhenContextIsStopped(): void - { - $this->webContext->stop(); - - (new PrepareRequestStage())->process($this->webContext); - - $this->assertFalse(config()->has('lang')); - $this->assertFalse(Di::isRegistered(ViewCache::class)); - } } diff --git a/tests/Unit/App/Stages/Web/ResolveRouteStageTest.php b/tests/Unit/App/Stages/Web/ResolveRouteStageTest.php index 306b48a4..726dc19a 100644 --- a/tests/Unit/App/Stages/Web/ResolveRouteStageTest.php +++ b/tests/Unit/App/Stages/Web/ResolveRouteStageTest.php @@ -58,18 +58,6 @@ public function testResolveRouteStageClearsARouteSetBeforeItWhenNoRouteMatches() $this->assertNull(request()->getMatchedRoute()); } - public function testResolveRouteStageSkipsWhenContextIsStopped(): void - { - request()->create('GET', '/test/am/tests'); - - $this->webContext->stop(); - - (new ResolveRouteStage())->process($this->webContext); - - $this->assertNull($this->webContext->getMatchedRoute()); - $this->assertNull(request()->getMatchedRoute()); - } - public function testResolveRouteStageReadsTheRequestAndRoutesFromTheContext(): void { request()->create('GET', '/test/am/tests'); From b72b2aab9391d14acfb5f2e3655cadaed4c2073e Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:44:42 +0400 Subject: [PATCH 4/7] [#589] Resolve the file system when the core commands run DebugBarCommand, OpenApiCommand and ResourceCacheClearCommand get the file system in exec() instead of in their constructor, so registering the core commands loads no config. A test guards that registering them needs neither the environment nor any config. --- src/Console/Commands/DebugBarCommand.php | 31 +++++---------- src/Console/Commands/OpenApiCommand.php | 37 +++++++----------- .../Commands/ResourceCacheClearCommand.php | 38 ++++++++----------- tests/Unit/Console/CommandRegistrarTest.php | 16 ++++++++ .../Console/Commands/DebugBarCommandTest.php | 9 +---- .../Console/Commands/OpenApiCommandTest.php | 9 +---- .../ResourceCacheClearCommandTest.php | 9 +---- 7 files changed, 59 insertions(+), 90 deletions(-) diff --git a/src/Console/Commands/DebugBarCommand.php b/src/Console/Commands/DebugBarCommand.php index 19cc23a5..aece0203 100644 --- a/src/Console/Commands/DebugBarCommand.php +++ b/src/Console/Commands/DebugBarCommand.php @@ -12,7 +12,9 @@ use Quantum\Storage\Exceptions\FileSystemException; use Quantum\Storage\Factories\FileSystemFactory; +use Quantum\Config\Exceptions\ConfigException; use Quantum\App\Exceptions\BaseException; +use Quantum\Di\Exceptions\DiException; use Quantum\Storage\FileSystem; use Quantum\Console\CliCommand; use ReflectionException; @@ -23,11 +25,6 @@ */ class DebugBarCommand extends CliCommand { - /** - * File System - */ - protected FileSystem $fs; - /** * Command name */ @@ -53,28 +50,20 @@ class DebugBarCommand extends CliCommand */ private string $vendorDebugBarFolderPath = 'vendor/php-debugbar/php-debugbar/src/DebugBar/Resources'; - /** - * @throws BaseException|ReflectionException - */ - public function __construct() - { - parent::__construct(); - $this->fs = FileSystemFactory::get(); - } - /** * Executes the command and publishes the debug bar assets - * @throws FileSystemException|BaseException + * @throws FileSystemException|ConfigException|DiException|BaseException|ReflectionException */ public function exec(): void { + $fs = FileSystemFactory::get(); - if ($this->fs->exists(assets_dir() . DS . 'DebugBar' . DS . 'Resources' . DS . 'debugbar.css')) { + if ($fs->exists(assets_dir() . DS . 'DebugBar' . DS . 'Resources' . DS . 'debugbar.css')) { $this->error('The debug ber already installed'); return; } - $this->copyResources($this->vendorDebugBarFolderPath, $this->publicDebugBarFolderPath); + $this->copyResources($fs, $this->vendorDebugBarFolderPath, $this->publicDebugBarFolderPath); $this->info('Debugbar resources successfully published'); } @@ -83,19 +72,19 @@ public function exec(): void * Recursively copies the debug bar assets * @throws FileSystemException */ - private function copyResources(string $src, string $dst): void + private function copyResources(FileSystem $fs, string $src, string $dst): void { $dir = opendir($src); - if ($dst != $this->publicDebugBarFolderPath && $this->fs->makeDirectory($dst) === false) { + if ($dst != $this->publicDebugBarFolderPath && $fs->makeDirectory($dst) === false) { throw FileSystemException::directoryNotWritable($dst); } if (is_resource($dir)) { while (($file = readdir($dir))) { if (($file !== '.') && ($file !== '..')) { - if ($this->fs->isDirectory($src . DS . $file)) { - $this->copyResources($src . DS . $file, $dst . DS . $file); + if ($fs->isDirectory($src . DS . $file)) { + $this->copyResources($fs, $src . DS . $file, $dst . DS . $file); } else { copy($src . DS . $file, $dst . DS . $file); } diff --git a/src/Console/Commands/OpenApiCommand.php b/src/Console/Commands/OpenApiCommand.php index 7c8891d5..0a590245 100644 --- a/src/Console/Commands/OpenApiCommand.php +++ b/src/Console/Commands/OpenApiCommand.php @@ -11,6 +11,7 @@ namespace Quantum\Console\Commands; use Quantum\Storage\Factories\FileSystemFactory; +use Quantum\Config\Exceptions\ConfigException; use Quantum\Module\Exceptions\ModuleException; use Quantum\Router\Exceptions\RouteException; use Quantum\App\Exceptions\BaseException; @@ -30,18 +31,6 @@ */ class OpenApiCommand extends CliCommand { - /** - * File System - */ - protected FileSystem $fs; - - public function __construct() - { - parent::__construct(); - - $this->fs = FileSystemFactory::get(); - } - /** * Command name */ @@ -83,10 +72,12 @@ public function __construct() /** * Executes the command and generate Open API specifications - * @throws ModuleException|RouteException|DiException|BaseException|ReflectionException + * @throws ModuleException|RouteException|ConfigException|DiException|BaseException|ReflectionException */ public function exec(): void { + $fs = FileSystemFactory::get(); + if (!Di::isRegistered(ModuleLoader::class)) { Di::register(ModuleLoader::class); } @@ -110,34 +101,34 @@ public function exec(): void $routes = $modulePath . DS . 'routes' . DS . 'routes.php'; - if (!$this->fs->exists(assets_dir() . DS . 'OpenApiUi' . DS . 'index.css')) { + if (!$fs->exists(assets_dir() . DS . 'OpenApiUi' . DS . 'index.css')) { $this->copyResources(); $this->info('OpenApi resources successfully published'); } - if (!$this->fs->isDirectory($modulePath)) { + if (!$fs->isDirectory($modulePath)) { $this->error('The module `' . ucfirst($module) . '` not found'); return; } - if (request()->routeGroupExists('openapi', $module) && $this->fs->exists($modulePath . DS . 'resources' . DS . 'openapi' . DS . 'spec.json')) { + if (request()->routeGroupExists('openapi', $module) && $fs->exists($modulePath . DS . 'resources' . DS . 'openapi' . DS . 'spec.json')) { $this->error('The Open API specifications already installed for `' . ucfirst($module) . '` module'); return; } if (!request()->routeGroupExists('openapi', $module)) { - $routeContent = $this->fs->get($routes); + $routeContent = $fs->get($routes); if ($routeContent !== false) { - $this->fs->put($routes, str_replace('return function ($route) {', $this->openapiRoutes($module), $routeContent)); + $fs->put($routes, str_replace('return function ($route) {', $this->openapiRoutes($module), $routeContent)); } } - if (!$this->fs->isDirectory($modulePath . DS . 'resources' . DS . 'openapi')) { - $this->fs->makeDirectory($modulePath . DS . 'resources' . DS . 'openapi'); + if (!$fs->isDirectory($modulePath . DS . 'resources' . DS . 'openapi')) { + $fs->makeDirectory($modulePath . DS . 'resources' . DS . 'openapi'); } - $this->generateOpenapiSpecification($module); + $this->generateOpenapiSpecification($fs, $module); } /** @@ -161,7 +152,7 @@ private function copyResources(): void /** * Generates file with OpenApi specifications */ - private function generateOpenapiSpecification(string $module): void + private function generateOpenapiSpecification(FileSystem $fs, string $module): void { $annotationPath = modules_dir() . DS . $module . DS . 'Controllers' . DS . 'OpenApi' . DS; @@ -174,7 +165,7 @@ private function generateOpenapiSpecification(string $module): void return; } - $this->fs->put($specPath, $openApi->toJson()); + $fs->put($specPath, $openApi->toJson()); $this->info('OpenAPI specification generated successfully.'); } diff --git a/src/Console/Commands/ResourceCacheClearCommand.php b/src/Console/Commands/ResourceCacheClearCommand.php index fca33e9c..55ebb163 100644 --- a/src/Console/Commands/ResourceCacheClearCommand.php +++ b/src/Console/Commands/ResourceCacheClearCommand.php @@ -67,19 +67,13 @@ class ResourceCacheClearCommand extends CliCommand protected string $cacheDir = ''; - protected FileSystem $fs; - /** - * @throws BaseException|ReflectionException + * @throws ConfigException|DiException|BaseException|ReflectionException */ - public function __construct() - { - parent::__construct(); - $this->fs = FileSystemFactory::get(); - } - public function exec(): void { + $fs = FileSystemFactory::get(); + try { $this->importConfig(); $this->initModule($this->getOption('module')); @@ -89,15 +83,15 @@ public function exec(): void return; } - if (!$this->fs->isDirectory($this->cacheDir)) { + if (!$fs->isDirectory($this->cacheDir)) { $this->error('Cache directory does not exist or is not accessible.'); return; } if ($this->module || $this->type) { - $this->clearResourceModuleAndType($this->module, $this->type); + $this->clearResourceModuleAndType($fs, $this->module, $this->type); } elseif (!empty($this->getOption('all'))) { - $this->removeFilesFromDirectory($this->cacheDir); + $this->removeFilesFromDirectory($fs, $this->cacheDir); } else { $this->error('Please specify at least one of the following options: --all, --module=moduleName or --type=typeName!'); return; @@ -167,7 +161,7 @@ private function initType(?string $typeOption): void } } - private function clearResourceModuleAndType(?string $moduleName = null, ?string $type = null): void + private function clearResourceModuleAndType(FileSystem $fs, ?string $moduleName = null, ?string $type = null): void { $dir = $this->cacheDir; @@ -183,24 +177,24 @@ private function clearResourceModuleAndType(?string $moduleName = null, ?string $dir = $dir . DS . strtolower($moduleName); } - $this->removeFilesFromDirectory($dir); + $this->removeFilesFromDirectory($fs, $dir); } - private function removeFilesFromDirectory(string $dir): void + private function removeFilesFromDirectory(FileSystem $fs, string $dir): void { - $entries = $this->fs->glob($dir . DS . '*'); + $entries = $fs->glob($dir . DS . '*'); foreach ($entries as $entry) { - if ($this->fs->isDirectory($entry)) { - $this->removeFilesFromDirectory($entry); + if ($fs->isDirectory($entry)) { + $this->removeFilesFromDirectory($fs, $entry); - if ($this->fs->fileName($entry) !== config()->get('view_cache.cache_dir', 'cache') && - count($this->fs->glob($entry . DS . '*')) === 0 + if ($fs->fileName($entry) !== config()->get('view_cache.cache_dir', 'cache') && + count($fs->glob($entry . DS . '*')) === 0 ) { - $this->fs->removeDirectory($entry); + $fs->removeDirectory($entry); } } else { - $this->fs->remove($entry); + $fs->remove($entry); } } } diff --git a/tests/Unit/Console/CommandRegistrarTest.php b/tests/Unit/Console/CommandRegistrarTest.php index 6fb0483f..10f26a4d 100644 --- a/tests/Unit/Console/CommandRegistrarTest.php +++ b/tests/Unit/Console/CommandRegistrarTest.php @@ -4,9 +4,12 @@ use Quantum\Tests\_root\shared\Commands\TestCommand; use Symfony\Component\Console\Command\Command; +use Quantum\App\Stages\Boot\LoadHelpersStage; use Symfony\Component\Console\Application; +use Quantum\Environment\Environment; use Quantum\Console\CommandRegistrar; use Quantum\Tests\Unit\AppTestCase; +use Quantum\Di\Di; class CommandRegistrarTest extends AppTestCase { @@ -63,4 +66,17 @@ public function testCommandRegistrarReplacesAnExistingCommandWithTheSameName(): $this->assertInstanceOf(TestCommand::class, $this->application->get('test:dummy')); } + + public function testCommandRegistrarRegistersTheCoreCommandsWithoutLoadingTheEnvironmentOrAnyConfig(): void + { + $this->clearAppContext(); + + (new LoadHelpersStage())->process($this->createContext()); + + CommandRegistrar::register($this->application, dirname(__DIR__, 3) . DS . 'src' . DS . 'Console' . DS . 'Commands', '\\Quantum\\Console\\Commands\\'); + + $this->assertTrue($this->application->has('core:env')); + $this->assertFalse(Di::has(Environment::class)); + $this->assertNull(config()->all()); + } } diff --git a/tests/Unit/Console/Commands/DebugBarCommandTest.php b/tests/Unit/Console/Commands/DebugBarCommandTest.php index 589a2ea9..d1e4898b 100644 --- a/tests/Unit/Console/Commands/DebugBarCommandTest.php +++ b/tests/Unit/Console/Commands/DebugBarCommandTest.php @@ -5,7 +5,6 @@ use Symfony\Component\Console\Tester\CommandTester; use Quantum\Console\Commands\DebugBarCommand; use Quantum\Tests\Unit\AppTestCase; -use Quantum\Storage\FileSystem; use ReflectionMethod; class DebugBarCommandTest extends AppTestCase @@ -29,12 +28,6 @@ public function testCommandMetadata(): void $this->assertSame('The command will publish debugbar assets', $this->command->getHelp()); } - public function testConstructorInitializesFileSystem(): void - { - $fs = $this->getPrivateProperty($this->command, 'fs'); - $this->assertInstanceOf(FileSystem::class, $fs); - } - public function testExecShowsErrorWhenAlreadyInstalled(): void { $assetsPath = assets_dir() . DS . 'DebugBar' . DS . 'Resources'; @@ -67,7 +60,7 @@ public function testCopyResourcesRecursivelyCopiesFiles(): void $method = new ReflectionMethod($this->command, 'copyResources'); $method->setAccessible(true); - $method->invoke($this->command, $sourceDir, $targetDir); + $method->invoke($this->command, $this->fs, $sourceDir, $targetDir); $this->assertFileExists($targetDir . DS . 'root.css'); $this->assertFileExists($targetDir . DS . 'nested' . DS . 'nested.js'); diff --git a/tests/Unit/Console/Commands/OpenApiCommandTest.php b/tests/Unit/Console/Commands/OpenApiCommandTest.php index cfe6db7c..f4572829 100644 --- a/tests/Unit/Console/Commands/OpenApiCommandTest.php +++ b/tests/Unit/Console/Commands/OpenApiCommandTest.php @@ -6,7 +6,6 @@ use Quantum\Console\Commands\OpenApiCommand; use Quantum\Tests\Unit\AppTestCase; use Quantum\Router\RouteCollection; -use Quantum\Storage\FileSystem; use ReflectionMethod; use Quantum\Di\Di; @@ -36,12 +35,6 @@ public function testCommandArgumentsAreRegistered(): void $this->assertTrue($definition->getArgument('module')->isRequired()); } - public function testConstructorInitializesFileSystem(): void - { - $fs = $this->getPrivateProperty($this->command, 'fs'); - $this->assertInstanceOf(FileSystem::class, $fs); - } - public function testExecShowsErrorWhenModuleIsMissing(): void { $openApiAssets = assets_dir() . DS . 'OpenApiUi'; @@ -191,7 +184,7 @@ public function error(string $message): void try { $method = new ReflectionMethod($command, 'generateOpenapiSpecification'); $method->setAccessible(true); - $method->invoke($command, $moduleName); + $method->invoke($command, $this->fs, $moduleName); $this->assertFileExists($specPath); $this->assertNotSame('', trim((string) file_get_contents($specPath))); diff --git a/tests/Unit/Console/Commands/ResourceCacheClearCommandTest.php b/tests/Unit/Console/Commands/ResourceCacheClearCommandTest.php index 8fa6a4c3..6268ae76 100644 --- a/tests/Unit/Console/Commands/ResourceCacheClearCommandTest.php +++ b/tests/Unit/Console/Commands/ResourceCacheClearCommandTest.php @@ -5,7 +5,6 @@ use Quantum\Console\Commands\ResourceCacheClearCommand; use Symfony\Component\Console\Tester\CommandTester; use Quantum\Tests\Unit\AppTestCase; -use Quantum\Storage\FileSystem; use ReflectionMethod; class ResourceCacheClearCommandTest extends AppTestCase @@ -38,12 +37,6 @@ public function testCommandOptionsAreRegistered(): void $this->assertTrue($definition->hasOption('module')); } - public function testConstructorInitializesFileSystem(): void - { - $fs = $this->getPrivateProperty($this->command, 'fs'); - $this->assertInstanceOf(FileSystem::class, $fs); - } - public function testExecShowsErrorWhenNoOptionsProvided(): void { config()->set('view_cache', ['cache_dir' => 'cache']); @@ -104,7 +97,7 @@ public function testClearResourceModuleAndTypeRemovesMatchedFiles(): void $method = new ReflectionMethod($this->command, 'clearResourceModuleAndType'); $method->setAccessible(true); - $method->invoke($this->command, 'blog', 'views'); + $method->invoke($this->command, $this->fs, 'blog', 'views'); $this->assertFileDoesNotExist($moduleDir . DS . 'view.php'); From 55c6d98fcfeb467ff2b688a4b68d446635e79424 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:02:36 +0400 Subject: [PATCH 5/7] [#589] Boot the console without catching any failure The console boot registers the core commands after the helpers and the app commands at its end, after the environment, the config and the error handler. The new ResolveEnvironmentStage lets core:env, list and help run when the .env file is missing or malformed by stopping the boot; every other command fails with the exception of the environment file. The boot failure, both try/catch blocks of ConsoleAppAdapter and the boot failure branch of RunCommandStage are removed. --- .../Adapters/Console/ConsoleAppAdapter.php | 47 ++-- src/App/Adapters/Console/ConsoleContext.php | 15 +- src/App/Stages/Boot/InitConsoleStage.php | 4 +- .../RegisterAppCommandsStage.php | 16 +- .../RegisterCoreCommandsStage.php | 18 +- .../Stages/Boot/ResolveEnvironmentStage.php | 72 ++++++ src/App/Stages/Console/RunCommandStage.php | 13 +- src/Environment/Environment.php | 4 +- .../Console/ConsoleAppAdapterTest.php | 223 +++++++++--------- .../Adapters/Console/ConsoleContextTest.php | 15 -- .../RegisterAppCommandsStageTest.php | 5 +- .../RegisterCoreCommandsStageTest.php | 5 +- .../Boot/ResolveEnvironmentStageTest.php | 178 ++++++++++++++ .../Stages/Console/ConsoleStageTestCase.php | 5 +- .../Stages/Console/RunCommandStageTest.php | 63 +---- 15 files changed, 427 insertions(+), 256 deletions(-) rename src/App/Stages/{Console => Boot}/RegisterAppCommandsStage.php (69%) rename src/App/Stages/{Console => Boot}/RegisterCoreCommandsStage.php (64%) create mode 100644 src/App/Stages/Boot/ResolveEnvironmentStage.php rename tests/Unit/App/Stages/{Console => Boot}/RegisterAppCommandsStageTest.php (87%) rename tests/Unit/App/Stages/{Console => Boot}/RegisterCoreCommandsStageTest.php (87%) create mode 100644 tests/Unit/App/Stages/Boot/ResolveEnvironmentStageTest.php diff --git a/src/App/Adapters/Console/ConsoleAppAdapter.php b/src/App/Adapters/Console/ConsoleAppAdapter.php index 9ab41f3a..9ca781b6 100644 --- a/src/App/Adapters/Console/ConsoleAppAdapter.php +++ b/src/App/Adapters/Console/ConsoleAppAdapter.php @@ -11,8 +11,9 @@ namespace Quantum\App\Adapters\Console; use Quantum\App\Stages\Boot\SetupConsoleApplicationStage; -use Quantum\App\Stages\Console\RegisterCoreCommandsStage; -use Quantum\App\Stages\Console\RegisterAppCommandsStage; +use Quantum\App\Stages\Boot\RegisterCoreCommandsStage; +use Quantum\App\Stages\Boot\RegisterAppCommandsStage; +use Quantum\App\Stages\Boot\ResolveEnvironmentStage; use Quantum\App\Stages\Boot\SetupErrorHandlerStage; use Quantum\App\Stages\Boot\LoadEnvironmentStage; use Quantum\App\Stages\Console\RunCommandStage; @@ -22,7 +23,6 @@ use Quantum\App\Contracts\AppInterface; use Quantum\App\AppContext; use Quantum\App\Pipeline; -use Throwable; use Exception; if (!defined('DS')) { @@ -37,8 +37,6 @@ class ConsoleAppAdapter implements AppInterface { protected AppContext $context; - protected ?Throwable $bootFailure = null; - public function __construct(AppContext $context) { $this->context = $context; @@ -46,40 +44,33 @@ public function __construct(AppContext $context) public function boot(): void { - try { - $pipeline = new Pipeline([ - new InitConsoleStage(), - new LoadHelpersStage(), - new LoadEnvironmentStage(), - new LoadAppConfigStage(), - new SetupErrorHandlerStage(), - new SetupConsoleApplicationStage(), - ]); + $pipeline = new Pipeline([ + new InitConsoleStage(), + new LoadHelpersStage(), + new RegisterCoreCommandsStage(), + new ResolveEnvironmentStage(), + new LoadEnvironmentStage(), + new LoadAppConfigStage(), + new SetupErrorHandlerStage(), + new SetupConsoleApplicationStage(), + new RegisterAppCommandsStage(), + ]); - $pipeline->run($this->context); - } catch (Throwable $exception) { - $this->bootFailure = $exception; - } + $pipeline->run($this->context); } /** - * @throws Exception|Throwable - */ + * @throws Exception + */ public function start(): ?int { $pipeline = new Pipeline([ - new RegisterCoreCommandsStage(), - new RegisterAppCommandsStage(), new RunCommandStage(), ]); - $context = new ConsoleContext($this->context, $this->bootFailure); + $context = new ConsoleContext($this->context); - try { - $pipeline->run($context); - } catch (Throwable $exception) { - throw $this->bootFailure ?? $exception; - } + $pipeline->run($context); return $context->getExitCode(); } diff --git a/src/App/Adapters/Console/ConsoleContext.php b/src/App/Adapters/Console/ConsoleContext.php index 03253ee8..4c10a3d2 100644 --- a/src/App/Adapters/Console/ConsoleContext.php +++ b/src/App/Adapters/Console/ConsoleContext.php @@ -14,7 +14,6 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Application; use Quantum\App\AppContext; -use Throwable; /** * Class ConsoleContext @@ -27,13 +26,9 @@ */ class ConsoleContext extends AppContext { - private ?Throwable $bootFailure; - - public function __construct(AppContext $appContext, ?Throwable $bootFailure = null) + public function __construct(AppContext $appContext) { parent::__construct($appContext->getBaseDir(), $appContext->getContainer()); - - $this->bootFailure = $bootFailure; } public function getApplication(): Application @@ -50,12 +45,4 @@ public function getOutput(): OutputInterface { return $this->resolveFromContainer(OutputInterface::class); } - - /** - * The failure of the boot that the console run continued after, if there was one - */ - public function getBootFailure(): ?Throwable - { - return $this->bootFailure; - } } diff --git a/src/App/Stages/Boot/InitConsoleStage.php b/src/App/Stages/Boot/InitConsoleStage.php index c3f36d3a..4c026a4c 100644 --- a/src/App/Stages/Boot/InitConsoleStage.php +++ b/src/App/Stages/Boot/InitConsoleStage.php @@ -22,8 +22,8 @@ /** * Class InitConsoleStage * - * Runs before the helpers are loaded, so that the console application, input and output exist - * even when a helper file fails; for the same reason it declares no boot events. + * Runs first in the console boot, before the helpers are loaded, so it declares no boot events. + * The core command registration and ResolveEnvironmentStage read the application and the input it registers. * * @implements StageInterface * @package Quantum\App diff --git a/src/App/Stages/Console/RegisterAppCommandsStage.php b/src/App/Stages/Boot/RegisterAppCommandsStage.php similarity index 69% rename from src/App/Stages/Console/RegisterAppCommandsStage.php rename to src/App/Stages/Boot/RegisterAppCommandsStage.php index 556b5ed4..f9e6efc2 100644 --- a/src/App/Stages/Console/RegisterAppCommandsStage.php +++ b/src/App/Stages/Boot/RegisterAppCommandsStage.php @@ -8,30 +8,34 @@ * @link https://quantumphp.io */ -namespace Quantum\App\Stages\Console; +namespace Quantum\App\Stages\Boot; -use Quantum\App\Adapters\Console\ConsoleContext; +use Symfony\Component\Console\Application; use Quantum\App\Contracts\StageInterface; +use Quantum\Di\Exceptions\DiException; use Quantum\Console\CommandRegistrar; use Quantum\App\AppContext; use ReflectionException; +use Quantum\Di\Di; /** * Class RegisterAppCommandsStage - * @implements StageInterface + * + * Runs last in the console boot, so the constructors of the app commands can use the environment and the config. + * + * @implements StageInterface * @package Quantum\App */ class RegisterAppCommandsStage implements StageInterface { /** * Registers the commands of the application - * @param ConsoleContext $context - * @throws ReflectionException + * @throws DiException|ReflectionException */ public function process(AppContext $context): void { CommandRegistrar::register( - $context->getApplication(), + Di::get(Application::class), $this->directory(), $this->namespace(), ); diff --git a/src/App/Stages/Console/RegisterCoreCommandsStage.php b/src/App/Stages/Boot/RegisterCoreCommandsStage.php similarity index 64% rename from src/App/Stages/Console/RegisterCoreCommandsStage.php rename to src/App/Stages/Boot/RegisterCoreCommandsStage.php index 7162b42c..ce2b047f 100644 --- a/src/App/Stages/Console/RegisterCoreCommandsStage.php +++ b/src/App/Stages/Boot/RegisterCoreCommandsStage.php @@ -8,30 +8,36 @@ * @link https://quantumphp.io */ -namespace Quantum\App\Stages\Console; +namespace Quantum\App\Stages\Boot; -use Quantum\App\Adapters\Console\ConsoleContext; +use Symfony\Component\Console\Application; use Quantum\App\Contracts\StageInterface; +use Quantum\Di\Exceptions\DiException; use Quantum\Console\CommandRegistrar; use Quantum\App\AppContext; use ReflectionException; +use Quantum\Di\Di; /** * Class RegisterCoreCommandsStage - * @implements StageInterface + * + * Runs right after the helpers, before the environment, so that ResolveEnvironmentStage can find + * core:env when the .env file cannot be loaded. A core command constructor therefore uses + * no environment and no config. + * + * @implements StageInterface * @package Quantum\App */ class RegisterCoreCommandsStage implements StageInterface { /** * Registers the commands that ship with the framework - * @param ConsoleContext $context - * @throws ReflectionException + * @throws DiException|ReflectionException */ public function process(AppContext $context): void { CommandRegistrar::register( - $context->getApplication(), + Di::get(Application::class), $this->directory(), $this->namespace(), ); diff --git a/src/App/Stages/Boot/ResolveEnvironmentStage.php b/src/App/Stages/Boot/ResolveEnvironmentStage.php new file mode 100644 index 00000000..7a286ee5 --- /dev/null +++ b/src/App/Stages/Boot/ResolveEnvironmentStage.php @@ -0,0 +1,72 @@ + + * @package Quantum\App + */ +class ResolveEnvironmentStage implements StageInterface +{ + /** + * @throws EnvException|InvalidFileException|DiException|BaseException + */ + public function process(AppContext $context): void + { + try { + (new Environment())->load(); + } catch (EnvException|InvalidFileException $exception) { + if ($this->requiresEnvironment()) { + throw $exception; + } + + $context->stop(); + } + } + + /** + * A CliCommand tells it itself, other commands (Symfony's list or help) do not need it, + * and an unknown or missing command is treated as needing it + * @throws DiException + */ + private function requiresEnvironment(): bool + { + $application = Di::get(Application::class); + $commandName = Di::get(InputInterface::class)->getFirstArgument(); + + if ($commandName === null || !$application->has($commandName)) { + return true; + } + + $command = $application->get($commandName); + + return $command instanceof CliCommand && $command->requiresEnvironment(); + } +} diff --git a/src/App/Stages/Console/RunCommandStage.php b/src/App/Stages/Console/RunCommandStage.php index a139f84b..3904f278 100644 --- a/src/App/Stages/Console/RunCommandStage.php +++ b/src/App/Stages/Console/RunCommandStage.php @@ -12,16 +12,13 @@ use Quantum\App\Adapters\Console\ConsoleContext; use Quantum\App\Contracts\StageInterface; -use Quantum\Console\CliCommand; use Quantum\App\AppContext; use Exception; -use Throwable; /** * Class RunCommandStage * - * Runs the requested command; a missing or unknown command is rejected before anything runs, - * and so is a command that needs the environment after the boot failed. + * Runs the requested command; a missing or unknown command is rejected before anything runs. * * @implements StageInterface * @package Quantum\App @@ -30,7 +27,7 @@ class RunCommandStage implements StageInterface { /** * @param ConsoleContext $context - * @throws Exception|Throwable + * @throws Exception */ public function process(AppContext $context): void { @@ -40,12 +37,6 @@ public function process(AppContext $context): void throw new Exception("Command `$commandName` is not defined"); } - $command = $context->getApplication()->get($commandName); - - if ($context->getBootFailure() !== null && $command instanceof CliCommand && $command->requiresEnvironment()) { - throw $context->getBootFailure(); - } - $context->setExitCode( $context->getApplication()->run($context->getInput(), $context->getOutput()) ); diff --git a/src/Environment/Environment.php b/src/Environment/Environment.php index 2f3d4d70..887e28de 100644 --- a/src/Environment/Environment.php +++ b/src/Environment/Environment.php @@ -12,6 +12,7 @@ use Quantum\Environment\Exceptions\EnvException; use Quantum\Config\Exceptions\ConfigException; +use Dotenv\Exception\InvalidFileException; use Quantum\App\Exceptions\BaseException; use Quantum\Di\Exceptions\DiException; use Quantum\Environment\Enums\Env; @@ -46,7 +47,7 @@ public function setMutable(bool $isMutable): Environment /** * Loads environment variables from file - * @throws EnvException|DiException|BaseException + * @throws EnvException|InvalidFileException|DiException|BaseException */ public function load(): void { @@ -191,6 +192,7 @@ private function resolveEnvironmentFile(): array /** * @return array + * @throws InvalidFileException */ private function loadDotenvFile(string $envFile): array { diff --git a/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php b/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php index 3d7bf96e..4b75af4f 100644 --- a/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php +++ b/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php @@ -20,7 +20,6 @@ use RuntimeException; use Quantum\Di\Di; use ParseError; -use Throwable; use Exception; use Mockery; @@ -230,9 +229,9 @@ public function testConsoleAppAdapterRegistersTheConsoleObjectsInTheContainerAft $this->assertSame('core:env', Di::get(InputInterface::class)->getFirstArgument()); } - public function testConsoleAppAdapterRegistersTheConsoleObjectsInTheContainerAfterAFailedBoot(): void + public function testConsoleAppAdapterRegistersTheConsoleObjectsInTheContainerWhenTheBootStopsBeforeTheEnvironment(): void { - $_SERVER['argv'] = ['qt', 'core:env', '--yes']; + $_SERVER['argv'] = ['qt', 'list', '--quiet']; $this->withoutEnvironmentFile(function (): void { (new ConsoleAppAdapter($this->createContext()))->boot(); @@ -240,27 +239,20 @@ public function testConsoleAppAdapterRegistersTheConsoleObjectsInTheContainerAft $this->assertTrue(Di::isRegistered(Application::class)); $this->assertTrue(Di::isRegistered(OutputInterface::class)); $this->assertInstanceOf(ArgvInput::class, Di::get(InputInterface::class)); - $this->assertSame('core:env', Di::get(InputInterface::class)->getFirstArgument()); + $this->assertSame('list', Di::get(InputInterface::class)->getFirstArgument()); }); } - public function testConsoleAppAdapterRecordsNoBootFailureAfterASuccessfulBoot(): void - { - $_SERVER['argv'] = ['qt', 'list', '--quiet']; - - $adapter = $this->createAdapter(); - - $this->assertNull($this->bootFailureOf($adapter)); - } - - public function testConsoleAppAdapterRecordsTheBootFailureInsteadOfThrowingWhenTheEnvFileIsMissing(): void + public function testConsoleAppAdapterBootStopsBeforeTheEnvironmentForListWhenTheEnvFileIsMissing(): void { $_SERVER['argv'] = ['qt', 'list', '--quiet']; $this->withoutEnvironmentFile(function (): void { - $adapter = $this->createAdapter(); + $this->createAdapter(); - $this->assertInstanceOf(EnvException::class, $this->bootFailureOf($adapter)); + $this->assertFalse(Di::has(Environment::class)); + $this->assertFalse(Di::isRegistered(ErrorHandler::class)); + $this->assertFalse(config()->has('app')); }); } @@ -288,7 +280,6 @@ public function testConsoleAppAdapterNamesTheApplicationUnknownWhenTheAppNameIsE $adapter = $this->createAdapter(null, $context); $adapter->start(); - $this->assertNull($this->bootFailureOf($adapter)); $this->assertSame('UNKNOWN', Di::get(Application::class)->getName()); $this->assertSame('UNKNOWN', Di::get(Application::class)->getVersion()); } @@ -306,7 +297,6 @@ public function testConsoleAppAdapterKeepsAnEmptyAppNameAndVersionFromTheConfig( $adapter = $this->createAdapter(null, $context); $adapter->start(); - $this->assertNull($this->bootFailureOf($adapter)); $this->assertSame('', Di::get(Application::class)->getName()); $this->assertSame('0', Di::get(Application::class)->getVersion()); } @@ -345,7 +335,7 @@ public function testConsoleAppAdapterNamesTheApplicationFromTheAppConfigAfterThe $this->assertNotSame('UNKNOWN', Di::get(Application::class)->getName()); } - public function testConsoleAppAdapterNamesTheApplicationUnknownAfterAFailedBoot(): void + public function testConsoleAppAdapterNamesTheApplicationUnknownWhenTheEnvFileIsMissing(): void { $_SERVER['argv'] = ['qt', 'list', '--quiet']; @@ -357,43 +347,33 @@ public function testConsoleAppAdapterNamesTheApplicationUnknownAfterAFailedBoot( }); } - public function testConsoleAppAdapterKeepsTheApplicationUnknownWhenTheErrorHandlerCannotBeSetUp(): void + public function testConsoleAppAdapterBootFailsWhenTheErrorHandlerCannotBeSetUp(): void { $_SERVER['argv'] = ['qt', 'list', '--quiet']; $this->withFileContent(PROJECT_ROOT . DS . 'shared' . DS . 'config' . DS . 'logging.php', "createAdapter(); - $adapter->start(); + $this->expectException(ParseError::class); - $this->assertNotNull($this->bootFailureOf($adapter)); - $this->assertTrue(config()->has('app')); - $this->assertSame('UNKNOWN', Di::get(Application::class)->getName()); - $this->assertSame('UNKNOWN', Di::get(Application::class)->getVersion()); + $this->createAdapter(); }); } - public function testConsoleAppAdapterStartRejectsACommandThatRequiresTheEnvironmentWhenTheEnvFileIsMissing(): void + public function testConsoleAppAdapterBootRejectsACommandThatRequiresTheEnvironmentWhenTheEnvFileIsMissing(): void { - $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; - - $this->withTemporaryCommand($directory, 'Shared\Commands', function (string $commandName): void { + $this->withTemporaryCommand(PROJECT_ROOT . DS . 'vendor' . DS . 'quantum' . DS . 'framework' . DS . 'src' . DS . 'Console' . DS . 'Commands', 'Quantum\Console\Commands', function (string $commandName): void { $_SERVER['argv'] = ['qt', $commandName, '--quiet']; $this->withoutEnvironmentFile(function (): void { - $adapter = $this->createAdapter(); - $this->expectException(EnvException::class); - $adapter->start(); + $this->createAdapter(); }); }); } - public function testConsoleAppAdapterStartRunsACommandThatDoesNotRequireTheEnvironmentWhenTheEnvFileIsMissing(): void + public function testConsoleAppAdapterStartRunsACoreCommandThatDoesNotRequireTheEnvironmentWhenTheEnvFileIsMissing(): void { - $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; - - $this->withTemporaryCommand($directory, 'Shared\Commands', function (string $commandName): void { + $this->withTemporaryCommand(PROJECT_ROOT . DS . 'vendor' . DS . 'quantum' . DS . 'framework' . DS . 'src' . DS . 'Console' . DS . 'Commands', 'Quantum\Console\Commands', function (string $commandName): void { $this->withoutEnvironmentFile(function () use ($commandName): void { $this->assertSame(0, $this->startWithRealApplication($commandName)); $this->assertSame($commandName, $GLOBALS['tmpCommandRan'] ?? null); @@ -401,6 +381,63 @@ public function testConsoleAppAdapterStartRunsACommandThatDoesNotRequireTheEnvir }, null, false); } + public function testConsoleAppAdapterBootRejectsAnAppCommandThatDoesNotRequireTheEnvironmentWhenTheEnvFileIsMissing(): void + { + $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; + + $this->withTemporaryCommand($directory, 'Shared\Commands', function (string $commandName): void { + $_SERVER['argv'] = ['qt', $commandName, '--quiet']; + + $this->withoutEnvironmentFile(function (): void { + $this->expectException(EnvException::class); + + $this->createAdapter(); + }); + }, null, false); + } + + public function testConsoleAppAdapterStartRunsAnAppCommandWhoseConstructorUsesTheEnvironment(): void + { + $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; + $className = 'TmpEnvCommand' . substr(md5(uniqid('', true)), 0, 8); + $commandName = 'tmp:env-constructor'; + + $command = <<appKey = (string) env('APP_KEY'); + } + + public function exec(): void + { + \$GLOBALS['tmpCommandRan'] = \$this->appKey; + } +} +PHP; + + $this->withTemporaryFile($directory, $className, $command, function () use ($commandName): void { + $this->assertSame(0, $this->startWithRealApplication($commandName)); + $this->assertSame(env('APP_KEY'), $GLOBALS['tmpCommandRan'] ?? null); + $this->assertNotEmpty($GLOBALS['tmpCommandRan']); + }); + } + public function testConsoleAppAdapterStartRunsListWhenTheEnvFileIsMissing(): void { $this->withoutEnvironmentFile(function (): void { @@ -408,11 +445,9 @@ public function testConsoleAppAdapterStartRunsListWhenTheEnvFileIsMissing(): voi }); } - public function testConsoleAppAdapterStartRunsACommandThatDoesNotRequireTheEnvironmentWhenTheEnvFileIsMalformed(): void + public function testConsoleAppAdapterStartRunsACoreCommandThatDoesNotRequireTheEnvironmentWhenTheEnvFileIsMalformed(): void { - $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; - - $this->withTemporaryCommand($directory, 'Shared\Commands', function (string $commandName): void { + $this->withTemporaryCommand(PROJECT_ROOT . DS . 'vendor' . DS . 'quantum' . DS . 'framework' . DS . 'src' . DS . 'Console' . DS . 'Commands', 'Quantum\Console\Commands', function (string $commandName): void { $this->withEnvironmentFileContent("APP_NAME=two words here\n", function () use ($commandName): void { $adapter = $this->bootRealAdapter($commandName); @@ -436,16 +471,12 @@ public function testConsoleAppAdapterStartRunsACommandThatDoesNotRequireTheEnvir }, null, false); } - public function testConsoleAppAdapterStartRejectsACommandThatRequiresTheEnvironmentWhenTheEnvFileIsMalformed(): void + public function testConsoleAppAdapterBootRejectsACommandThatRequiresTheEnvironmentWhenTheEnvFileIsMalformed(): void { - $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; - - $this->withTemporaryCommand($directory, 'Shared\Commands', function (string $commandName): void { + $this->withTemporaryCommand(PROJECT_ROOT . DS . 'vendor' . DS . 'quantum' . DS . 'framework' . DS . 'src' . DS . 'Console' . DS . 'Commands', 'Quantum\Console\Commands', function (string $commandName): void { $this->withEnvironmentFileContent("APP_NAME=two words here\n", function () use ($commandName): void { - $adapter = $this->bootRealAdapter($commandName); - try { - $adapter->start(); + $this->bootRealAdapter($commandName); $this->fail('The command must not run when the environment file is malformed.'); } catch (InvalidFileException $exception) { $this->assertArrayNotHasKey('tmpCommandRan', $GLOBALS); @@ -454,6 +485,19 @@ public function testConsoleAppAdapterStartRejectsACommandThatRequiresTheEnvironm }); } + public function testConsoleAppAdapterBootRejectsAnAppCommandThatDoesNotRequireTheEnvironmentWhenTheEnvFileIsMalformed(): void + { + $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; + + $this->withTemporaryCommand($directory, 'Shared\Commands', function (string $commandName): void { + $this->withEnvironmentFileContent("APP_NAME=two words here\n", function () use ($commandName): void { + $this->expectException(InvalidFileException::class); + + $this->bootRealAdapter($commandName); + }); + }, null, false); + } + public function testConsoleAppAdapterStartRunsListWhenTheEnvFileIsMalformed(): void { $this->withEnvironmentFileContent("APP_NAME=two words here\n", function (): void { @@ -461,97 +505,66 @@ public function testConsoleAppAdapterStartRunsListWhenTheEnvFileIsMalformed(): v }); } - public function testConsoleAppAdapterStartReportsTheBootFailureForAnUnknownCommandWhenTheEnvFileIsMalformed(): void + public function testConsoleAppAdapterBootReportsTheEnvFileExceptionForAnUnknownCommandWhenTheEnvFileIsMalformed(): void { $this->withEnvironmentFileContent("APP_NAME=two words here\n", function (): void { - $adapter = $this->bootRealAdapter('unknown:command'); - $this->expectException(InvalidFileException::class); - $adapter->start(); + $this->bootRealAdapter('unknown:command'); }); } - public function testConsoleAppAdapterStartReportsTheBootFailureForAnUnknownCommandWhenTheEnvFileIsMissing(): void + public function testConsoleAppAdapterBootReportsTheEnvFileExceptionForAnUnknownCommandWhenTheEnvFileIsMissing(): void { $this->withoutEnvironmentFile(function (): void { - $adapter = $this->bootRealAdapter('unknown:command'); + $this->expectException(EnvException::class); + + $this->bootRealAdapter('unknown:command'); + }); + } + + public function testConsoleAppAdapterBootReportsTheEnvFileExceptionWithoutACommandWhenTheEnvFileIsMissing(): void + { + $_SERVER['argv'] = ['qt']; + $this->withoutEnvironmentFile(function (): void { $this->expectException(EnvException::class); - $adapter->start(); + $this->createAdapter(); }); } - public function testConsoleAppAdapterStartReportsTheBootFailureWhenACommandCannotBeCreatedAfterAFailedBoot(): void + public function testConsoleAppAdapterStartRunsListWhenTheEnvFileIsMissingAndAnAppCommandCannotBeCreated(): void { $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; $this->withBrokenCommand($directory, function (): void { $this->withoutEnvironmentFile(function (): void { - $adapter = $this->bootRealAdapter('list'); - - $this->expectException(EnvException::class); - - $adapter->start(); + $this->assertSame(0, $this->bootRealAdapter('list')->start()); }); }); } - public function testConsoleAppAdapterStartReportsTheCommandFailureWhenACommandCannotBeCreatedAfterASuccessfulBoot(): void + public function testConsoleAppAdapterBootReportsTheCommandFailureWhenAnAppCommandCannotBeCreated(): void { $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; $this->withBrokenCommand($directory, function (): void { - $adapter = $this->bootRealAdapter('list'); - $this->expectException(RuntimeException::class); $this->expectExceptionMessage('The command cannot be created'); - $adapter->start(); + $this->bootRealAdapter('list'); }); } - public function testConsoleAppAdapterStartRunsListWhenAnAppHelperFileCannotBeLoaded(): void + public function testConsoleAppAdapterBootFailsWhenAnAppHelperFileCannotBeLoaded(): void { $_SERVER['argv'] = ['qt', 'list', '--quiet']; $this->withBrokenAppHelper(function (): void { - $adapter = $this->createAdapter(); - - $this->assertSame(0, $adapter->start()); - $this->assertInstanceOf(ParseError::class, $this->bootFailureOf($adapter)); - }); - } - - public function testConsoleAppAdapterStartRunsACommandThatDoesNotRequireTheEnvironmentWhenAnAppHelperFileCannotBeLoaded(): void - { - $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; - - $this->withTemporaryCommand($directory, 'Shared\Commands', function (string $commandName): void { - $_SERVER['argv'] = ['qt', $commandName, '--quiet']; - - $this->withBrokenAppHelper(function () use ($commandName): void { - $this->assertSame(0, $this->createAdapter()->start()); - $this->assertSame($commandName, $GLOBALS['tmpCommandRan'] ?? null); - }); - }, null, false); - } + $this->expectException(ParseError::class); - public function testConsoleAppAdapterStartRejectsACommandThatRequiresTheEnvironmentWhenAnAppHelperFileCannotBeLoaded(): void - { - $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; - - $this->withTemporaryCommand($directory, 'Shared\Commands', function (string $commandName): void { - $_SERVER['argv'] = ['qt', $commandName, '--quiet']; - - $this->withBrokenAppHelper(function (): void { - $adapter = $this->createAdapter(); - - $this->expectException(ParseError::class); - - $adapter->start(); - }); + $this->createAdapter(); }); } @@ -694,16 +707,6 @@ private function withoutEnvironmentFile(callable $test): void } } - /** - * The failure the adapter recorded while booting, if there was one - */ - private function bootFailureOf(ConsoleAppAdapter $adapter): ?Throwable - { - $property = new ReflectionProperty(ConsoleAppAdapter::class, 'bootFailure'); - $property->setAccessible(true); - - return $property->getValue($adapter); - } /** * Starts the adapter with a real Symfony Application (no auto exit, no exception catching), * so the registered command is really executed and its exit code comes from Symfony. diff --git a/tests/Unit/App/Adapters/Console/ConsoleContextTest.php b/tests/Unit/App/Adapters/Console/ConsoleContextTest.php index 80027d3c..1c4bcc70 100644 --- a/tests/Unit/App/Adapters/Console/ConsoleContextTest.php +++ b/tests/Unit/App/Adapters/Console/ConsoleContextTest.php @@ -12,7 +12,6 @@ use Quantum\App\Enums\ExitCode; use Quantum\App\AppContext; use Quantum\Di\DiContainer; -use RuntimeException; use Quantum\App\App; use Quantum\Di\Di; @@ -73,20 +72,6 @@ public function testConsoleContextReadsTheConsoleObjectsFromTheContainerWhenAske $this->assertSame($application, $consoleContext->getApplication()); } - public function testConsoleContextHasNoBootFailureByDefault(): void - { - $this->assertNull($this->consoleContext->getBootFailure()); - } - - public function testConsoleContextExposesTheBootFailure(): void - { - $failure = new RuntimeException('The boot failed'); - - $consoleContext = new ConsoleContext($this->appContext, $failure); - - $this->assertSame($failure, $consoleContext->getBootFailure()); - } - public function testConsoleContextDefaultsExitCodeToSuccess(): void { $this->assertSame(ExitCode::SUCCESS, $this->consoleContext->getExitCode()); diff --git a/tests/Unit/App/Stages/Console/RegisterAppCommandsStageTest.php b/tests/Unit/App/Stages/Boot/RegisterAppCommandsStageTest.php similarity index 87% rename from tests/Unit/App/Stages/Console/RegisterAppCommandsStageTest.php rename to tests/Unit/App/Stages/Boot/RegisterAppCommandsStageTest.php index 0de21f78..9bba1167 100644 --- a/tests/Unit/App/Stages/Console/RegisterAppCommandsStageTest.php +++ b/tests/Unit/App/Stages/Boot/RegisterAppCommandsStageTest.php @@ -1,8 +1,9 @@ createConsoleContext(['command' => 'needs:env'], $this->createApplication()); + + (new ResolveEnvironmentStage())->process($context); + + $this->assertFalse($context->isStopped()); + } + + /** + * @dataProvider commandsThatRunWithoutTheEnvironment + */ + public function testResolveEnvironmentStageStopsTheBootWhenTheEnvFileIsMissing(string $commandName): void + { + $context = $this->createConsoleContext(['command' => $commandName], $this->createApplication()); + + $this->withoutEnvironmentFile(function () use ($context): void { + (new ResolveEnvironmentStage())->process($context); + }); + + $this->assertTrue($context->isStopped()); + } + + /** + * @dataProvider commandsThatRunWithoutTheEnvironment + */ + public function testResolveEnvironmentStageStopsTheBootWhenTheEnvFileIsMalformed(string $commandName): void + { + $context = $this->createConsoleContext(['command' => $commandName], $this->createApplication()); + + $this->withEnvironmentFileContent("APP_NAME=two words here\n", function () use ($context): void { + (new ResolveEnvironmentStage())->process($context); + }); + + $this->assertTrue($context->isStopped()); + } + + /** + * @dataProvider inputsThatNeedTheEnvironment + * @param array $input + */ + public function testResolveEnvironmentStageThrowsTheEnvExceptionWhenTheEnvFileIsMissing(array $input): void + { + $context = $this->createConsoleContext($input, $this->createApplication()); + + $this->withoutEnvironmentFile(function () use ($context): void { + try { + (new ResolveEnvironmentStage())->process($context); + + $this->fail('The command needs the environment, so the boot must fail.'); + } catch (EnvException $exception) { + $this->assertFalse($context->isStopped()); + } + }); + } + + /** + * @dataProvider inputsThatNeedTheEnvironment + * @param array $input + */ + public function testResolveEnvironmentStageThrowsTheParseExceptionWhenTheEnvFileIsMalformed(array $input): void + { + $context = $this->createConsoleContext($input, $this->createApplication()); + + $this->withEnvironmentFileContent("APP_NAME=two words here\n", function () use ($context): void { + try { + (new ResolveEnvironmentStage())->process($context); + + $this->fail('The command needs the environment, so the boot must fail.'); + } catch (InvalidFileException $exception) { + $this->assertFalse($context->isStopped()); + } + }); + } + + /** + * @return array + */ + public function commandsThatRunWithoutTheEnvironment(): array + { + return [ + 'a CliCommand that does not require the environment' => ['no:env'], + 'list' => ['list'], + 'help' => ['help'], + ]; + } + + /** + * @return array}> + */ + public function inputsThatNeedTheEnvironment(): array + { + return [ + 'a CliCommand that requires the environment' => [['command' => 'needs:env']], + 'an unknown command' => [['command' => 'unknown:command']], + 'no command' => [[]], + ]; + } + + /** + * An application with a CliCommand that requires the environment and one that does not + */ + private function createApplication(): Application + { + $application = new Application(); + + foreach (['needs:env' => true, 'no:env' => false] as $commandName => $requiresEnvironment) { + $application->add(new class ($commandName, $requiresEnvironment) extends CliCommand { + public function __construct(string $commandName, bool $requiresEnvironment) + { + $this->name = $commandName; + $this->requiresEnvironment = $requiresEnvironment; + + parent::__construct(); + } + + public function exec(): void + { + } + }); + } + + return $application; + } + + /** + * Hides the environment file of the test app for the duration of the callback + */ + private function withoutEnvironmentFile(callable $test): void + { + $file = PROJECT_ROOT . DS . '.env.testing'; + $hidden = $file . '.hidden'; + + $this->assertTrue(rename($file, $hidden)); + + try { + $test(); + } finally { + rename($hidden, $file); + } + } + + /** + * Replaces the content of the environment file of the test app for the duration of the callback + */ + private function withEnvironmentFileContent(string $content, callable $test): void + { + $file = PROJECT_ROOT . DS . '.env.testing'; + $backup = $file . '.backup'; + + $this->assertTrue(rename($file, $backup)); + + try { + $this->assertNotFalse(file_put_contents($file, $content)); + + $test(); + } finally { + if (is_file($file)) { + unlink($file); + } + + rename($backup, $file); + } + } +} diff --git a/tests/Unit/App/Stages/Console/ConsoleStageTestCase.php b/tests/Unit/App/Stages/Console/ConsoleStageTestCase.php index fd1e3fef..9c1f526f 100644 --- a/tests/Unit/App/Stages/Console/ConsoleStageTestCase.php +++ b/tests/Unit/App/Stages/Console/ConsoleStageTestCase.php @@ -11,7 +11,6 @@ use Quantum\App\Adapters\Console\ConsoleContext; use Quantum\App\App; use Quantum\Di\Di; -use Throwable; abstract class ConsoleStageTestCase extends AppTestCase { @@ -19,7 +18,7 @@ abstract class ConsoleStageTestCase extends AppTestCase * Creates a console context around a real Symfony application that neither exits nor catches exceptions. * @param array $input */ - protected function createConsoleContext(array $input = [], ?Application $application = null, ?Throwable $bootFailure = null): ConsoleContext + protected function createConsoleContext(array $input = [], ?Application $application = null): ConsoleContext { $application ??= new Application(); $application->setAutoExit(false); @@ -29,7 +28,7 @@ protected function createConsoleContext(array $input = [], ?Application $applica Di::set(InputInterface::class, new ArrayInput($input)); Di::set(OutputInterface::class, new BufferedOutput()); - return new ConsoleContext(App::getContext(), $bootFailure); + return new ConsoleContext(App::getContext()); } /** diff --git a/tests/Unit/App/Stages/Console/RunCommandStageTest.php b/tests/Unit/App/Stages/Console/RunCommandStageTest.php index 9daf8a8b..146115ec 100644 --- a/tests/Unit/App/Stages/Console/RunCommandStageTest.php +++ b/tests/Unit/App/Stages/Console/RunCommandStageTest.php @@ -9,7 +9,6 @@ use Symfony\Component\Console\Application; use Quantum\App\Enums\ExitCode; use Quantum\Console\CliCommand; -use RuntimeException; use Exception; class RunCommandStageTest extends ConsoleStageTestCase @@ -85,75 +84,27 @@ public function testRunCommandStageRunsNothingWhenTheCommandNameIsMissing(): voi $this->assertSame('', $context->getOutput()->fetch()); } - public function testRunCommandStageRejectsUnknownCommandWhenTheBootFailed(): void + public function testRunCommandStageRunsACliCommand(): void { - $context = $this->createConsoleContext(['command' => 'unknown:command'], $this->createApplication('run:ok', 0), new RuntimeException('The boot failed')); - - $this->expectException(Exception::class); - $this->expectExceptionMessage('Command `unknown:command` is not defined'); - - (new RunCommandStage())->process($context); - } - - public function testRunCommandStageRejectsACommandThatRequiresTheEnvironmentWhenTheBootFailed(): void - { - $failure = new RuntimeException('The boot failed'); - $context = $this->createConsoleContext(['command' => 'needs:env'], $this->createCliApplication('needs:env', true), $failure); - - try { - (new RunCommandStage())->process($context); - - $this->fail('The command must not run after a failed boot.'); - } catch (RuntimeException $e) { - $this->assertSame($failure, $e); - } - - $this->assertSame(ExitCode::SUCCESS, $context->getExitCode()); - $this->assertSame('', $context->getOutput()->fetch()); - } - - public function testRunCommandStageRunsACommandThatDoesNotRequireTheEnvironmentWhenTheBootFailed(): void - { - $context = $this->createConsoleContext(['command' => 'opt:out'], $this->createCliApplication('opt:out', false), new RuntimeException('The boot failed')); - - (new RunCommandStage())->process($context); - - $this->assertSame(ExitCode::SUCCESS, $context->getExitCode()); - $this->assertStringContainsString('ran opt:out', $context->getOutput()->fetch()); - } - - public function testRunCommandStageRunsAPlainSymfonyCommandWhenTheBootFailed(): void - { - $context = $this->createConsoleContext(['command' => 'run:ok'], $this->createApplication('run:ok', 0), new RuntimeException('The boot failed')); - - (new RunCommandStage())->process($context); - - $this->assertSame(ExitCode::SUCCESS, $context->getExitCode()); - $this->assertStringContainsString('hello from run:ok', $context->getOutput()->fetch()); - } - - public function testRunCommandStageRunsACommandThatRequiresTheEnvironmentWhenTheBootSucceeded(): void - { - $context = $this->createConsoleContext(['command' => 'needs:env'], $this->createCliApplication('needs:env', true)); + $context = $this->createConsoleContext(['command' => 'cli:ok'], $this->createCliApplication('cli:ok')); (new RunCommandStage())->process($context); $this->assertSame(ExitCode::SUCCESS, $context->getExitCode()); - $this->assertStringContainsString('ran needs:env', $context->getOutput()->fetch()); + $this->assertStringContainsString('ran cli:ok', $context->getOutput()->fetch()); } /** - * An application with one CliCommand that does or does not require the environment + * An application with one CliCommand */ - private function createCliApplication(string $commandName, bool $requiresEnvironment): Application + private function createCliApplication(string $commandName): Application { $application = new Application(); - $application->add(new class ($commandName, $requiresEnvironment) extends CliCommand { - public function __construct(string $commandName, bool $requiresEnvironment) + $application->add(new class ($commandName) extends CliCommand { + public function __construct(string $commandName) { $this->name = $commandName; - $this->requiresEnvironment = $requiresEnvironment; parent::__construct(); } From e38e64be6802f26c2b00ae4996c877ea2a1e0207 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:29:26 +0400 Subject: [PATCH 6/7] [#589] Add the changelog entry for the unified app adapter lifecycle --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 821a0ea4..59e47877 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,15 @@ The format is based on Keep a Changelog. - Added adapter-based Lang provider support with `DeepL` and `Google Translate` adapters plus shared remote request/caching infrastructure (#533) ### Changed +- **BREAKING:** Unified the app adapter lifecycle and let the pipeline own an early end of a run (#589): + - `AppInterface` gains `boot()`. The adapter constructors only store the context, and `AppFactory` calls `boot()` right after creating the adapter, so `AppFactory::create(...)->start()` is unchanged. Code that creates an adapter directly must call `boot()` before `start()`. + - `Pipeline` ends the run when a stage calls `stop()` on the context: no later stage runs or dispatches events. An optional closing stage, the second constructor argument, runs last, also after a stop, but not after a stage throws. The web runtime passes `SendResponseStage` as its closing stage, and the web stages no longer check `isStopped()` themselves. + - `stop()`, `isStopped()`, `getExitCode()` and `setExitCode()` moved to `AppContext`; `WebContext` and `ConsoleContext` inherit them. + - The console boot no longer catches failures. It registers the core commands right after the helpers and the app commands at its end, after the environment, the config and the error handler, so app command constructors can use `env()` and `config()`. The new `ResolveEnvironmentStage` lets a core command that does not require the environment (`core:env`) and Symfony's `list` and `help` run when the `.env` file is missing or malformed, by stopping the boot; every other command fails with the exception of the environment file. The console runtime only runs the command. + - Removed the boot failure added earlier in 3.1.0: `ConsoleContext::getBootFailure()` and its constructor argument, and the boot failure handling of `ConsoleAppAdapter` and `RunCommandStage`. + - On a missing or malformed `.env`, an app command no longer runs even when it does not require the environment, and `list` shows only the core commands. A broken app helper file, or an error handler that cannot be set up, now fails every command instead of being tolerated. + - Moved `RegisterCoreCommandsStage` and `RegisterAppCommandsStage` to `Quantum\App\Stages\Boot`. A core command constructor must not use the environment or any config: `DebugBarCommand`, `OpenApiCommand` and `ResourceCacheClearCommand` now get the file system in `exec()`. + - `Environment::load()` now documents the Dotenv `InvalidFileException` it throws for a malformed file. - **BREAKING:** Replaced the boot, web and console pipelines with one generic `Pipeline` (#583): - `Pipeline` runs `StageInterface` stages in order and dispatches the `BEFORE`/`AFTER` events they declare. The context type is a PHPDoc template checked by PHPStan: a stage states it with `@implements StageInterface` (or `WebContext`, `ConsoleContext`) and takes `process(AppContext $context)`. - Removed `BootPipeline` and `BootStageInterface`: build a `new Pipeline([...])`, and implement `StageInterface` with `@implements StageInterface` in a boot stage. `WebPipeline`, `ConsolePipeline`, `WebStageInterface` and `ConsoleStageInterface`, added earlier in 3.1.0, are removed too. From 9a32bb4766fedc6a6c4ec64e1dfa7625405b53db Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:37:34 +0400 Subject: [PATCH 7/7] [#589] Describe the app lifecycle change from 3.0.3 in one changelog entry --- CHANGELOG.md | 35 +++++++++-------------------------- 1 file changed, 9 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59e47877..f705fe9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,33 +12,16 @@ The format is based on Keep a Changelog. - Added adapter-based Lang provider support with `DeepL` and `Google Translate` adapters plus shared remote request/caching infrastructure (#533) ### Changed -- **BREAKING:** Unified the app adapter lifecycle and let the pipeline own an early end of a run (#589): +- **BREAKING:** Reworked the app boot and runtime into pipelines of stages that the adapters run in explicit steps (#537, #575, #576, #579, #583, #589): - `AppInterface` gains `boot()`. The adapter constructors only store the context, and `AppFactory` calls `boot()` right after creating the adapter, so `AppFactory::create(...)->start()` is unchanged. Code that creates an adapter directly must call `boot()` before `start()`. - - `Pipeline` ends the run when a stage calls `stop()` on the context: no later stage runs or dispatches events. An optional closing stage, the second constructor argument, runs last, also after a stop, but not after a stage throws. The web runtime passes `SendResponseStage` as its closing stage, and the web stages no longer check `isStopped()` themselves. - - `stop()`, `isStopped()`, `getExitCode()` and `setExitCode()` moved to `AppContext`; `WebContext` and `ConsoleContext` inherit them. - - The console boot no longer catches failures. It registers the core commands right after the helpers and the app commands at its end, after the environment, the config and the error handler, so app command constructors can use `env()` and `config()`. The new `ResolveEnvironmentStage` lets a core command that does not require the environment (`core:env`) and Symfony's `list` and `help` run when the `.env` file is missing or malformed, by stopping the boot; every other command fails with the exception of the environment file. The console runtime only runs the command. - - Removed the boot failure added earlier in 3.1.0: `ConsoleContext::getBootFailure()` and its constructor argument, and the boot failure handling of `ConsoleAppAdapter` and `RunCommandStage`. - - On a missing or malformed `.env`, an app command no longer runs even when it does not require the environment, and `list` shows only the core commands. A broken app helper file, or an error handler that cannot be set up, now fails every command instead of being tolerated. - - Moved `RegisterCoreCommandsStage` and `RegisterAppCommandsStage` to `Quantum\App\Stages\Boot`. A core command constructor must not use the environment or any config: `DebugBarCommand`, `OpenApiCommand` and `ResourceCacheClearCommand` now get the file system in `exec()`. - - `Environment::load()` now documents the Dotenv `InvalidFileException` it throws for a malformed file. -- **BREAKING:** Replaced the boot, web and console pipelines with one generic `Pipeline` (#583): - - `Pipeline` runs `StageInterface` stages in order and dispatches the `BEFORE`/`AFTER` events they declare. The context type is a PHPDoc template checked by PHPStan: a stage states it with `@implements StageInterface` (or `WebContext`, `ConsoleContext`) and takes `process(AppContext $context)`. - - Removed `BootPipeline` and `BootStageInterface`: build a `new Pipeline([...])`, and implement `StageInterface` with `@implements StageInterface` in a boot stage. `WebPipeline`, `ConsolePipeline`, `WebStageInterface` and `ConsoleStageInterface`, added earlier in 3.1.0, are removed too. - - A pipeline still rejects anything that is not a `StageInterface`, but no longer rejects a stage of another kind at runtime (a boot stage in a web pipeline); PHPStan reports it instead. -- **BREAKING:** The web and console contexts now extend `AppContext` (#579): - - `AppContext` keeps only what both the web and the console boot provide: `getBaseDir()`, `getContainer()`, `getEnvironment()` and `getConfig()`. Removed `AppContext::getRequest()`, `getResponse()` and `getRoutes()`, which only the web boot can serve: use `WebContext::getRequest()` and `WebContext::getRoutes()`, and `response()` for the shared response. - - `WebContext` and `ConsoleContext` extend `AppContext` and are built from it with the same base directory and container; `WebContext::getAppContext()` is removed. Like `ConsoleContext`, `WebContext` reads its services from the container (the request, the routes and the view cache) and the matched route from the request, and stores only what the run produces: the response that is sent and the stopped flag. `WebContext::setMatchedRoute()` and `setViewCache()` are removed. - - The web stages read the request, the routes and the config from the context instead of the global `request()` and `config()` helpers, as the console stages do. They are the same objects from the same container, so behavior is unchanged. -- **BREAKING:** The console adapter constructor is now only the boot pipeline, like the web adapter (#576): - - The Symfony `Application`, the input and the output are provided by the new `InitConsoleStage` through the container, and the new `SetupConsoleApplicationStage` names the application from `app.name` and `app.version` once the whole boot has succeeded (it stays `UNKNOWN` after a failed boot). `ConsoleContext` is built from the `AppContext` and the boot failure, and reads those objects from the container. Removed `ConsoleAppAdapter::createApplication()` (get the application with `Di::get(Application::class)`) and `ConsoleContext::getAppContext()`. - - The adapter no longer makes the environment mutable for every command. `core:key` and the toolkit installer already do it themselves; a command outside the framework that calls `environment()->updateRow()` must call `setMutable(true)` first, or it gets `EnvException::environmentImmutable()`. -- The console boot no longer decides by the `core:env` command name (#575): - - `ConsoleAppAdapter` always runs the full boot and, when it fails, keeps the failure instead of stopping. `RunCommandStage` re-throws that original failure only for a command that requires the environment, so `core:env`, `list`, and `help` still run and `core:env --yes` can repair a missing, empty, or malformed `.env`. The reduced boot for `core:env` is gone, and the Symfony `Application` is named `UNKNOWN` until a successful boot names it from the app config. - - Added `CliCommand::$requiresEnvironment` (default `true`) with `CliCommand::requiresEnvironment()`; a command that works without the environment, like `EnvCommand`, sets it to `false`. `ConsoleContext` takes an optional boot failure and exposes it with `getBootFailure()`. -- **BREAKING:** Refactored the web and console runtime into staged pipelines (#537): - - `WebAppAdapter::start()` now runs a `Pipeline` of the preflight, route resolution, not found, prepare, dispatch, and send stages with a `WebContext`, and `ConsoleAppAdapter::start()` runs a `Pipeline` of the register core commands, register app commands, and run stages with a `ConsoleContext`; the run stage rejects a missing or unknown command before running anything, and registration goes through the new `Quantum\Console\CommandRegistrar`. Behavior is unchanged. The pipeline dispatches a stage's `BEFORE`/`AFTER` events when the stage declares them, but the built-in web and console stages declare none yet. - - Moved the web adapter and context to `Quantum\App\Adapters\Web`, the console ones to `Quantum\App\Adapters\Console`, and the boot stages to `Quantum\App\Stages\Boot`. Code that goes through `AppFactory` is unaffected; code that references these classes directly must update its imports. - - Removed the `AppAdapter` base class and the `WebAppTrait` and `ConsoleAppTrait` traits. `WebAppAdapter` and `ConsoleAppAdapter` now implement `AppInterface` directly. + - Removed the `AppAdapter` base class and the `WebAppTrait` and `ConsoleAppTrait` traits: `WebAppAdapter` and `ConsoleAppAdapter` implement `AppInterface` directly and moved to `Quantum\App\Adapters\Web` and `Quantum\App\Adapters\Console`. The boot stages moved to `Quantum\App\Stages\Boot`. Code that goes through `AppFactory` is unaffected; code that references these classes directly must update its imports. + - Replaced `BootPipeline` and `BootStageInterface` with one generic `Quantum\App\Pipeline`, which runs `StageInterface` stages in order and dispatches the `BEFORE`/`AFTER` events they declare. The context type is a PHPDoc template checked by PHPStan: a stage states it with `@implements StageInterface` (or `WebContext`, `ConsoleContext`) and takes `process(AppContext $context)`. A stage that calls `stop()` on the context ends the run; an optional closing stage, the second constructor argument, runs last, also after a stop, but not after a stage throws. + - `AppContext` keeps `getBaseDir()`, `getContainer()`, `getEnvironment()` and `getConfig()`, and carries the state of a run: `stop()`, `isStopped()`, `getExitCode()` and `setExitCode()`. Removed `AppContext::getRequest()`, `getResponse()` and `getRoutes()`: use `WebContext::getRequest()` and `WebContext::getRoutes()`, and `response()` for the shared response. + - The new `WebContext` and `ConsoleContext` extend `AppContext` with the same base directory and container. `WebContext` reads the request, the routes and the view cache from the container and stores the response to send; `ConsoleContext` reads the Symfony `Application`, the input and the output from the container. + - The web runtime runs the preflight, route resolution, not found, prepare and dispatch stages, with `SendResponseStage` as its closing stage. Behavior is unchanged. + - The console boot registers the Symfony `Application`, the input and the output in the container (`InitConsoleStage`), loads the helpers, registers the core commands, loads the environment, the config and the error handler, names the application from `app.name` and `app.version` (`UNKNOWN` without them), and registers the app commands last, so their constructors can use `env()` and `config()`. The console runtime runs the command and rejects a missing or unknown one. Registration goes through the new `Quantum\Console\CommandRegistrar`. Removed `ConsoleAppAdapter::createApplication()`: get the application with `Di::get(Application::class)`. + - The console no longer decides by the `core:env` command name. Added `CliCommand::$requiresEnvironment` (default `true`) with `CliCommand::requiresEnvironment()`. When the `.env` file is missing or malformed, the new `ResolveEnvironmentStage` stops the boot for a core command that sets it to `false` (`core:env`) and for Symfony's `list` and `help`, so they still run; every other command fails with the exception of the environment file. With a valid `.env`, `core:env` now runs the full boot. A core command constructor must not use the environment or any config, since the core commands are registered before the environment is loaded. + - The console no longer makes the environment mutable for every command. `core:key` and the toolkit installer already do it themselves; a command outside the framework that calls `environment()->updateRow()` must call `setMutable(true)` first, or it gets `EnvException::environmentImmutable()`. - **BREAKING:** Replaced the `Quantum\Hook` package with `Quantum\Event` (#536): - `hook()` is now `event()`; `on()` and `fire()` are now `listen()` and `dispatch()`, and no aliases remain. Listeners receive one named payload array, which is empty when none is given. - Event names no longer need to be registered, so `shared/config/hooks.php` is no longer read; dispatching a name without listeners does nothing and an empty name throws `EventException`.