From df90ffba213c0f9424cef3ede107c3339d7e25ae Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:32:50 +0400 Subject: [PATCH 1/7] [#536] Add Event package with listen/dispatch API Introduce Quantum\Event\EventManager and the event() helper as the replacement for the Hook package. Event names need no pre-registration, listeners persist across dispatches and run in registration order, and the manager does not read config, so it is safe to use during boot. The Hook package is removed in a follow-up commit. Co-Authored-By: Claude Sonnet 5.5 --- src/Event/Enums/ExceptionMessages.php | 22 +++ src/Event/EventManager.php | 70 +++++++ src/Event/Exceptions/EventException.php | 29 +++ src/Event/Helpers/event.php | 22 +++ tests/Unit/Event/EventManagerTest.php | 185 +++++++++++++++++++ tests/Unit/Event/Helpers/EventHelperTest.php | 40 ++++ 6 files changed, 368 insertions(+) create mode 100644 src/Event/Enums/ExceptionMessages.php create mode 100644 src/Event/EventManager.php create mode 100644 src/Event/Exceptions/EventException.php create mode 100644 src/Event/Helpers/event.php create mode 100644 tests/Unit/Event/EventManagerTest.php create mode 100644 tests/Unit/Event/Helpers/EventHelperTest.php diff --git a/src/Event/Enums/ExceptionMessages.php b/src/Event/Enums/ExceptionMessages.php new file mode 100644 index 00000000..1639d077 --- /dev/null +++ b/src/Event/Enums/ExceptionMessages.php @@ -0,0 +1,22 @@ +> + */ + private array $listeners = []; + + /** + * Adds a listener for a given event + * @throws EventException + */ + public function listen(string $name, callable $listener): void + { + $this->assertName($name); + + $this->listeners[$name][] = $listener; + } + + /** + * Dispatches the event to its listeners in registration order + * @param array $payload + * @throws EventException + */ + public function dispatch(string $name, array $payload = []): void + { + $this->assertName($name); + + foreach ($this->listeners[$name] ?? [] as $listener) { + $listener($payload); + } + } + + /** + * Gets all registered listeners by event name + * @return array> + */ + public function getRegistered(): array + { + return $this->listeners; + } + + /** + * @throws EventException + */ + private function assertName(string $name): void + { + if ($name === '') { + throw EventException::emptyEventName(); + } + } +} diff --git a/src/Event/Exceptions/EventException.php b/src/Event/Exceptions/EventException.php new file mode 100644 index 00000000..3ce95a33 --- /dev/null +++ b/src/Event/Exceptions/EventException.php @@ -0,0 +1,29 @@ +events = new EventManager(); + } + + public function testListenAndDispatch(): void + { + $output = ''; + + $this->events->listen('SAVE', function () use (&$output): void { + $output .= 'Saved!'; + }); + + $this->events->dispatch('SAVE'); + + $this->assertSame('Saved!', $output); + } + + public function testDispatchPassesPayloadToListener(): void + { + $received = null; + + $this->events->listen('NOTIFY', function (array $payload) use (&$received): void { + $received = $payload; + }); + + $this->events->dispatch('NOTIFY', ['user' => 'John']); + + $this->assertSame(['user' => 'John'], $received); + } + + public function testDispatchWithoutPayloadPassesEmptyArray(): void + { + $received = null; + + $this->events->listen('NOTIFY', function (array $payload) use (&$received): void { + $received = $payload; + }); + + $this->events->dispatch('NOTIFY'); + + $this->assertSame([], $received); + } + + public function testListenersRunInRegistrationOrder(): void + { + $output = ''; + + $this->events->listen('SAVE', function () use (&$output): void { + $output .= 'A'; + }); + + $this->events->listen('SAVE', function () use (&$output): void { + $output .= 'B'; + }); + + $this->events->dispatch('SAVE'); + + $this->assertSame('AB', $output); + } + + public function testListenersPersistAfterDispatch(): void + { + $count = 0; + + $this->events->listen('SAVE', function () use (&$count): void { + $count++; + }); + + $this->events->dispatch('SAVE'); + $this->events->dispatch('SAVE'); + + $this->assertSame(2, $count); + } + + public function testListenersAreScopedByEventName(): void + { + $output = ''; + + $this->events->listen('SAVE', function () use (&$output): void { + $output .= 'save'; + }); + + $this->events->listen('DELETE', function () use (&$output): void { + $output .= 'delete'; + }); + + $this->events->dispatch('DELETE'); + + $this->assertSame('delete', $output); + } + + public function testDispatchWithoutListenersIsNoOp(): void + { + $this->events->dispatch('NEVER_LISTENED'); + + $this->assertSame([], $this->events->getRegistered()); + } + + public function testListenerExceptionPropagatesAndStopsLaterListeners(): void + { + $called = false; + + $this->events->listen('SAVE', function (): void { + throw new RuntimeException('Listener failed'); + }); + + $this->events->listen('SAVE', function () use (&$called): void { + $called = true; + }); + + try { + $this->events->dispatch('SAVE'); + $this->fail('The listener exception was not propagated.'); + } catch (RuntimeException $e) { + $this->assertSame('Listener failed', $e->getMessage()); + } + + $this->assertFalse($called); + } + + public function testListenWithEmptyNameThrows(): void + { + $this->expectException(EventException::class); + $this->expectExceptionMessage('The event name must not be empty.'); + + $this->events->listen('', function (): void { + }); + } + + public function testDispatchWithEmptyNameThrows(): void + { + $this->expectException(EventException::class); + $this->expectExceptionMessage('The event name must not be empty.'); + + $this->events->dispatch(''); + } + + public function testGetRegisteredReturnsListenersByName(): void + { + $this->events->listen('SAVE', function (): void { + }); + + $this->events->listen('SAVE', function (): void { + }); + + $registered = $this->events->getRegistered(); + + $this->assertArrayHasKey('SAVE', $registered); + $this->assertCount(2, $registered['SAVE']); + } + + public function testWorksWithoutLoadedConfig(): void + { + config()->flush(); + + $events = new EventManager(); + $output = ''; + + $events->listen('BOOT', function () use (&$output): void { + $output .= 'ok'; + }); + + $events->dispatch('BOOT'); + + $this->assertSame('ok', $output); + $this->assertFalse(config()->has('hooks')); + $this->assertFalse(config()->has('events')); + } +} diff --git a/tests/Unit/Event/Helpers/EventHelperTest.php b/tests/Unit/Event/Helpers/EventHelperTest.php new file mode 100644 index 00000000..eff37484 --- /dev/null +++ b/tests/Unit/Event/Helpers/EventHelperTest.php @@ -0,0 +1,40 @@ +assertInstanceOf(EventManager::class, event()); + } + + public function testEventHelperRegistersManagerInContainer(): void + { + event(); + + $this->assertTrue(Di::isRegistered(EventManager::class)); + } + + public function testEventHelperReturnsSameInstance(): void + { + $this->assertSame(event(), event()); + } + + public function testEventHelperListenAndDispatch(): void + { + $output = ''; + + event()->listen('SAVE', function (array $payload) use (&$output): void { + $output .= 'The file ' . $payload['filename'] . ' was saved'; + }); + + event()->dispatch('SAVE', ['filename' => 'doc.pdf']); + + $this->assertSame('The file doc.pdf was saved', $output); + } +} From eaf568c76e6ce41104a7fef4835e903ac11c13c1 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:44:18 +0400 Subject: [PATCH 2/7] [#536] Remove Hook package in favor of Event Delete Quantum\Hook (HookManager, HookException, hook() helper) and its tests, and read the debugger Hooks tab from event()->getRegistered(). Event replaces Hook with no aliases; no other caller existed. Co-Authored-By: Claude Sonnet 5.5 --- src/App/Traits/WebAppTrait.php | 2 +- src/Hook/Enums/ExceptionMessages.php | 24 ---- src/Hook/Exceptions/HookException.php | 37 ------ src/Hook/Helpers/hook.php | 24 ---- src/Hook/HookManager.php | 111 ------------------ .../Hook/Exceptions/HookExceptionTest.php | 27 ----- tests/Unit/Hook/Helpers/HookHelperTest.php | 80 ------------- tests/Unit/Hook/HookManagerTest.php | 98 ---------------- 8 files changed, 1 insertion(+), 402 deletions(-) delete mode 100644 src/Hook/Enums/ExceptionMessages.php delete mode 100644 src/Hook/Exceptions/HookException.php delete mode 100644 src/Hook/Helpers/hook.php delete mode 100644 src/Hook/HookManager.php delete mode 100644 tests/Unit/Hook/Exceptions/HookExceptionTest.php delete mode 100644 tests/Unit/Hook/Helpers/HookHelperTest.php delete mode 100644 tests/Unit/Hook/HookManagerTest.php diff --git a/src/App/Traits/WebAppTrait.php b/src/App/Traits/WebAppTrait.php index e2ee5cd9..dd268ebe 100644 --- a/src/App/Traits/WebAppTrait.php +++ b/src/App/Traits/WebAppTrait.php @@ -68,7 +68,7 @@ private function logDebugInfo(): void $debugbar = debugbar(); if ($debugbar->isEnabled()) { - $debugbar->addToStoreCell(Debugger::HOOKS, 'info', hook()->getRegistered()); + $debugbar->addToStoreCell(Debugger::HOOKS, 'info', event()->getRegistered()); } } diff --git a/src/Hook/Enums/ExceptionMessages.php b/src/Hook/Enums/ExceptionMessages.php deleted file mode 100644 index f7b3d674..00000000 --- a/src/Hook/Enums/ExceptionMessages.php +++ /dev/null @@ -1,24 +0,0 @@ -> - */ - private array $store = []; - - /** - * @throws HookException|ConfigException|DiException|ReflectionException - */ - public function __construct() - { - if (!config()->has('hooks')) { - config()->import(new Setup('config', 'hooks')); - } - - $registeredHooks = array_merge(self::CORE_HOOKS, config()->get('hooks') ?: []); - - foreach ($registeredHooks as $hookName) { - $this->register($hookName); - } - } - - /** - * Adds a new listener for a given hook - * @throws HookException - */ - public function on(string $name, callable $function): void - { - if (!$this->exists($name)) { - throw HookException::unregisteredHookName($name); - } - - $this->store[$name][] = $function; - } - - /** - * Fires the hook - * @param array|null $args - * @throws HookException - */ - public function fire(string $name, ?array $args = null): void - { - if (!$this->exists($name)) { - throw HookException::unregisteredHookName($name); - } - - foreach ($this->store[$name] as $index => $fn) { - unset($this->store[$name][$index]); - $fn($args); - } - } - - /** - * Gets all registered hooks - * @return array> - */ - public function getRegistered(): array - { - return $this->store; - } - - /** - * Registers new hook - * @throws HookException - */ - protected function register(string $name): void - { - if ($this->exists($name)) { - throw HookException::hookDuplicateName($name); - } - - $this->store[$name] = []; - } - - /** - * Checks if hooks registered - */ - protected function exists(string $name): bool - { - return array_key_exists($name, $this->store); - } -} diff --git a/tests/Unit/Hook/Exceptions/HookExceptionTest.php b/tests/Unit/Hook/Exceptions/HookExceptionTest.php deleted file mode 100644 index 2df1e632..00000000 --- a/tests/Unit/Hook/Exceptions/HookExceptionTest.php +++ /dev/null @@ -1,27 +0,0 @@ -assertInstanceOf(HookException::class, $exception); - $this->assertSame('The Hook `boot` already registered.', $exception->getMessage()); - $this->assertSame(E_ERROR, $exception->getCode()); - } - - public function testUnregisteredHookName(): void - { - $exception = HookException::unregisteredHookName('shutdown'); - - $this->assertInstanceOf(HookException::class, $exception); - $this->assertSame('The Hook `shutdown` was not registered.', $exception->getMessage()); - $this->assertSame(E_WARNING, $exception->getCode()); - } -} diff --git a/tests/Unit/Hook/Helpers/HookHelperTest.php b/tests/Unit/Hook/Helpers/HookHelperTest.php deleted file mode 100644 index 3eacea22..00000000 --- a/tests/Unit/Hook/Helpers/HookHelperTest.php +++ /dev/null @@ -1,80 +0,0 @@ -assertInstanceOf(HookManager::class, hook()); - } - - public function testHookOnAndFire(): void - { - $output = ''; - hook()->on('SAVE', function () use (&$output): void { - $output .= 'Data successfully saved'; - }); - - hook()->fire('SAVE'); - - $this->assertSame('Data successfully saved', $output); - } - - public function testHookFireWithArgument(): void - { - $output = ''; - hook()->on('SAVE', function (array $data) use (&$output): void { - $output .= 'The file ' . $data['filename'] . ' was successfully saved'; - }); - - hook()->fire('SAVE', ['filename' => 'doc.pdf']); - - $this->assertSame('The file doc.pdf was successfully saved', $output); - } - - public function testHookMultipleListeners(): void - { - $output = ''; - hook()->on('SAVE', function (array $data) use (&$output): void { - $output .= 'The file ' . $data['filename'] . ' was successfully saved' . PHP_EOL; - }); - - hook()->on('SAVE', function () use (&$output): void { - $output .= 'The email was successfully sent'; - }); - - hook()->fire('SAVE', ['filename' => 'doc.pdf']); - - $this->assertSame('The file doc.pdf was successfully saved' . PHP_EOL . 'The email was successfully sent', $output); - } - - public function testUnregisteredHookAtOn(): void - { - $this->expectException(HookException::class); - - $this->expectExceptionMessage('The Hook `SOME_EVENT` was not registered.'); - - hook()->on('SOME_EVENT', function (): void { - // No output needed - }); - } - - public function testUnregisteredHookAtFire(): void - { - $this->expectException(HookException::class); - - $this->expectExceptionMessage('The Hook `SOME_EVENT` was not registered.'); - - hook()->fire('SOME_EVENT'); - } -} diff --git a/tests/Unit/Hook/HookManagerTest.php b/tests/Unit/Hook/HookManagerTest.php deleted file mode 100644 index e9c9b948..00000000 --- a/tests/Unit/Hook/HookManagerTest.php +++ /dev/null @@ -1,98 +0,0 @@ -assertInstanceOf(HookManager::class, hook()); - } - - public function testOnAndFireOutputsCorrectly(): void - { - $output = ''; - hook()->on('SAVE', function () use (&$output): void { - $output .= 'Saved!'; - }); - - hook()->fire('SAVE'); - - $this->assertSame('Saved!', $output); - } - - public function testFireWithArguments(): void - { - config()->set('hooks', ['NOTIFY']); - - $output = ''; - hook()->on('NOTIFY', function (array $args) use (&$output): void { - $output .= 'Notified ' . $args['user']; - }); - - hook()->fire('NOTIFY', ['user' => 'John']); - - $this->assertSame('Notified John', $output); - } - - public function testMultipleListeners(): void - { - $output = ''; - hook()->on('SAVE', function () use (&$output): void { - $output .= 'A'; - }); - - hook()->on('SAVE', function () use (&$output): void { - $output .= 'B'; - }); - - hook()->fire('SAVE'); - - $this->assertSame('AB', $output); - } - - public function testHookIsFiredOnlyOncePerListener(): void - { - $output = ''; - hook()->on('SAVE', function () use (&$output): void { - $output .= 'Once'; - }); - - hook()->fire('SAVE'); - hook()->fire('SAVE'); - - $this->assertSame('Once', $output); - } - - public function testUnregisteredHookThrowsOnOn(): void - { - $this->expectException(HookException::class); - $this->expectExceptionMessage('The Hook `INVALID` was not registered.'); - - hook()->on('INVALID', function (): void { - }); - } - - public function testUnregisteredHookThrowsOnFire(): void - { - $this->expectException(HookException::class); - $this->expectExceptionMessage('The Hook `INVALID` was not registered.'); - - hook()->fire('INVALID'); - } - - public function testGetRegisteredReturnsHookStore(): void - { - hook()->on('SAVE', function (): void { - }); - - $store = hook()->getRegistered(); - - $this->assertArrayHasKey('SAVE', $store); - $this->assertCount(1, $store['SAVE']); - } -} From db19a17d7e9c0f427111f58c1b438e826c73f798 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:03:24 +0400 Subject: [PATCH 3/7] [#536] Dispatch boot stage lifecycle events from BootPipeline Boot stages may declare BEFORE/AFTER constants naming an event. BootPipeline dispatches them around each stage with the AppContext as a named payload, and skips dispatch while the event() helper is not loaded yet. Environment, config, error handler, HTTP and modules stages declare boot..before/after; the helpers and debugger stages declare none. Co-Authored-By: Claude Sonnet 5.5 --- src/App/BootPipeline.php | 19 ++ src/App/Stages/InitHttpStage.php | 9 +- src/App/Stages/LoadAppConfigStage.php | 4 + src/App/Stages/LoadEnvironmentStage.php | 4 + src/App/Stages/LoadModulesStage.php | 4 + src/App/Stages/SetupErrorHandlerStage.php | 4 + tests/Unit/App/BootPipelineEventsTest.php | 216 ++++++++++++++++++++++ tests/Unit/App/BootPipelineTest.php | 34 ++++ 8 files changed, 289 insertions(+), 5 deletions(-) create mode 100644 tests/Unit/App/BootPipelineEventsTest.php diff --git a/src/App/BootPipeline.php b/src/App/BootPipeline.php index 89cfa493..486a9aac 100644 --- a/src/App/BootPipeline.php +++ b/src/App/BootPipeline.php @@ -43,7 +43,26 @@ public function __construct(array $stages = []) public function run(AppContext $context): void { foreach ($this->stages as $stage) { + $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. + */ + private function dispatchStageEvent(BootStageInterface $stage, string $point, AppContext $context): void + { + $constant = $stage::class . '::' . $point; + + if (!defined($constant) || !function_exists('event')) { + return; } + + event()->dispatch((string) constant($constant), ['context' => $context]); } } diff --git a/src/App/Stages/InitHttpStage.php b/src/App/Stages/InitHttpStage.php index 0c77901f..ff893653 100644 --- a/src/App/Stages/InitHttpStage.php +++ b/src/App/Stages/InitHttpStage.php @@ -11,11 +11,9 @@ namespace Quantum\App\Stages; use Quantum\App\Contracts\BootStageInterface; -use Quantum\Di\Exceptions\DiException; use Quantum\App\AppContext; use Quantum\Http\Response; use Quantum\Http\Request; -use ReflectionException; use Quantum\Di\Di; /** @@ -24,9 +22,10 @@ */ class InitHttpStage implements BootStageInterface { - /** - * @throws DiException|ReflectionException - */ + public const BEFORE = 'boot.http.before'; + + public const AFTER = 'boot.http.after'; + public function process(AppContext $context): void { if (!Di::isRegistered(Request::class)) { diff --git a/src/App/Stages/LoadAppConfigStage.php b/src/App/Stages/LoadAppConfigStage.php index 156b3731..f2944bdf 100644 --- a/src/App/Stages/LoadAppConfigStage.php +++ b/src/App/Stages/LoadAppConfigStage.php @@ -23,6 +23,10 @@ */ class LoadAppConfigStage implements BootStageInterface { + public const BEFORE = 'boot.config.before'; + + public const AFTER = 'boot.config.after'; + /** * @throws ConfigException|DiException|ReflectionException */ diff --git a/src/App/Stages/LoadEnvironmentStage.php b/src/App/Stages/LoadEnvironmentStage.php index 6fd49c31..16f935cc 100644 --- a/src/App/Stages/LoadEnvironmentStage.php +++ b/src/App/Stages/LoadEnvironmentStage.php @@ -24,6 +24,10 @@ */ class LoadEnvironmentStage implements BootStageInterface { + public const BEFORE = 'boot.environment.before'; + + public const AFTER = 'boot.environment.after'; + /** * @throws EnvException|DiException|BaseException */ diff --git a/src/App/Stages/LoadModulesStage.php b/src/App/Stages/LoadModulesStage.php index 2baa9eb0..c75ff5c7 100644 --- a/src/App/Stages/LoadModulesStage.php +++ b/src/App/Stages/LoadModulesStage.php @@ -27,6 +27,10 @@ */ class LoadModulesStage implements BootStageInterface { + public const BEFORE = 'boot.modules.before'; + + public const AFTER = 'boot.modules.after'; + /** * @throws ModuleException|RouteException|DiException|ReflectionException */ diff --git a/src/App/Stages/SetupErrorHandlerStage.php b/src/App/Stages/SetupErrorHandlerStage.php index cf565ee5..88097abb 100644 --- a/src/App/Stages/SetupErrorHandlerStage.php +++ b/src/App/Stages/SetupErrorHandlerStage.php @@ -26,6 +26,10 @@ */ class SetupErrorHandlerStage implements BootStageInterface { + public const BEFORE = 'boot.error_handler.before'; + + public const AFTER = 'boot.error_handler.after'; + /** * @throws ConfigException|DiException|BaseException|ReflectionException */ diff --git a/tests/Unit/App/BootPipelineEventsTest.php b/tests/Unit/App/BootPipelineEventsTest.php new file mode 100644 index 00000000..ff3ae17c --- /dev/null +++ b/tests/Unit/App/BootPipelineEventsTest.php @@ -0,0 +1,216 @@ +listen('test.first.before', function () use (&$log): void { + $log[] = 'before'; + }); + + event()->listen('test.first.after', function () use (&$log): void { + $log[] = 'after'; + }); + + $pipeline = new BootPipeline([$this->firstStage(function () use (&$log): void { + $log[] = 'process'; + })]); + + $pipeline->run($this->context()); + + $this->assertSame(['before', 'process', 'after'], $log); + } + + public function testEventsFollowStageOrder(): void + { + $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; + }); + } + + $pipeline = new BootPipeline([ + $this->firstStage(function () use (&$log): void { + $log[] = 'process first'; + }), + $this->secondStage(function () use (&$log): void { + $log[] = 'process second'; + }), + ]); + + $pipeline->run($this->context()); + + $this->assertSame([ + 'test.first.before', + 'process first', + 'test.first.after', + 'test.second.before', + 'process second', + 'test.second.after', + ], $log); + } + + public function testEventPayloadCarriesContext(): void + { + $payloads = []; + + event()->listen('test.first.before', function (array $payload) use (&$payloads): void { + $payloads[] = $payload; + }); + + event()->listen('test.first.after', function (array $payload) use (&$payloads): void { + $payloads[] = $payload; + }); + + $context = $this->context(); + + (new BootPipeline([$this->firstStage(function (): void { + })]))->run($context); + + $this->assertCount(2, $payloads); + $this->assertSame(['context'], array_keys($payloads[0])); + $this->assertSame($context, $payloads[0]['context']); + $this->assertSame($context, $payloads[1]['context']); + } + + public function testStageDeclaringOnlyAfterDispatchesOnlyAfter(): void + { + $log = []; + + event()->listen('test.after-only.after', function () use (&$log): void { + $log[] = 'after'; + }); + + (new BootPipeline([$this->afterOnlyStage(function () use (&$log): void { + $log[] = 'process'; + })]))->run($this->context()); + + $this->assertSame(['process', 'after'], $log); + } + + public function testListenersAreNotConsumedByStageEvents(): void + { + $count = 0; + + event()->listen('test.first.before', function () use (&$count): void { + $count++; + }); + + $pipeline = new BootPipeline([$this->firstStage(function (): void { + })]); + + $pipeline->run($this->context()); + $pipeline->run($this->context()); + + $this->assertSame(2, $count); + } + + public function testBootStagesDeclareEventNames(): void + { + $this->assertSame('boot.environment.before', LoadEnvironmentStage::BEFORE); + $this->assertSame('boot.environment.after', LoadEnvironmentStage::AFTER); + + $this->assertSame('boot.config.before', LoadAppConfigStage::BEFORE); + $this->assertSame('boot.config.after', LoadAppConfigStage::AFTER); + + $this->assertSame('boot.error_handler.before', SetupErrorHandlerStage::BEFORE); + $this->assertSame('boot.error_handler.after', SetupErrorHandlerStage::AFTER); + + $this->assertSame('boot.http.before', InitHttpStage::BEFORE); + $this->assertSame('boot.http.after', InitHttpStage::AFTER); + + $this->assertSame('boot.modules.before', LoadModulesStage::BEFORE); + $this->assertSame('boot.modules.after', LoadModulesStage::AFTER); + } + + public function testHelpersAndDebuggerStagesDeclareNoEvents(): void + { + foreach ([LoadHelpersStage::class, InitDebuggerStage::class] as $stage) { + $this->assertFalse(defined($stage . '::BEFORE'), $stage . ' must not declare BEFORE'); + $this->assertFalse(defined($stage . '::AFTER'), $stage . ' must not declare AFTER'); + } + } + + private function context(): AppContext + { + return new AppContext(PROJECT_ROOT, new DiContainer()); + } + + private function firstStage(callable $callback): BootStageInterface + { + return new class ($callback) implements BootStageInterface { + public const BEFORE = 'test.first.before'; + public const AFTER = 'test.first.after'; + + private $callback; + + public function __construct(callable $callback) + { + $this->callback = $callback; + } + + public function process(AppContext $context): void + { + ($this->callback)($context); + } + }; + } + + private function secondStage(callable $callback): BootStageInterface + { + return new class ($callback) implements BootStageInterface { + public const BEFORE = 'test.second.before'; + public const AFTER = 'test.second.after'; + + private $callback; + + public function __construct(callable $callback) + { + $this->callback = $callback; + } + + public function process(AppContext $context): void + { + ($this->callback)($context); + } + }; + } + + private function afterOnlyStage(callable $callback): BootStageInterface + { + return new class ($callback) implements BootStageInterface { + public const AFTER = 'test.after-only.after'; + + private $callback; + + public function __construct(callable $callback) + { + $this->callback = $callback; + } + + public function process(AppContext $context): void + { + ($this->callback)($context); + } + }; + } +} diff --git a/tests/Unit/App/BootPipelineTest.php b/tests/Unit/App/BootPipelineTest.php index f425fdae..5fb4cba5 100644 --- a/tests/Unit/App/BootPipelineTest.php +++ b/tests/Unit/App/BootPipelineTest.php @@ -69,6 +69,40 @@ public function testPipelinePropagatesException(): void $pipeline->run(new AppContext('', new DiContainer())); } + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function testStageEventsAreSkippedWhileEventHelperIsNotLoaded(): void + { + $this->assertFalse(function_exists('event')); + + $processed = false; + + $stage = new class (function () use (&$processed): void { + $processed = true; + }) implements BootStageInterface { + public const BEFORE = 'test.early.before'; + public const AFTER = 'test.early.after'; + + private $callback; + + public function __construct(callable $callback) + { + $this->callback = $callback; + } + + public function process(AppContext $context): void + { + ($this->callback)($context); + } + }; + + (new BootPipeline([$stage]))->run(new AppContext('', new DiContainer())); + + $this->assertTrue($processed); + } + public function testPipelineRejectsInvalidStage(): void { $this->expectException(InvalidArgumentException::class); From 06824b805bb8630eef5ee729397cac3d1d81b264 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:19:02 +0400 Subject: [PATCH 4/7] [#536] Test app helper listener registration and align test names Cover registering an event listener from an app helper during web boot: it fires at boot.modules.before, registers before the app config is loaded, and is not registered again on a second boot in the same process (helpers load with require_once). Align the Event and boot pipeline tests with repo conventions: name methods test, move the pipeline event tests into BootPipelineTest, and add EventExceptionTest to mirror EventException. Co-Authored-By: Claude Sonnet 5.5 --- tests/Unit/App/Adapters/WebAppAdapterTest.php | 69 +++++ tests/Unit/App/BootPipelineEventsTest.php | 216 ---------------- tests/Unit/App/BootPipelineTest.php | 243 ++++++++++++++++-- tests/Unit/Event/EventManagerTest.php | 24 +- .../Event/Exceptions/EventExceptionTest.php | 18 ++ 5 files changed, 326 insertions(+), 244 deletions(-) delete mode 100644 tests/Unit/App/BootPipelineEventsTest.php create mode 100644 tests/Unit/Event/Exceptions/EventExceptionTest.php diff --git a/tests/Unit/App/Adapters/WebAppAdapterTest.php b/tests/Unit/App/Adapters/WebAppAdapterTest.php index 3e512ae3..d13f2e66 100644 --- a/tests/Unit/App/Adapters/WebAppAdapterTest.php +++ b/tests/Unit/App/Adapters/WebAppAdapterTest.php @@ -95,4 +95,73 @@ public function send(): void $this->assertSame('1', response()->getHeader('X-Test')); $this->assertSame(200, response()->getStatusCode()); } + + public function testWebAppAdapterBootFiresAppHelperListenerAtModulesBeforeEvent(): void + { + $this->withTemporaryAppHelper(function (): void { + $context = $this->createContext(); + + new WebAppAdapter($context); + + $this->assertCount(1, $GLOBALS['bootEventPayloads']); + $this->assertSame($context, $GLOBALS['bootEventPayloads'][0]['context']); + }); + } + + public function testWebAppAdapterBootRegistersAppHelperListenerBeforeAppConfigIsLoaded(): void + { + $this->withTemporaryAppHelper(function (): void { + new WebAppAdapter($this->createContext()); + + $this->assertFalse($GLOBALS['bootEventConfigLoadedAtRegistration']); + $this->assertTrue(config()->has('app')); + }); + } + + public function testWebAppAdapterSecondBootDoesNotRegisterAppHelperListenerAgain(): void + { + $this->withTemporaryAppHelper(function (): void { + new WebAppAdapter($this->createContext()); + $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()); + $this->assertCount(1, $GLOBALS['bootEventPayloads']); + }); + } + + private function withTemporaryAppHelper(callable $test): void + { + $dir = PROJECT_ROOT . DS . 'helpers'; + $createdDir = !is_dir($dir); + $file = $dir . DS . uniqid('event_listener_helper_') . '.php'; + + $helper = <<<'PHP' +has('app'); + +event()->listen('boot.modules.before', function (array $payload): void { + $GLOBALS['bootEventPayloads'][] = $payload; +}); +PHP; + + try { + if ($createdDir) { + $this->assertTrue(mkdir($dir)); + } + + $this->assertNotFalse(file_put_contents($file, $helper)); + + $test(); + } finally { + if (is_file($file)) { + unlink($file); + } + if ($createdDir && is_dir($dir)) { + rmdir($dir); + } + unset($GLOBALS['bootEventPayloads'], $GLOBALS['bootEventConfigLoadedAtRegistration']); + } + } } diff --git a/tests/Unit/App/BootPipelineEventsTest.php b/tests/Unit/App/BootPipelineEventsTest.php deleted file mode 100644 index ff3ae17c..00000000 --- a/tests/Unit/App/BootPipelineEventsTest.php +++ /dev/null @@ -1,216 +0,0 @@ -listen('test.first.before', function () use (&$log): void { - $log[] = 'before'; - }); - - event()->listen('test.first.after', function () use (&$log): void { - $log[] = 'after'; - }); - - $pipeline = new BootPipeline([$this->firstStage(function () use (&$log): void { - $log[] = 'process'; - })]); - - $pipeline->run($this->context()); - - $this->assertSame(['before', 'process', 'after'], $log); - } - - public function testEventsFollowStageOrder(): void - { - $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; - }); - } - - $pipeline = new BootPipeline([ - $this->firstStage(function () use (&$log): void { - $log[] = 'process first'; - }), - $this->secondStage(function () use (&$log): void { - $log[] = 'process second'; - }), - ]); - - $pipeline->run($this->context()); - - $this->assertSame([ - 'test.first.before', - 'process first', - 'test.first.after', - 'test.second.before', - 'process second', - 'test.second.after', - ], $log); - } - - public function testEventPayloadCarriesContext(): void - { - $payloads = []; - - event()->listen('test.first.before', function (array $payload) use (&$payloads): void { - $payloads[] = $payload; - }); - - event()->listen('test.first.after', function (array $payload) use (&$payloads): void { - $payloads[] = $payload; - }); - - $context = $this->context(); - - (new BootPipeline([$this->firstStage(function (): void { - })]))->run($context); - - $this->assertCount(2, $payloads); - $this->assertSame(['context'], array_keys($payloads[0])); - $this->assertSame($context, $payloads[0]['context']); - $this->assertSame($context, $payloads[1]['context']); - } - - public function testStageDeclaringOnlyAfterDispatchesOnlyAfter(): void - { - $log = []; - - event()->listen('test.after-only.after', function () use (&$log): void { - $log[] = 'after'; - }); - - (new BootPipeline([$this->afterOnlyStage(function () use (&$log): void { - $log[] = 'process'; - })]))->run($this->context()); - - $this->assertSame(['process', 'after'], $log); - } - - public function testListenersAreNotConsumedByStageEvents(): void - { - $count = 0; - - event()->listen('test.first.before', function () use (&$count): void { - $count++; - }); - - $pipeline = new BootPipeline([$this->firstStage(function (): void { - })]); - - $pipeline->run($this->context()); - $pipeline->run($this->context()); - - $this->assertSame(2, $count); - } - - public function testBootStagesDeclareEventNames(): void - { - $this->assertSame('boot.environment.before', LoadEnvironmentStage::BEFORE); - $this->assertSame('boot.environment.after', LoadEnvironmentStage::AFTER); - - $this->assertSame('boot.config.before', LoadAppConfigStage::BEFORE); - $this->assertSame('boot.config.after', LoadAppConfigStage::AFTER); - - $this->assertSame('boot.error_handler.before', SetupErrorHandlerStage::BEFORE); - $this->assertSame('boot.error_handler.after', SetupErrorHandlerStage::AFTER); - - $this->assertSame('boot.http.before', InitHttpStage::BEFORE); - $this->assertSame('boot.http.after', InitHttpStage::AFTER); - - $this->assertSame('boot.modules.before', LoadModulesStage::BEFORE); - $this->assertSame('boot.modules.after', LoadModulesStage::AFTER); - } - - public function testHelpersAndDebuggerStagesDeclareNoEvents(): void - { - foreach ([LoadHelpersStage::class, InitDebuggerStage::class] as $stage) { - $this->assertFalse(defined($stage . '::BEFORE'), $stage . ' must not declare BEFORE'); - $this->assertFalse(defined($stage . '::AFTER'), $stage . ' must not declare AFTER'); - } - } - - private function context(): AppContext - { - return new AppContext(PROJECT_ROOT, new DiContainer()); - } - - private function firstStage(callable $callback): BootStageInterface - { - return new class ($callback) implements BootStageInterface { - public const BEFORE = 'test.first.before'; - public const AFTER = 'test.first.after'; - - private $callback; - - public function __construct(callable $callback) - { - $this->callback = $callback; - } - - public function process(AppContext $context): void - { - ($this->callback)($context); - } - }; - } - - private function secondStage(callable $callback): BootStageInterface - { - return new class ($callback) implements BootStageInterface { - public const BEFORE = 'test.second.before'; - public const AFTER = 'test.second.after'; - - private $callback; - - public function __construct(callable $callback) - { - $this->callback = $callback; - } - - public function process(AppContext $context): void - { - ($this->callback)($context); - } - }; - } - - private function afterOnlyStage(callable $callback): BootStageInterface - { - return new class ($callback) implements BootStageInterface { - public const AFTER = 'test.after-only.after'; - - private $callback; - - public function __construct(callable $callback) - { - $this->callback = $callback; - } - - public function process(AppContext $context): void - { - ($this->callback)($context); - } - }; - } -} diff --git a/tests/Unit/App/BootPipelineTest.php b/tests/Unit/App/BootPipelineTest.php index 5fb4cba5..03685fca 100644 --- a/tests/Unit/App/BootPipelineTest.php +++ b/tests/Unit/App/BootPipelineTest.php @@ -2,16 +2,33 @@ namespace Quantum\Tests\Unit\App; +use Quantum\App\Stages\SetupErrorHandlerStage; use Quantum\App\Contracts\BootStageInterface; +use Quantum\App\Stages\LoadEnvironmentStage; +use Quantum\App\Stages\LoadAppConfigStage; +use Quantum\App\Stages\InitDebuggerStage; +use Quantum\App\Stages\LoadModulesStage; +use Quantum\App\Stages\LoadHelpersStage; +use Quantum\App\Stages\InitHttpStage; +use Quantum\Tests\Unit\AppTestCase; +use InvalidArgumentException; use Quantum\App\BootPipeline; -use Quantum\App\AppContext; use Quantum\Di\DiContainer; -use PHPUnit\Framework\TestCase; -use InvalidArgumentException; +use Quantum\App\AppContext; use RuntimeException; -class BootPipelineTest extends TestCase +class BootPipelineTest extends AppTestCase { + public function setUp(): void + { + $this->context = $this->createContext(); + } + + public function tearDown(): void + { + $this->clearAppContext(); + } + public function testPipelineRunsStagesInOrder(): void { $log = []; @@ -69,22 +86,186 @@ public function testPipelinePropagatesException(): void $pipeline->run(new AppContext('', new DiContainer())); } + public function testPipelineDispatchesEventsAroundStage(): void + { + $this->loadHelpers(); + + $log = []; + + event()->listen('test.first.before', function () use (&$log): void { + $log[] = 'before'; + }); + + event()->listen('test.first.after', function () use (&$log): void { + $log[] = 'after'; + }); + + $pipeline = new BootPipeline([$this->createFirstEventStage(function () use (&$log): void { + $log[] = 'process'; + })]); + + $pipeline->run($this->context); + + $this->assertSame(['before', 'process', 'after'], $log); + } + + public function testPipelineDispatchesEventsInStageOrder(): 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; + }); + } + + $pipeline = new BootPipeline([ + $this->createFirstEventStage(function () use (&$log): void { + $log[] = 'process first'; + }), + $this->createSecondEventStage(function () use (&$log): void { + $log[] = 'process second'; + }), + ]); + + $pipeline->run($this->context); + + $this->assertSame([ + 'test.first.before', + 'process first', + 'test.first.after', + 'test.second.before', + 'process second', + 'test.second.after', + ], $log); + } + + public function testPipelinePassesContextInEventPayload(): void + { + $this->loadHelpers(); + + $payloads = []; + + event()->listen('test.first.before', function (array $payload) use (&$payloads): void { + $payloads[] = $payload; + }); + + event()->listen('test.first.after', function (array $payload) use (&$payloads): void { + $payloads[] = $payload; + }); + + $pipeline = new BootPipeline([$this->createFirstEventStage(function (): void { + })]); + + $pipeline->run($this->context); + + $this->assertCount(2, $payloads); + $this->assertSame(['context'], array_keys($payloads[0])); + $this->assertSame($this->context, $payloads[0]['context']); + $this->assertSame($this->context, $payloads[1]['context']); + } + + public function testPipelineDispatchesOnlyAfterEventWhenStageDeclaresOnlyAfter(): void + { + $this->loadHelpers(); + + $log = []; + + event()->listen('test.after-only.after', function () use (&$log): void { + $log[] = 'after'; + }); + + $pipeline = new BootPipeline([$this->createAfterOnlyEventStage(function () use (&$log): void { + $log[] = 'process'; + })]); + + $pipeline->run($this->context); + + $this->assertSame(['process', 'after'], $log); + } + + public function testPipelineKeepsListenersAfterStageEvents(): void + { + $this->loadHelpers(); + + $count = 0; + + event()->listen('test.first.before', function () use (&$count): void { + $count++; + }); + + $pipeline = new BootPipeline([$this->createFirstEventStage(function (): void { + })]); + + $pipeline->run($this->context); + $pipeline->run($this->context); + + $this->assertSame(2, $count); + } + + public function testPipelineStagesDeclareBootEventNames(): void + { + $this->assertSame('boot.environment.before', LoadEnvironmentStage::BEFORE); + $this->assertSame('boot.environment.after', LoadEnvironmentStage::AFTER); + + $this->assertSame('boot.config.before', LoadAppConfigStage::BEFORE); + $this->assertSame('boot.config.after', LoadAppConfigStage::AFTER); + + $this->assertSame('boot.error_handler.before', SetupErrorHandlerStage::BEFORE); + $this->assertSame('boot.error_handler.after', SetupErrorHandlerStage::AFTER); + + $this->assertSame('boot.http.before', InitHttpStage::BEFORE); + $this->assertSame('boot.http.after', InitHttpStage::AFTER); + + $this->assertSame('boot.modules.before', LoadModulesStage::BEFORE); + $this->assertSame('boot.modules.after', LoadModulesStage::AFTER); + } + + public function testLoadHelpersAndInitDebuggerStagesDeclareNoEvents(): void + { + foreach ([LoadHelpersStage::class, InitDebuggerStage::class] as $stage) { + $this->assertFalse(defined($stage . '::BEFORE'), $stage . ' must not declare BEFORE'); + $this->assertFalse(defined($stage . '::AFTER'), $stage . ' must not declare AFTER'); + } + } + /** * @runInSeparateProcess * @preserveGlobalState disabled */ - public function testStageEventsAreSkippedWhileEventHelperIsNotLoaded(): void + public function testPipelineSkipsStageEventsWhileEventHelperIsNotLoaded(): void { $this->assertFalse(function_exists('event')); $processed = false; - $stage = new class (function () use (&$processed): void { + $pipeline = new BootPipeline([$this->createFirstEventStage(function () use (&$processed): void { $processed = true; - }) implements BootStageInterface { - public const BEFORE = 'test.early.before'; - public const AFTER = 'test.early.after'; + })]); + + $pipeline->run($this->context); + + $this->assertTrue($processed); + } + public function testPipelineRejectsInvalidStage(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('All stages must implement'); + + new BootPipeline([new \stdClass()]); + } + + private function loadHelpers(): void + { + (new LoadHelpersStage())->process($this->context); + } + + private function createStage(callable $callback): BootStageInterface + { + return new class ($callback) implements BootStageInterface { private $callback; public function __construct(callable $callback) @@ -97,23 +278,53 @@ public function process(AppContext $context): void ($this->callback)($context); } }; + } - (new BootPipeline([$stage]))->run(new AppContext('', new DiContainer())); + private function createFirstEventStage(callable $callback): BootStageInterface + { + return new class ($callback) implements BootStageInterface { + public const BEFORE = 'test.first.before'; + public const AFTER = 'test.first.after'; - $this->assertTrue($processed); + private $callback; + + public function __construct(callable $callback) + { + $this->callback = $callback; + } + + public function process(AppContext $context): void + { + ($this->callback)($context); + } + }; } - public function testPipelineRejectsInvalidStage(): void + private function createSecondEventStage(callable $callback): BootStageInterface { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('All stages must implement'); + return new class ($callback) implements BootStageInterface { + public const BEFORE = 'test.second.before'; + public const AFTER = 'test.second.after'; - new BootPipeline([new \stdClass()]); + private $callback; + + public function __construct(callable $callback) + { + $this->callback = $callback; + } + + public function process(AppContext $context): void + { + ($this->callback)($context); + } + }; } - private function createStage(callable $callback): BootStageInterface + private function createAfterOnlyEventStage(callable $callback): BootStageInterface { return new class ($callback) implements BootStageInterface { + public const AFTER = 'test.after-only.after'; + private $callback; public function __construct(callable $callback) diff --git a/tests/Unit/Event/EventManagerTest.php b/tests/Unit/Event/EventManagerTest.php index 942de2a6..e1dd7267 100644 --- a/tests/Unit/Event/EventManagerTest.php +++ b/tests/Unit/Event/EventManagerTest.php @@ -18,7 +18,7 @@ public function setUp(): void $this->events = new EventManager(); } - public function testListenAndDispatch(): void + public function testEventManagerDispatchesToListener(): void { $output = ''; @@ -31,7 +31,7 @@ public function testListenAndDispatch(): void $this->assertSame('Saved!', $output); } - public function testDispatchPassesPayloadToListener(): void + public function testEventManagerPassesPayloadToListener(): void { $received = null; @@ -44,7 +44,7 @@ public function testDispatchPassesPayloadToListener(): void $this->assertSame(['user' => 'John'], $received); } - public function testDispatchWithoutPayloadPassesEmptyArray(): void + public function testEventManagerPassesEmptyPayloadWhenNoneGiven(): void { $received = null; @@ -57,7 +57,7 @@ public function testDispatchWithoutPayloadPassesEmptyArray(): void $this->assertSame([], $received); } - public function testListenersRunInRegistrationOrder(): void + public function testEventManagerRunsListenersInRegistrationOrder(): void { $output = ''; @@ -74,7 +74,7 @@ public function testListenersRunInRegistrationOrder(): void $this->assertSame('AB', $output); } - public function testListenersPersistAfterDispatch(): void + public function testEventManagerKeepsListenersAfterDispatch(): void { $count = 0; @@ -88,7 +88,7 @@ public function testListenersPersistAfterDispatch(): void $this->assertSame(2, $count); } - public function testListenersAreScopedByEventName(): void + public function testEventManagerScopesListenersByEventName(): void { $output = ''; @@ -105,14 +105,14 @@ public function testListenersAreScopedByEventName(): void $this->assertSame('delete', $output); } - public function testDispatchWithoutListenersIsNoOp(): void + public function testEventManagerIgnoresDispatchWithoutListeners(): void { $this->events->dispatch('NEVER_LISTENED'); $this->assertSame([], $this->events->getRegistered()); } - public function testListenerExceptionPropagatesAndStopsLaterListeners(): void + public function testEventManagerPropagatesListenerExceptionAndStopsLaterListeners(): void { $called = false; @@ -134,7 +134,7 @@ public function testListenerExceptionPropagatesAndStopsLaterListeners(): void $this->assertFalse($called); } - public function testListenWithEmptyNameThrows(): void + public function testEventManagerThrowsWhenListeningWithEmptyName(): void { $this->expectException(EventException::class); $this->expectExceptionMessage('The event name must not be empty.'); @@ -143,7 +143,7 @@ public function testListenWithEmptyNameThrows(): void }); } - public function testDispatchWithEmptyNameThrows(): void + public function testEventManagerThrowsWhenDispatchingWithEmptyName(): void { $this->expectException(EventException::class); $this->expectExceptionMessage('The event name must not be empty.'); @@ -151,7 +151,7 @@ public function testDispatchWithEmptyNameThrows(): void $this->events->dispatch(''); } - public function testGetRegisteredReturnsListenersByName(): void + public function testEventManagerGetRegisteredReturnsListenersByName(): void { $this->events->listen('SAVE', function (): void { }); @@ -165,7 +165,7 @@ public function testGetRegisteredReturnsListenersByName(): void $this->assertCount(2, $registered['SAVE']); } - public function testWorksWithoutLoadedConfig(): void + public function testEventManagerWorksWithoutLoadedConfig(): void { config()->flush(); diff --git a/tests/Unit/Event/Exceptions/EventExceptionTest.php b/tests/Unit/Event/Exceptions/EventExceptionTest.php new file mode 100644 index 00000000..0d4b046e --- /dev/null +++ b/tests/Unit/Event/Exceptions/EventExceptionTest.php @@ -0,0 +1,18 @@ +assertInstanceOf(EventException::class, $exception); + $this->assertSame('The event name must not be empty.', $exception->getMessage()); + $this->assertSame(E_ERROR, $exception->getCode()); + } +} From 9eb8e41c97484e6b71881fdb9fe07e161120a758 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:21:13 +0400 Subject: [PATCH 5/7] [#536] Document Event package and boot stage events in changelog Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cddbbaa5..957f01b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,15 @@ The format is based on Keep a Changelog. ## [3.1.0] - TBD ### Added +- Added boot stage lifecycle events: stages may declare `BEFORE`/`AFTER` event names and `BootPipeline` dispatches them with the `AppContext` as a named payload. The environment, config, error handler, HTTP, and modules stages declare `boot..before` and `boot..after` (#536) - Added adapter-based Lang provider support with `DeepL` and `Google Translate` adapters plus shared remote request/caching infrastructure (#533) ### Changed +- **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`. + - Listeners stay registered after `dispatch()` instead of being removed after the first call. + - Listeners can be registered from app helper files, which load before config and before any boot event. - **BREAKING:** Removed `Quantum\Loader` and moved its responsibilities to their owning packages (#535): - Moved `Setup` to `Quantum\Config\Setup`; required config failures now use `ConfigException`, and optional imports use `Config::importIfExists()`. - Config paths resolve from the application base directory, independent of the process working directory. From 72c4ffb13f1c7487cd7c41a61a059e9bc13433a7 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:35:56 +0400 Subject: [PATCH 6/7] [#536] Cover debugger event logging in WebAppAdapter Test that logDebugInfo() records registered event listeners in the debugger Hooks cell, records nothing without listeners or when debug is disabled, and reset the shared debugger store in tearDown() so the static store does not leak between test classes. Co-Authored-By: Claude Sonnet 5.5 --- tests/Unit/App/Adapters/WebAppAdapterTest.php | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/Unit/App/Adapters/WebAppAdapterTest.php b/tests/Unit/App/Adapters/WebAppAdapterTest.php index d13f2e66..4bff6510 100644 --- a/tests/Unit/App/Adapters/WebAppAdapterTest.php +++ b/tests/Unit/App/Adapters/WebAppAdapterTest.php @@ -5,7 +5,9 @@ use Quantum\Http\Exceptions\HttpException; use Quantum\App\Adapters\WebAppAdapter; use Quantum\Tests\Unit\AppTestCase; +use Quantum\Debugger\Debugger; use Quantum\Router\Route; +use Quantum\Di\Di; use Throwable; class WebAppAdapterTest extends AppTestCase @@ -19,6 +21,10 @@ public function setUp(): void public function tearDown(): void { + if (Di::isRegistered(Debugger::class)) { + Di::get(Debugger::class)->resetStore(); + } + config()->flush(); $this->clearAppContext(); } @@ -96,6 +102,46 @@ public function send(): void $this->assertSame(200, response()->getStatusCode()); } + public function testWebAppAdapterLogsRegisteredEventsToDebugger(): void + { + config()->set('app.debug', true); + + $listener = function (): void { + }; + + event()->listen('test.event', $listener); + + $this->invokePrivateMethod($this->webAppAdapter, 'logDebugInfo'); + + $cell = debugbar()->getStoreCell(Debugger::HOOKS); + + $this->assertCount(1, $cell); + $this->assertSame(['test.event' => [$listener]], $cell[0]['info']); + } + + public function testWebAppAdapterLogsNothingToDebuggerWithoutEventListeners(): void + { + config()->set('app.debug', true); + + $this->assertSame([], event()->getRegistered()); + + $this->invokePrivateMethod($this->webAppAdapter, 'logDebugInfo'); + + $this->assertSame([], debugbar()->getStoreCell(Debugger::HOOKS)); + } + + public function testWebAppAdapterSkipsDebuggerEventLogWhenDebugDisabled(): void + { + config()->set('app.debug', false); + + event()->listen('test.event', function (): void { + }); + + $this->invokePrivateMethod($this->webAppAdapter, 'logDebugInfo'); + + $this->assertSame([], debugbar()->getStoreCell(Debugger::HOOKS)); + } + public function testWebAppAdapterBootFiresAppHelperListenerAtModulesBeforeEvent(): void { $this->withTemporaryAppHelper(function (): void { From 39adef2859297b8747cf251bb2bbc9efb7e4929a Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:45:31 +0400 Subject: [PATCH 7/7] [#536] Remove leftover Hook names from helpers and debugbar Delete the unused hooks_dir() helper and its test, rename the debugger tab constant Debugger::HOOKS to Debugger::EVENTS (tab value "events"), and drop the obsolete tests/_root hooks.php config fixture. Record the removal and rename in the changelog as part of the Hook to Event change. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + src/App/Helpers/dir.php | 8 -------- src/App/Traits/WebAppTrait.php | 2 +- src/Debugger/Debugger.php | 8 ++++---- tests/Unit/App/Adapters/WebAppAdapterTest.php | 6 +++--- tests/Unit/App/Helpers/DirHelperTest.php | 6 ------ tests/Unit/Debugger/DebuggerTest.php | 2 +- tests/_root/shared/config/hooks.php | 5 ----- 8 files changed, 10 insertions(+), 28 deletions(-) delete mode 100644 tests/_root/shared/config/hooks.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 957f01b2..21f90c87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ The format is based on Keep a Changelog. - 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`. - Listeners stay registered after `dispatch()` instead of being removed after the first call. - Listeners can be registered from app helper files, which load before config and before any boot event. + - Removed the unused `hooks_dir()` helper and renamed the debugger tab constant `Debugger::HOOKS` to `Debugger::EVENTS`; the debugbar tab is now `events` and lists only events that have listeners. - **BREAKING:** Removed `Quantum\Loader` and moved its responsibilities to their owning packages (#535): - Moved `Setup` to `Quantum\Config\Setup`; required config failures now use `ConfigException`, and optional imports use `Config::importIfExists()`. - Config paths resolve from the application base directory, independent of the process working directory. diff --git a/src/App/Helpers/dir.php b/src/App/Helpers/dir.php index cdea4218..d3f713fa 100644 --- a/src/App/Helpers/dir.php +++ b/src/App/Helpers/dir.php @@ -64,14 +64,6 @@ function assets_dir(): string return public_dir() . DS . 'assets'; } -/** - * Gets hooks directory - */ -function hooks_dir(): string -{ - return App::getBaseDir() . DS . 'hooks'; -} - /** * Recursively deletes folder */ diff --git a/src/App/Traits/WebAppTrait.php b/src/App/Traits/WebAppTrait.php index dd268ebe..91cb8866 100644 --- a/src/App/Traits/WebAppTrait.php +++ b/src/App/Traits/WebAppTrait.php @@ -68,7 +68,7 @@ private function logDebugInfo(): void $debugbar = debugbar(); if ($debugbar->isEnabled()) { - $debugbar->addToStoreCell(Debugger::HOOKS, 'info', event()->getRegistered()); + $debugbar->addToStoreCell(Debugger::EVENTS, 'info', event()->getRegistered()); } } diff --git a/src/Debugger/Debugger.php b/src/Debugger/Debugger.php index a42db167..558063b6 100644 --- a/src/Debugger/Debugger.php +++ b/src/Debugger/Debugger.php @@ -44,9 +44,9 @@ class Debugger public const ROUTES = 'routes'; /** - * Hooks tab + * Events tab */ - public const HOOKS = 'hooks'; + public const EVENTS = 'events'; /** * Mails tab @@ -104,7 +104,7 @@ public function initStore(): void Debugger::MESSAGES, Debugger::QUERIES, Debugger::ROUTES, - Debugger::HOOKS, + Debugger::EVENTS, Debugger::MAILS, ]); } @@ -151,7 +151,7 @@ public function render(): string return ''; } - foreach ([self::MESSAGES, self::QUERIES, self::ROUTES, self::HOOKS, self::MAILS] as $tab) { + foreach ([self::MESSAGES, self::QUERIES, self::ROUTES, self::EVENTS, self::MAILS] as $tab) { $this->createTab($tab); } diff --git a/tests/Unit/App/Adapters/WebAppAdapterTest.php b/tests/Unit/App/Adapters/WebAppAdapterTest.php index 4bff6510..c651d122 100644 --- a/tests/Unit/App/Adapters/WebAppAdapterTest.php +++ b/tests/Unit/App/Adapters/WebAppAdapterTest.php @@ -113,7 +113,7 @@ public function testWebAppAdapterLogsRegisteredEventsToDebugger(): void $this->invokePrivateMethod($this->webAppAdapter, 'logDebugInfo'); - $cell = debugbar()->getStoreCell(Debugger::HOOKS); + $cell = debugbar()->getStoreCell(Debugger::EVENTS); $this->assertCount(1, $cell); $this->assertSame(['test.event' => [$listener]], $cell[0]['info']); @@ -127,7 +127,7 @@ public function testWebAppAdapterLogsNothingToDebuggerWithoutEventListeners(): v $this->invokePrivateMethod($this->webAppAdapter, 'logDebugInfo'); - $this->assertSame([], debugbar()->getStoreCell(Debugger::HOOKS)); + $this->assertSame([], debugbar()->getStoreCell(Debugger::EVENTS)); } public function testWebAppAdapterSkipsDebuggerEventLogWhenDebugDisabled(): void @@ -139,7 +139,7 @@ public function testWebAppAdapterSkipsDebuggerEventLogWhenDebugDisabled(): void $this->invokePrivateMethod($this->webAppAdapter, 'logDebugInfo'); - $this->assertSame([], debugbar()->getStoreCell(Debugger::HOOKS)); + $this->assertSame([], debugbar()->getStoreCell(Debugger::EVENTS)); } public function testWebAppAdapterBootFiresAppHelperListenerAtModulesBeforeEvent(): void diff --git a/tests/Unit/App/Helpers/DirHelperTest.php b/tests/Unit/App/Helpers/DirHelperTest.php index 24d818bb..c647b553 100644 --- a/tests/Unit/App/Helpers/DirHelperTest.php +++ b/tests/Unit/App/Helpers/DirHelperTest.php @@ -52,10 +52,4 @@ public function testAssetsDir(): void $expected = dirname(__DIR__, 3) . DS . '_root' . DS . 'public' . DS . 'assets'; $this->assertEquals($expected, assets_dir()); } - - public function testHooksDir(): void - { - $expected = dirname(__DIR__, 3) . DS . '_root' . DS . 'hooks'; - $this->assertEquals($expected, hooks_dir()); - } } diff --git a/tests/Unit/Debugger/DebuggerTest.php b/tests/Unit/Debugger/DebuggerTest.php index b73bcffe..4e963ba0 100644 --- a/tests/Unit/Debugger/DebuggerTest.php +++ b/tests/Unit/Debugger/DebuggerTest.php @@ -59,7 +59,7 @@ public function testDebuggerInitStore(): void $this->assertTrue($this->debuggerStore->has(Debugger::MESSAGES)); $this->assertTrue($this->debuggerStore->has(Debugger::QUERIES)); $this->assertTrue($this->debuggerStore->has(Debugger::ROUTES)); - $this->assertTrue($this->debuggerStore->has(Debugger::HOOKS)); + $this->assertTrue($this->debuggerStore->has(Debugger::EVENTS)); $this->assertTrue($this->debuggerStore->has(Debugger::MAILS)); } diff --git a/tests/_root/shared/config/hooks.php b/tests/_root/shared/config/hooks.php deleted file mode 100644 index 293a6551..00000000 --- a/tests/_root/shared/config/hooks.php +++ /dev/null @@ -1,5 +0,0 @@ -