From 02126bbfd2c78c6ac82bf88fc6b2e660c3de40f4 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 28 Aug 2025 18:12:49 -0500 Subject: [PATCH 01/49] refactor: reorder use statements in controller.stub for consistency --- src/stubs/controller.stub | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/stubs/controller.stub b/src/stubs/controller.stub index fca5395a..2d455916 100644 --- a/src/stubs/controller.stub +++ b/src/stubs/controller.stub @@ -4,10 +4,10 @@ declare(strict_types=1); namespace {namespace}; +use Phenix\Http\Constants\HttpStatus; +use Phenix\Http\Controller; use Phenix\Http\Request; use Phenix\Http\Response; -use Amp\Http\Status; -use Phenix\Http\Controller; class {name} extends Controller { From c577567eed786ba88cc96ca3176ad2c8a391a8b0 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 28 Aug 2025 18:13:17 -0500 Subject: [PATCH 02/49] feat: include output file path in success message --- src/Console/Commands/MakeModel.php | 4 ++- src/Console/Maker.php | 4 ++- .../Console/MakeCollectionCommandTest.php | 10 +++---- .../Console/MakeControllerCommandTest.php | 8 ++--- .../Console/MakeMiddlewareCommandTest.php | 6 ++-- tests/Unit/Console/MakeModelCommandTest.php | 29 ++++++++++--------- tests/Unit/Console/MakeQueryCommandTest.php | 6 ++-- tests/Unit/Console/MakeRequestCommandTest.php | 6 ++-- .../MakeServiceProviderCommandTest.php | 2 +- tests/Unit/Console/MakeTestCommandTest.php | 8 ++--- .../Console/MakeMigrationCommandTest.php | 3 +- .../Console/MakeSeederCommandTest.php | 2 +- tests/Unit/Queue/Console/TableCommandTest.php | 2 +- .../Tasks/Console/MakeTaskCommandTest.php | 8 ++--- 14 files changed, 52 insertions(+), 46 deletions(-) diff --git a/src/Console/Commands/MakeModel.php b/src/Console/Commands/MakeModel.php index 069377b6..3ff88f0d 100644 --- a/src/Console/Commands/MakeModel.php +++ b/src/Console/Commands/MakeModel.php @@ -102,7 +102,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int File::put($filePath, $stub); - $output->writeln(["{$this->commonName()} successfully generated!", self::EMPTY_LINE]); + $outputPath = str_replace(base_path(), '', $filePath); + + $output->writeln(["{$this->commonName()} [{$outputPath}] successfully generated!", self::EMPTY_LINE]); return parent::SUCCESS; } diff --git a/src/Console/Maker.php b/src/Console/Maker.php index 14a90479..b9d8b03e 100644 --- a/src/Console/Maker.php +++ b/src/Console/Maker.php @@ -48,7 +48,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int File::put($filePath, $stub); - $output->writeln(["{$this->commonName()} successfully generated!", self::EMPTY_LINE]); + $outputPath = str_replace(base_path(), '', $filePath); + + $output->writeln(["{$this->commonName()} [{$outputPath}] successfully generated!", self::EMPTY_LINE]); return Command::SUCCESS; } diff --git a/tests/Unit/Console/MakeCollectionCommandTest.php b/tests/Unit/Console/MakeCollectionCommandTest.php index c3dfdf5c..358a0ce9 100644 --- a/tests/Unit/Console/MakeCollectionCommandTest.php +++ b/tests/Unit/Console/MakeCollectionCommandTest.php @@ -22,12 +22,12 @@ /** @var CommandTester $command */ $command = $this->phenix('make:collection', [ - 'name' => 'User', + 'name' => 'UserCollection', ]); $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Collection successfully generated!'); + expect($command->getDisplay())->toContain('Collection [app/Collections/UserCollection.php] successfully generated!'); }); it('does not create the collection because it already exists', function () { @@ -72,13 +72,13 @@ /** @var CommandTester $command */ $command = $this->phenix('make:collection', [ - 'name' => 'User', + 'name' => 'UserCollection', '--force' => true, ]); $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Collection successfully generated!'); + expect($command->getDisplay())->toContain('Collection [app/Collections/UserCollection.php] successfully generated!'); expect('new content')->toBe(file_get_contents($tempPath)); }); @@ -101,5 +101,5 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Collection successfully generated!'); + expect($command->getDisplay())->toContain('Collection [app/Collections/Admin/User.php] successfully generated!'); }); diff --git a/tests/Unit/Console/MakeControllerCommandTest.php b/tests/Unit/Console/MakeControllerCommandTest.php index ed86ae8c..e2ed621f 100644 --- a/tests/Unit/Console/MakeControllerCommandTest.php +++ b/tests/Unit/Console/MakeControllerCommandTest.php @@ -24,7 +24,7 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Controller successfully generated!'); + expect($command->getDisplay())->toContain('Controller [app/Http/Controllers/TestController.php] successfully generated!'); }); it('does not create the controller because it already exists', function () { @@ -75,7 +75,7 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Controller successfully generated!'); + expect($command->getDisplay())->toContain('Controller [app/Http/Controllers/TestController.php] successfully generated!'); expect('new content')->toBe(file_get_contents($tempPath)); }); @@ -98,7 +98,7 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Controller successfully generated!'); + expect($command->getDisplay())->toContain('Controller [app/Http/Controllers/Admin/UserController.php] successfully generated!'); }); it('creates controller successfully with api option', function () { @@ -124,6 +124,6 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Controller successfully generated!'); + expect($command->getDisplay())->toContain('Controller [app/Http/Controllers/TestController.php] successfully generated!'); expect(file_get_contents($tempPath))->toContain('Hello, world!'); }); diff --git a/tests/Unit/Console/MakeMiddlewareCommandTest.php b/tests/Unit/Console/MakeMiddlewareCommandTest.php index bed1025d..8e9345a8 100644 --- a/tests/Unit/Console/MakeMiddlewareCommandTest.php +++ b/tests/Unit/Console/MakeMiddlewareCommandTest.php @@ -28,7 +28,7 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Middleware successfully generated!'); + expect($command->getDisplay())->toContain('Middleware [app/Http/Middleware/AwesomeMiddleware.php] successfully generated!'); }); it('does not create the middleware because it already exists', function () { @@ -79,7 +79,7 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Middleware successfully generated!'); + expect($command->getDisplay())->toContain('Middleware [app/Http/Middleware/TestMiddleware.php] successfully generated!'); expect('new content')->toBe(file_get_contents($tempPath)); }); @@ -106,5 +106,5 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Middleware successfully generated!'); + expect($command->getDisplay())->toContain('Middleware [app/Http/Middleware/Admin/TestMiddleware.php] successfully generated!'); }); diff --git a/tests/Unit/Console/MakeModelCommandTest.php b/tests/Unit/Console/MakeModelCommandTest.php index 26981e4b..1bde3f09 100644 --- a/tests/Unit/Console/MakeModelCommandTest.php +++ b/tests/Unit/Console/MakeModelCommandTest.php @@ -27,7 +27,7 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Model successfully generated!'); + expect($command->getDisplay())->toContain('Model [app/Models/User.php] successfully generated!'); }); it('does not create the model because it already exists', function () { @@ -78,7 +78,7 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Model successfully generated!'); + expect($command->getDisplay())->toContain('Model [app/Models/User.php] successfully generated!'); expect('new content')->toBe(file_get_contents($tempPath)); }); @@ -101,7 +101,7 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Model successfully generated!'); + expect($command->getDisplay())->toContain('Model [app/Models/Admin/User.php] successfully generated!'); }); it('creates model with custom collection', function () { @@ -139,8 +139,8 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Model successfully generated!'); - expect($command->getDisplay())->toContain('Collection successfully generated!'); + expect($command->getDisplay())->toContain('Model [app/Models/User.php] successfully generated!'); + expect($command->getDisplay())->toContain('Collection [app/Collections/UserCollection.php] successfully generated!'); }); it('creates model with custom query builder', function () { @@ -178,8 +178,8 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Model successfully generated!'); - expect($command->getDisplay())->toContain('Query successfully generated!'); + expect($command->getDisplay())->toContain('Model [app/Models/User.php] successfully generated!'); + expect($command->getDisplay())->toContain('Query [app/Queries/UserQuery.php] successfully generated!'); }); it('creates model with all', function () { @@ -223,9 +223,9 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Model successfully generated!'); - expect($command->getDisplay())->toContain('Query successfully generated!'); - expect($command->getDisplay())->toContain('Collection successfully generated!'); + expect($command->getDisplay())->toContain('Model [app/Models/User.php] successfully generated!'); + expect($command->getDisplay())->toContain('Query [app/Queries/UserQuery.php] successfully generated!'); + expect($command->getDisplay())->toContain('Collection [app/Collections/UserCollection.php] successfully generated!'); }); it('creates model with migration', function () { @@ -261,8 +261,9 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Model successfully generated!'); - expect($command->getDisplay())->toContain('Migration successfully generated!'); + expect($command->getDisplay())->toContain('Model [app/Models/User.php] successfully generated!'); + expect($command->getDisplay())->toContain('Migration [database/migrations/'); + expect($command->getDisplay())->toContain('_create_users_table.php] successfully generated!'); }); it('creates model with controller', function () { @@ -298,6 +299,6 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Model successfully generated!'); - expect($command->getDisplay())->toContain('Controller successfully generated!'); + expect($command->getDisplay())->toContain('Model [app/Models/User.php] successfully generated!'); + expect($command->getDisplay())->toContain('Controller [app/Http/Controllers/UserController.php] successfully generated!'); }); diff --git a/tests/Unit/Console/MakeQueryCommandTest.php b/tests/Unit/Console/MakeQueryCommandTest.php index 16b4399c..4ca9ba42 100644 --- a/tests/Unit/Console/MakeQueryCommandTest.php +++ b/tests/Unit/Console/MakeQueryCommandTest.php @@ -27,7 +27,7 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Query successfully generated!'); + expect($command->getDisplay())->toContain('Query [app/Queries/UserQuery.php] successfully generated!'); }); it('does not create the query because it already exists', function () { @@ -78,7 +78,7 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Query successfully generated!'); + expect($command->getDisplay())->toContain('Query [app/Queries/UserQuery.php] successfully generated!'); expect('new content')->toBe(file_get_contents($tempPath)); }); @@ -101,5 +101,5 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Query successfully generated!'); + expect($command->getDisplay())->toContain('Query [app/Queries/Domain/UserQuery.php] successfully generated!'); }); diff --git a/tests/Unit/Console/MakeRequestCommandTest.php b/tests/Unit/Console/MakeRequestCommandTest.php index 9e41f65a..27faaeab 100644 --- a/tests/Unit/Console/MakeRequestCommandTest.php +++ b/tests/Unit/Console/MakeRequestCommandTest.php @@ -28,7 +28,7 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Request successfully generated!'); + expect($command->getDisplay())->toContain('Request [app/Http/Requests/StoreUserRequest.php] successfully generated!'); }); it('does not create the form request because it already exists', function () { @@ -79,7 +79,7 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Request successfully generated!'); + expect($command->getDisplay())->toContain('Request [app/Http/Requests/StoreUserRequest.php] successfully generated!'); expect('new content')->toBe(file_get_contents($tempPath)); }); @@ -106,5 +106,5 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Request successfully generated!'); + expect($command->getDisplay())->toContain('Request [app/Http/Requests/Admin/StoreUserRequest.php] successfully generated!'); }); diff --git a/tests/Unit/Console/MakeServiceProviderCommandTest.php b/tests/Unit/Console/MakeServiceProviderCommandTest.php index d21cf691..e2583a27 100644 --- a/tests/Unit/Console/MakeServiceProviderCommandTest.php +++ b/tests/Unit/Console/MakeServiceProviderCommandTest.php @@ -28,5 +28,5 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Service provider successfully generated!'); + expect($command->getDisplay())->toContain('Service provider [app/Providers/SlackServiceProvider.php] successfully generated!'); }); diff --git a/tests/Unit/Console/MakeTestCommandTest.php b/tests/Unit/Console/MakeTestCommandTest.php index b79fcdef..f218e0af 100644 --- a/tests/Unit/Console/MakeTestCommandTest.php +++ b/tests/Unit/Console/MakeTestCommandTest.php @@ -28,7 +28,7 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Test successfully generated!'); + expect($command->getDisplay())->toContain('Test [tests/Feature/ExampleTest.php] successfully generated!'); }); it('does not create the test because it already exists', function () { @@ -79,7 +79,7 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Test successfully generated!'); + expect($command->getDisplay())->toContain('Test [tests/Feature/ExampleTest.php] successfully generated!'); expect('new content')->toBe(file_get_contents($tempPath)); }); @@ -102,7 +102,7 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Test successfully generated!'); + expect($command->getDisplay())->toContain('Test [tests/Feature/Admin/ExampleTest.php] successfully generated!'); }); it('creates test successfully with unit option', function () { @@ -129,5 +129,5 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Test successfully generated!'); + expect($command->getDisplay())->toContain('Test [tests/Unit/ExampleTest.php] successfully generated!'); }); diff --git a/tests/Unit/Database/Console/MakeMigrationCommandTest.php b/tests/Unit/Database/Console/MakeMigrationCommandTest.php index 9907938b..2eb89fd4 100644 --- a/tests/Unit/Database/Console/MakeMigrationCommandTest.php +++ b/tests/Unit/Database/Console/MakeMigrationCommandTest.php @@ -24,5 +24,6 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Migration successfully generated!'); + expect($command->getDisplay())->toContain('Migration [database/migrations/'); + expect($command->getDisplay())->toContain('_create_products_table.php] successfully generated!'); }); diff --git a/tests/Unit/Database/Console/MakeSeederCommandTest.php b/tests/Unit/Database/Console/MakeSeederCommandTest.php index 4784fedb..a4152f5e 100644 --- a/tests/Unit/Database/Console/MakeSeederCommandTest.php +++ b/tests/Unit/Database/Console/MakeSeederCommandTest.php @@ -24,5 +24,5 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Seeder successfully generated!'); + expect($command->getDisplay())->toContain('Seeder [database/seeders/UsersSeeder.php] successfully generated!'); }); diff --git a/tests/Unit/Queue/Console/TableCommandTest.php b/tests/Unit/Queue/Console/TableCommandTest.php index 89b679b1..f05f15cf 100644 --- a/tests/Unit/Queue/Console/TableCommandTest.php +++ b/tests/Unit/Queue/Console/TableCommandTest.php @@ -27,5 +27,5 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Queue table successfully generated!'); + expect($command->getDisplay())->toContain('Queue table [database/migrations/20250101205638_create_tasks_table.php] successfully generated!'); }); diff --git a/tests/Unit/Tasks/Console/MakeTaskCommandTest.php b/tests/Unit/Tasks/Console/MakeTaskCommandTest.php index 23778d18..6fa74ded 100644 --- a/tests/Unit/Tasks/Console/MakeTaskCommandTest.php +++ b/tests/Unit/Tasks/Console/MakeTaskCommandTest.php @@ -28,7 +28,7 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Task successfully generated!'); + expect($command->getDisplay())->toContain('Task [app/Tasks/AwesomeTask.php] successfully generated!'); }); it('creates queuable task successfully', function () { @@ -55,7 +55,7 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Task successfully generated!'); + expect($command->getDisplay())->toContain('Task [app/Tasks/AwesomeTask.php] successfully generated!'); }); it('does not create the task because it already exists', function () { @@ -106,7 +106,7 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Task successfully generated!'); + expect($command->getDisplay())->toContain('Task [app/Tasks/TestTask.php] successfully generated!'); expect('new content')->toBe(file_get_contents($tempPath)); }); @@ -133,5 +133,5 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Task successfully generated!'); + expect($command->getDisplay())->toContain('Task [app/Tasks/Admin/TestTask.php] successfully generated!'); }); From 9218b5e3e8e46744296d9649446dd947e09bf324 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 28 Aug 2025 18:13:25 -0500 Subject: [PATCH 03/49] fix: correct output directory path for seeders --- src/Database/Console/MakeSeeder.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/Console/MakeSeeder.php b/src/Database/Console/MakeSeeder.php index 89b603a2..990e38f2 100644 --- a/src/Database/Console/MakeSeeder.php +++ b/src/Database/Console/MakeSeeder.php @@ -36,7 +36,7 @@ protected function configure(): void protected function outputDirectory(): string { - return 'database' . DIRECTORY_SEPARATOR . 'seeds'; + return 'database' . DIRECTORY_SEPARATOR . 'seeders'; } protected function stub(): string From 243dce39cb53111a0438409b0182cab14fa64aed Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 11 Sep 2025 12:07:57 -0500 Subject: [PATCH 04/49] feat: event system --- src/Events/AbstractEvent.php | 50 ++++ src/Events/AbstractListener.php | 25 ++ src/Events/Console/MakeEventCommand.php | 36 +++ src/Events/Console/MakeListenerCommand.php | 36 +++ src/Events/Contracts/Event.php | 18 ++ src/Events/Contracts/EventEmitter.php | 27 ++ src/Events/Contracts/EventListener.php | 14 + src/Events/Event.php | 20 ++ src/Events/EventEmitter.php | 316 +++++++++++++++++++++ src/Events/EventListener.php | 60 ++++ src/Events/EventServiceProvider.php | 27 ++ src/Events/Exceptions/EventException.php | 12 + src/Facades/Event.php | 36 +++ src/stubs/event.stub | 16 ++ src/stubs/listener.stub | 25 ++ tests/Unit/Events/EventEmitterTest.php | 219 ++++++++++++++ 16 files changed, 937 insertions(+) create mode 100644 src/Events/AbstractEvent.php create mode 100644 src/Events/AbstractListener.php create mode 100644 src/Events/Console/MakeEventCommand.php create mode 100644 src/Events/Console/MakeListenerCommand.php create mode 100644 src/Events/Contracts/Event.php create mode 100644 src/Events/Contracts/EventEmitter.php create mode 100644 src/Events/Contracts/EventListener.php create mode 100644 src/Events/Event.php create mode 100644 src/Events/EventEmitter.php create mode 100644 src/Events/EventListener.php create mode 100644 src/Events/EventServiceProvider.php create mode 100644 src/Events/Exceptions/EventException.php create mode 100644 src/Facades/Event.php create mode 100644 src/stubs/event.stub create mode 100644 src/stubs/listener.stub create mode 100644 tests/Unit/Events/EventEmitterTest.php diff --git a/src/Events/AbstractEvent.php b/src/Events/AbstractEvent.php new file mode 100644 index 00000000..f8c289d6 --- /dev/null +++ b/src/Events/AbstractEvent.php @@ -0,0 +1,50 @@ +timestamp = microtime(true); + } + + public function getName(): string + { + return static::class; + } + + public function getPayload(): mixed + { + return $this->payload; + } + + public function isPropagationStopped(): bool + { + return $this->propagationStopped; + } + + public function stopPropagation(): void + { + $this->propagationStopped = true; + } + + public function getTimestamp(): float + { + return $this->timestamp; + } + + public function __toString(): string + { + return $this->getName(); + } +} diff --git a/src/Events/AbstractListener.php b/src/Events/AbstractListener.php new file mode 100644 index 00000000..27cd9171 --- /dev/null +++ b/src/Events/AbstractListener.php @@ -0,0 +1,25 @@ +priority; + } + + public function shouldHandle(Event $event): bool + { + return true; + } + + abstract public function handle(Event $event): mixed; +} diff --git a/src/Events/Console/MakeEventCommand.php b/src/Events/Console/MakeEventCommand.php new file mode 100644 index 00000000..0b414390 --- /dev/null +++ b/src/Events/Console/MakeEventCommand.php @@ -0,0 +1,36 @@ +addArgument('name', InputArgument::REQUIRED, 'The name of the event'); + } + + protected function outputDirectory(): string + { + return 'app' . DIRECTORY_SEPARATOR . 'Events'; + } + + protected function commonName(): string + { + return 'Event'; + } + + protected function stub(): string + { + return 'event.stub'; + } +} diff --git a/src/Events/Console/MakeListenerCommand.php b/src/Events/Console/MakeListenerCommand.php new file mode 100644 index 00000000..8efdee9c --- /dev/null +++ b/src/Events/Console/MakeListenerCommand.php @@ -0,0 +1,36 @@ +addArgument('name', InputArgument::REQUIRED, 'The name of the listener'); + } + + protected function outputDirectory(): string + { + return 'app' . DIRECTORY_SEPARATOR . 'Listeners'; + } + + protected function commonName(): string + { + return 'Event Listener'; + } + + protected function stub(): string + { + return 'listener.stub'; + } +} diff --git a/src/Events/Contracts/Event.php b/src/Events/Contracts/Event.php new file mode 100644 index 00000000..32e405f4 --- /dev/null +++ b/src/Events/Contracts/Event.php @@ -0,0 +1,18 @@ +timestamp = microtime(true); + } + + public function getName(): string + { + return $this->name; + } +} diff --git a/src/Events/EventEmitter.php b/src/Events/EventEmitter.php new file mode 100644 index 00000000..8ea91a8a --- /dev/null +++ b/src/Events/EventEmitter.php @@ -0,0 +1,316 @@ +> + */ + protected array $listeners = []; + + /** + * @var array + */ + protected array $listenerCounts = []; + + /** + * Maximum number of listeners per event. + */ + protected int $maxListeners = 10; + + /** + * Whether to emit warnings for too many listeners. + */ + protected bool $emitWarnings = true; + + public function on(string $event, Closure|EventListenerContract|string $listener, int $priority = 0): void + { + $eventListener = $this->createEventListener($listener, $priority); + + $this->listeners[$event][] = $eventListener; + $this->listenerCounts[$event] = ($this->listenerCounts[$event] ?? 0) + 1; + + $this->sortListenersByPriority($event); + $this->checkMaxListeners($event); + } + + public function once(string $event, Closure|EventListenerContract|string $listener, int $priority = 0): void + { + $eventListener = $this->createEventListener($listener, $priority); + $eventListener->setOnce(true); + + $this->listeners[$event][] = $eventListener; + $this->listenerCounts[$event] = ($this->listenerCounts[$event] ?? 0) + 1; + + $this->sortListenersByPriority($event); + $this->checkMaxListeners($event); + } + + public function off(string $event, Closure|EventListenerContract|string|null $listener = null): void + { + if (! isset($this->listeners[$event])) { + return; + } + + if ($listener === null) { + unset($this->listeners[$event]); + $this->listenerCounts[$event] = 0; + + return; + } + + $this->listeners[$event] = array_filter( + $this->listeners[$event], + fn (EventListener $eventListener): bool => ! $this->isSameListener($eventListener, $listener) + ); + + $this->listenerCounts[$event] = count($this->listeners[$event]); + + if ($this->listenerCounts[$event] === 0) { + unset($this->listeners[$event]); + } + } + + public function emit(string|EventContract $event, mixed $payload = null): array + { + $eventObject = $this->createEvent($event, $payload); + $results = []; + + $listeners = $this->getListeners($eventObject->getName()); + + foreach ($listeners as $listener) { + if ($eventObject->isPropagationStopped()) { + break; + } + + if (! $listener->shouldHandle($eventObject)) { + continue; + } + + try { + $result = $listener->handle($eventObject); + $results[] = $result; + + // Remove one-time listeners after execution + if ($listener->isOnce()) { + $this->removeListener($eventObject->getName(), $listener); + } + } catch (Throwable $e) { + Log::error('Event listener error', [ + 'event' => $eventObject->getName(), + 'error' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ]); + + if ($this->emitWarnings) { + throw new EventException( + "Error in event listener for '{$eventObject->getName()}': {$e->getMessage()}", + 0, + $e + ); + } + } + } + + return $results; + } + + public function emitAsync(string|EventContract $event, mixed $payload = null): Future + { + return async(function () use ($event, $payload): array { + $eventObject = $this->createEvent($event, $payload); + $listeners = $this->getListeners($eventObject->getName()); + $futures = []; + + foreach ($listeners as $listener) { + if ($eventObject->isPropagationStopped()) { + break; + } + + if (! $listener->shouldHandle($eventObject)) { + continue; + } + + $futures[] = $this->handleListenerAsync($listener, $eventObject); + } + + $results = []; + foreach ($futures as $future) { + try { + $results[] = $future->await(); + } catch (Throwable $e) { + Log::error('Future await error', [ + 'event' => $eventObject->getName(), + 'error' => $e->getMessage(), + ]); + $results[] = null; + } + } + + return $results; + }); + } + + protected function handleListenerAsync(EventListener $listener, EventContract $eventObject): Future + { + return async(function () use ($listener, $eventObject): mixed { + try { + $result = $listener->handle($eventObject); + + // Remove one-time listeners after execution + if ($listener->isOnce()) { + $this->removeListener($eventObject->getName(), $listener); + } + + return $result; + } catch (Throwable $e) { + Log::error('Async event listener error', [ + 'event' => $eventObject->getName(), + 'error' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ]); + + if ($this->emitWarnings) { + throw new EventException( + "Error in async event listener for '{$eventObject->getName()}': {$e->getMessage()}", + 0, + $e + ); + } + + return null; + } + }); + } + + /** + * @return array + */ + public function getListeners(string $event): array + { + return $this->listeners[$event] ?? []; + } + + public function hasListeners(string $event): bool + { + return isset($this->listeners[$event]) && count($this->listeners[$event]) > 0; + } + + public function removeAllListeners(): void + { + $this->listeners = []; + $this->listenerCounts = []; + } + + public function setMaxListeners(int $maxListeners): void + { + $this->maxListeners = $maxListeners; + } + + public function getMaxListeners(): int + { + return $this->maxListeners; + } + + public function setEmitWarnings(bool $emitWarnings): void + { + $this->emitWarnings = $emitWarnings; + } + + public function getListenerCount(string $event): int + { + return $this->listenerCounts[$event] ?? 0; + } + + public function getEventNames(): array + { + return array_keys($this->listeners); + } + + protected function createEventListener(Closure|EventListenerContract|string $listener, int $priority): EventListener + { + if ($listener instanceof EventListenerContract) { + return $listener; + } + + return new EventListener($listener, $priority); + } + + protected function createEvent(string|EventContract $event, mixed $payload): EventContract + { + if ($event instanceof EventContract) { + return $event; + } + + return new Event($event, $payload); + } + + protected function sortListenersByPriority(string $event): void + { + if (! isset($this->listeners[$event])) { + return; + } + + usort($this->listeners[$event], function (EventListener $a, EventListener $b): int { + return $b->getPriority() <=> $a->getPriority(); + }); + } + + protected function checkMaxListeners(string $event): void + { + if (! $this->emitWarnings) { + return; + } + + $count = $this->getListenerCount($event); + + if ($count > $this->maxListeners) { + Log::warning("Possible memory leak detected. Event '{$event}' has {$count} listeners. Maximum is {$this->maxListeners}."); + } + } + + protected function isSameListener(EventListener $eventListener, Closure|EventListenerContract|string $listener): bool + { + $handler = $eventListener->getHandler(); + + if ($listener instanceof EventListenerContract) { + return $eventListener === $listener; + } + + return $handler === $listener; + } + + protected function removeListener(string $event, EventListener $listener): void + { + if (! isset($this->listeners[$event])) { + return; + } + + $this->listeners[$event] = array_filter( + $this->listeners[$event], + fn (EventListener $eventListener) => $eventListener !== $listener + ); + + $this->listenerCounts[$event] = count($this->listeners[$event]); + + if ($this->listenerCounts[$event] === 0) { + unset($this->listeners[$event]); + } + } +} diff --git a/src/Events/EventListener.php b/src/Events/EventListener.php new file mode 100644 index 00000000..3e154304 --- /dev/null +++ b/src/Events/EventListener.php @@ -0,0 +1,60 @@ +priority = $priority; + } + + public function handle(Event $event): mixed + { + if ($this->handler instanceof Closure) { + return ($this->handler)($event); + } + + // Handle string-based class listeners + if (is_string($this->handler)) { + $listener = App::make($this->handler); + + if (method_exists($listener, 'handle')) { + return $listener->handle($event); + } + + if (is_callable($listener)) { + return $listener($event); + } + } + + return null; + } + + public function isOnce(): bool + { + return $this->once; + } + + public function setOnce(bool $once = true): self + { + $this->once = $once; + + return $this; + } + + public function getHandler(): Closure|string + { + return $this->handler; + } +} diff --git a/src/Events/EventServiceProvider.php b/src/Events/EventServiceProvider.php new file mode 100644 index 00000000..76365c3e --- /dev/null +++ b/src/Events/EventServiceProvider.php @@ -0,0 +1,27 @@ +provides); + } + + public function register(): void + { + $this->getContainer()->addShared(EventEmitter::class, EventEmitter::class); + $this->getContainer()->add(EventEmitterContract::class, EventEmitter::class); + } +} diff --git a/src/Events/Exceptions/EventException.php b/src/Events/Exceptions/EventException.php new file mode 100644 index 00000000..47ef792c --- /dev/null +++ b/src/Events/Exceptions/EventException.php @@ -0,0 +1,12 @@ +on('test.event', function (EventContract $event) use (&$called): void { + $called = true; + expect($event->getName())->toBe('test.event'); + expect($event->getPayload())->toBe('test data'); + }); + + $emitter->emit('test.event', 'test data'); + + expect($called)->toBeTrue(); +}); + +it('can register multiple listeners for same event', function (): void { + $emitter = new EventEmitter(); + $count = 0; + + $emitter->on('multi.event', function () use (&$count): void { + $count++; + }); + + $emitter->on('multi.event', function () use (&$count): void { + $count++; + }); + + $emitter->emit('multi.event'); + + expect($count)->toBe(2); +}); + +it('respects listener priorities', function (): void { + $emitter = new EventEmitter(); + $order = []; + + $emitter->on('priority.test', function () use (&$order): void { + $order[] = 'low'; + }, 1); + + $emitter->on('priority.test', function () use (&$order): void { + $order[] = 'high'; + }, 10); + + $emitter->on('priority.test', function () use (&$order): void { + $order[] = 'medium'; + }, 5); + + $emitter->emit('priority.test'); + + expect($order)->toBe(['high', 'medium', 'low']); +}); + +it('can register one-time listeners', function (): void { + $emitter = new EventEmitter(); + $count = 0; + + $emitter->once('once.event', function () use (&$count): void { + $count++; + }); + + $emitter->emit('once.event'); + $emitter->emit('once.event'); + $emitter->emit('once.event'); + + expect($count)->toBe(1); +}); + +it('can remove listeners', function (): void { + $emitter = new EventEmitter(); + $called = false; + + $listener = function () use (&$called): void { + $called = true; + }; + + $emitter->on('removable.event', $listener); + $emitter->off('removable.event', $listener); + $emitter->emit('removable.event'); + + expect($called)->toBeFalse(); +}); + +it('can remove all listeners for an event', function (): void { + $emitter = new EventEmitter(); + $count = 0; + + $emitter->on('clear.event', function () use (&$count): void { + $count++; + }); + + $emitter->on('clear.event', function () use (&$count): void { + $count++; + }); + + $emitter->off('clear.event'); + $emitter->emit('clear.event'); + + expect($count)->toBe(0); +}); + +it('can stop event propagation', function (): void { + $emitter = new EventEmitter(); + $count = 0; + + $emitter->on('stop.event', function (EventContract $event) use (&$count): void { + $count++; + $event->stopPropagation(); + }); + + $emitter->on('stop.event', function (EventContract $event) use (&$count): void { + $count++; + }); + + $emitter->emit('stop.event'); + + expect($count)->toBe(1); +}); + +it('returns results from listeners', function (): void { + $emitter = new EventEmitter(); + + $emitter->on('result.event', fn (): string => 'first result'); + + $emitter->on('result.event', fn (): string => 'second result'); + + $results = $emitter->emit('result.event'); + + expect($results)->toBe(['first result', 'second result']); +}); + +it('can handle Event objects', function (): void { + $emitter = new EventEmitter(); + $called = false; + + $emitter->on('custom.event', function ($event) use (&$called): void { + $called = true; + expect($event->getName())->toBe('custom.event'); + expect($event->getPayload())->toBe('custom data'); + }); + + $event = new Event('custom.event', 'custom data'); + $emitter->emit($event); + + expect($called)->toBeTrue(); +}); + +it('can check if event has listeners', function (): void { + $emitter = new EventEmitter(); + + expect($emitter->hasListeners('nonexistent.event'))->toBeFalse(); + + $emitter->on('existing.event', function (): void { + // Do something + }); + + expect($emitter->hasListeners('existing.event'))->toBeTrue(); +}); + +it('can get listener count', function (): void { + $emitter = new EventEmitter(); + + expect($emitter->getListenerCount('count.event'))->toBe(0); + + $emitter->on('count.event', function (): void { + // Do something + }); + $emitter->on('count.event', function (): void { + // Do something + }); + + expect($emitter->getListenerCount('count.event'))->toBe(2); +}); + +it('can get event names', function (): void { + $emitter = new EventEmitter(); + + $emitter->on('event.one', function (): void { + // Do something + }); + $emitter->on('event.two', function (): void { + // Do something + }); + + $eventNames = $emitter->getEventNames(); + + expect($eventNames)->toContain('event.one'); + expect($eventNames)->toContain('event.two'); +}); + +it('can set max listeners', function () { + $emitter = new EventEmitter(); + $emitter->setMaxListeners(2); + + expect($emitter->getMaxListeners())->toBe(2); +}); + +it('can clear all listeners', function (): void { + $emitter = new EventEmitter(); + + $emitter->on('event.one', function (): void { + // Do something + }); + $emitter->on('event.two', function (): void { + // Do something + }); + + $emitter->removeAllListeners(); + + expect($emitter->getEventNames())->toBeEmpty(); +}); From 12c8b6d7e4920170bf51b6e7196384496e47d632 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 11 Sep 2025 12:21:09 -0500 Subject: [PATCH 05/49] tests: make event command --- .../{MakeEventCommand.php => MakeEvent.php} | 5 +- src/Events/EventServiceProvider.php | 12 +- .../Events/Console/MakeEventCommandTest.php | 110 ++++++++++++++++++ tests/fixtures/application/config/app.php | 1 + 4 files changed, 125 insertions(+), 3 deletions(-) rename src/Events/Console/{MakeEventCommand.php => MakeEvent.php} (80%) create mode 100644 tests/Unit/Events/Console/MakeEventCommandTest.php diff --git a/src/Events/Console/MakeEventCommand.php b/src/Events/Console/MakeEvent.php similarity index 80% rename from src/Events/Console/MakeEventCommand.php rename to src/Events/Console/MakeEvent.php index 0b414390..a7bbf2d3 100644 --- a/src/Events/Console/MakeEventCommand.php +++ b/src/Events/Console/MakeEvent.php @@ -7,16 +7,19 @@ use Phenix\Console\Maker; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputOption; #[AsCommand( name: 'make:event', description: 'Create a new event class' )] -class MakeEventCommand extends Maker +class MakeEvent extends Maker { protected function configure(): void { $this->addArgument('name', InputArgument::REQUIRED, 'The name of the event'); + + $this->addOption('force', 'f', InputOption::VALUE_NONE, 'Force to create event'); } protected function outputDirectory(): string diff --git a/src/Events/EventServiceProvider.php b/src/Events/EventServiceProvider.php index 76365c3e..ea97813e 100644 --- a/src/Events/EventServiceProvider.php +++ b/src/Events/EventServiceProvider.php @@ -4,10 +4,11 @@ namespace Phenix\Events; -use League\Container\ServiceProvider\AbstractServiceProvider; +use Phenix\Events\Console\MakeEvent; use Phenix\Events\Contracts\EventEmitter as EventEmitterContract; +use Phenix\Providers\ServiceProvider; -class EventServiceProvider extends AbstractServiceProvider +class EventServiceProvider extends ServiceProvider { protected $provides = [ EventEmitter::class, @@ -24,4 +25,11 @@ public function register(): void $this->getContainer()->addShared(EventEmitter::class, EventEmitter::class); $this->getContainer()->add(EventEmitterContract::class, EventEmitter::class); } + + public function boot(): void + { + $this->commands([ + MakeEvent::class, + ]); + } } diff --git a/tests/Unit/Events/Console/MakeEventCommandTest.php b/tests/Unit/Events/Console/MakeEventCommandTest.php new file mode 100644 index 00000000..293954a4 --- /dev/null +++ b/tests/Unit/Events/Console/MakeEventCommandTest.php @@ -0,0 +1,110 @@ +expect( + exists: fn (string $path) => false, + get: fn (string $path) => '', + put: function (string $path) { + expect($path)->toBe(base_path('app/Events/AwesomeEvent.php')); + + return true; + }, + createDirectory: function (string $path): void { + // .. + } + ); + + $this->app->swap(File::class, $mock); + + /** @var \Symfony\Component\Console\Tester\CommandTester $command */ + $command = $this->phenix('make:event', [ + 'name' => 'AwesomeEvent', + ]); + + $command->assertCommandIsSuccessful(); + + expect($command->getDisplay())->toContain('Event [app/Events/AwesomeEvent.php] successfully generated!'); +}); + +it('does not create the event because it already exists', function () { + $mock = Mock::of(File::class)->expect( + exists: fn (string $path) => true, + ); + + $this->app->swap(File::class, $mock); + + $this->phenix('make:event', [ + 'name' => 'TestEvent', + ]); + + /** @var \Symfony\Component\Console\Tester\CommandTester $command */ + $command = $this->phenix('make:event', [ + 'name' => 'TestEvent', + ]); + + $command->assertCommandIsSuccessful(); + + expect($command->getDisplay())->toContain('Event already exists!'); +}); + +it('creates event successfully with force option', function () { + $tempDir = sys_get_temp_dir(); + $tempPath = $tempDir . DIRECTORY_SEPARATOR . 'TestEvent.php'; + + file_put_contents($tempPath, 'old content'); + + $this->assertEquals('old content', file_get_contents($tempPath)); + + $mock = Mock::of(File::class)->expect( + exists: fn (string $path) => false, + get: fn (string $path) => 'new content', + put: fn (string $path, string $content) => file_put_contents($tempPath, $content), + createDirectory: function (string $path): void { + // .. + } + ); + + $this->app->swap(File::class, $mock); + + /** @var \Symfony\Component\Console\Tester\CommandTester $command */ + $command = $this->phenix('make:event', [ + 'name' => 'TestEvent', + '--force' => true, + ]); + + $command->assertCommandIsSuccessful(); + + expect($command->getDisplay())->toContain('Event [app/Events/TestEvent.php] successfully generated!'); + expect('new content')->toBe(file_get_contents($tempPath)); +}); + +it('creates event successfully in nested namespace', function () { + $mock = Mock::of(File::class)->expect( + exists: fn (string $path) => false, + get: fn (string $path) => '', + put: function (string $path) { + expect($path)->toBe(base_path('app/Events/Admin/TestEvent.php')); + + return true; + }, + createDirectory: function (string $path): void { + // .. + } + ); + + $this->app->swap(File::class, $mock); + + /** @var \Symfony\Component\Console\Tester\CommandTester $command */ + $command = $this->phenix('make:event', [ + 'name' => 'Admin/TestEvent', + ]); + + $command->assertCommandIsSuccessful(); + + expect($command->getDisplay())->toContain('Event [app/Events/Admin/TestEvent.php] successfully generated!'); +}); diff --git a/tests/fixtures/application/config/app.php b/tests/fixtures/application/config/app.php index 6393975d..4778ad35 100644 --- a/tests/fixtures/application/config/app.php +++ b/tests/fixtures/application/config/app.php @@ -27,5 +27,6 @@ \Phenix\Mail\MailServiceProvider::class, \Phenix\Crypto\CryptoServiceProvider::class, \Phenix\Queue\QueueServiceProvider::class, + \Phenix\Events\EventServiceProvider::class, ], ]; From d741479992c10799a59d11ee6ab6a534c2bd2988 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 11 Sep 2025 12:24:42 -0500 Subject: [PATCH 06/49] tests: make listener command --- ...keListenerCommand.php => MakeListener.php} | 5 +- src/Events/EventServiceProvider.php | 2 + .../Console/MakeListenerCommandTest.php | 110 ++++++++++++++++++ 3 files changed, 116 insertions(+), 1 deletion(-) rename src/Events/Console/{MakeListenerCommand.php => MakeListener.php} (80%) create mode 100644 tests/Unit/Events/Console/MakeListenerCommandTest.php diff --git a/src/Events/Console/MakeListenerCommand.php b/src/Events/Console/MakeListener.php similarity index 80% rename from src/Events/Console/MakeListenerCommand.php rename to src/Events/Console/MakeListener.php index 8efdee9c..fbdbc8c2 100644 --- a/src/Events/Console/MakeListenerCommand.php +++ b/src/Events/Console/MakeListener.php @@ -7,16 +7,19 @@ use Phenix\Console\Maker; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputOption; #[AsCommand( name: 'make:listener', description: 'Create a new event listener class' )] -class MakeListenerCommand extends Maker +class MakeListener extends Maker { protected function configure(): void { $this->addArgument('name', InputArgument::REQUIRED, 'The name of the listener'); + + $this->addOption('force', 'f', InputOption::VALUE_NONE, 'Force to create listener'); } protected function outputDirectory(): string diff --git a/src/Events/EventServiceProvider.php b/src/Events/EventServiceProvider.php index ea97813e..07b72665 100644 --- a/src/Events/EventServiceProvider.php +++ b/src/Events/EventServiceProvider.php @@ -5,6 +5,7 @@ namespace Phenix\Events; use Phenix\Events\Console\MakeEvent; +use Phenix\Events\Console\MakeListener; use Phenix\Events\Contracts\EventEmitter as EventEmitterContract; use Phenix\Providers\ServiceProvider; @@ -30,6 +31,7 @@ public function boot(): void { $this->commands([ MakeEvent::class, + MakeListener::class, ]); } } diff --git a/tests/Unit/Events/Console/MakeListenerCommandTest.php b/tests/Unit/Events/Console/MakeListenerCommandTest.php new file mode 100644 index 00000000..9ac325f6 --- /dev/null +++ b/tests/Unit/Events/Console/MakeListenerCommandTest.php @@ -0,0 +1,110 @@ +expect( + exists: fn (string $path) => false, + get: fn (string $path) => '', + put: function (string $path) { + expect($path)->toBe(base_path('app/Listeners/AwesomeListener.php')); + + return true; + }, + createDirectory: function (string $path): void { + // .. + } + ); + + $this->app->swap(File::class, $mock); + + /** @var \Symfony\Component\Console\Tester\CommandTester $command */ + $command = $this->phenix('make:listener', [ + 'name' => 'AwesomeListener', + ]); + + $command->assertCommandIsSuccessful(); + + expect($command->getDisplay())->toContain('Listener [app/Listeners/AwesomeListener.php] successfully generated!'); +}); + +it('does not create the listener because it already exists', function () { + $mock = Mock::of(File::class)->expect( + exists: fn (string $path) => true, + ); + + $this->app->swap(File::class, $mock); + + $this->phenix('make:listener', [ + 'name' => 'TestListener', + ]); + + /** @var \Symfony\Component\Console\Tester\CommandTester $command */ + $command = $this->phenix('make:listener', [ + 'name' => 'TestListener', + ]); + + $command->assertCommandIsSuccessful(); + + expect($command->getDisplay())->toContain('Listener already exists!'); +}); + +it('creates listener successfully with force option', function () { + $tempDir = sys_get_temp_dir(); + $tempPath = $tempDir . DIRECTORY_SEPARATOR . 'TestListener.php'; + + file_put_contents($tempPath, 'old content'); + + $this->assertEquals('old content', file_get_contents($tempPath)); + + $mock = Mock::of(File::class)->expect( + exists: fn (string $path) => false, + get: fn (string $path) => 'new content', + put: fn (string $path, string $content) => file_put_contents($tempPath, $content), + createDirectory: function (string $path): void { + // .. + } + ); + + $this->app->swap(File::class, $mock); + + /** @var \Symfony\Component\Console\Tester\CommandTester $command */ + $command = $this->phenix('make:listener', [ + 'name' => 'TestListener', + '--force' => true, + ]); + + $command->assertCommandIsSuccessful(); + + expect($command->getDisplay())->toContain('Listener [app/Listeners/TestListener.php] successfully generated!'); + expect('new content')->toBe(file_get_contents($tempPath)); +}); + +it('creates listener successfully in nested namespace', function () { + $mock = Mock::of(File::class)->expect( + exists: fn (string $path) => false, + get: fn (string $path) => '', + put: function (string $path) { + expect($path)->toBe(base_path('app/Listeners/Admin/TestListener.php')); + + return true; + }, + createDirectory: function (string $path): void { + // .. + } + ); + + $this->app->swap(File::class, $mock); + + /** @var \Symfony\Component\Console\Tester\CommandTester $command */ + $command = $this->phenix('make:listener', [ + 'name' => 'Admin/TestListener', + ]); + + $command->assertCommandIsSuccessful(); + + expect($command->getDisplay())->toContain('Listener [app/Listeners/Admin/TestListener.php] successfully generated!'); +}); From 08a9b11df4c3321eed5f9e04d09942fa26fc04c7 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 12 Sep 2025 12:58:37 -0500 Subject: [PATCH 07/49] refactor: update EventListener and AbstractListener for consistency in interface implementation --- src/Events/AbstractListener.php | 22 +++++++++++++++++++++- src/Events/Contracts/EventListener.php | 8 ++++++++ src/Events/EventEmitter.php | 20 ++++++++++---------- src/Events/EventListener.php | 14 -------------- 4 files changed, 39 insertions(+), 25 deletions(-) diff --git a/src/Events/AbstractListener.php b/src/Events/AbstractListener.php index 27cd9171..3c394023 100644 --- a/src/Events/AbstractListener.php +++ b/src/Events/AbstractListener.php @@ -4,6 +4,7 @@ namespace Phenix\Events; +use Closure; use Phenix\Events\Contracts\Event; use Phenix\Events\Contracts\EventListener as EventListenerContract; @@ -11,6 +12,10 @@ abstract class AbstractListener implements EventListenerContract { protected int $priority = 0; + protected bool $once = false; + + abstract public function handle(Event $event): mixed; + public function getPriority(): int { return $this->priority; @@ -21,5 +26,20 @@ public function shouldHandle(Event $event): bool return true; } - abstract public function handle(Event $event): mixed; + public function isOnce(): bool + { + return $this->once; + } + + public function setOnce(bool $once = true): self + { + $this->once = $once; + + return $this; + } + + public function getHandler(): Closure|static|string + { + return $this; + } } diff --git a/src/Events/Contracts/EventListener.php b/src/Events/Contracts/EventListener.php index 3b0a8661..7ff80621 100644 --- a/src/Events/Contracts/EventListener.php +++ b/src/Events/Contracts/EventListener.php @@ -4,6 +4,8 @@ namespace Phenix\Events\Contracts; +use Closure; + interface EventListener { public function handle(Event $event): mixed; @@ -11,4 +13,10 @@ public function handle(Event $event): mixed; public function getPriority(): int; public function shouldHandle(Event $event): bool; + + public function isOnce(): bool; + + public function setOnce(bool $once = true): self; + + public function getHandler(): Closure|static|string; } diff --git a/src/Events/EventEmitter.php b/src/Events/EventEmitter.php index 8ea91a8a..c32f8810 100644 --- a/src/Events/EventEmitter.php +++ b/src/Events/EventEmitter.php @@ -18,7 +18,7 @@ class EventEmitter implements EventEmitterContract { /** - * @var array> + * @var array> */ protected array $listeners = []; @@ -75,7 +75,7 @@ public function off(string $event, Closure|EventListenerContract|string|null $li $this->listeners[$event] = array_filter( $this->listeners[$event], - fn (EventListener $eventListener): bool => ! $this->isSameListener($eventListener, $listener) + fn (EventListenerContract $eventListener): bool => ! $this->isSameListener($eventListener, $listener) ); $this->listenerCounts[$event] = count($this->listeners[$event]); @@ -166,7 +166,7 @@ public function emitAsync(string|EventContract $event, mixed $payload = null): F }); } - protected function handleListenerAsync(EventListener $listener, EventContract $eventObject): Future + protected function handleListenerAsync(EventListenerContract $listener, EventContract $eventObject): Future { return async(function () use ($listener, $eventObject): mixed { try { @@ -200,7 +200,7 @@ protected function handleListenerAsync(EventListener $listener, EventContract $e } /** - * @return array + * @return array */ public function getListeners(string $event): array { @@ -243,7 +243,7 @@ public function getEventNames(): array return array_keys($this->listeners); } - protected function createEventListener(Closure|EventListenerContract|string $listener, int $priority): EventListener + protected function createEventListener(Closure|EventListenerContract|string $listener, int $priority): EventListenerContract { if ($listener instanceof EventListenerContract) { return $listener; @@ -267,7 +267,7 @@ protected function sortListenersByPriority(string $event): void return; } - usort($this->listeners[$event], function (EventListener $a, EventListener $b): int { + usort($this->listeners[$event], function (EventListenerContract $a, EventListenerContract $b): int { return $b->getPriority() <=> $a->getPriority(); }); } @@ -285,18 +285,18 @@ protected function checkMaxListeners(string $event): void } } - protected function isSameListener(EventListener $eventListener, Closure|EventListenerContract|string $listener): bool + protected function isSameListener(EventListenerContract $eventListener, Closure|EventListenerContract|string $listener): bool { $handler = $eventListener->getHandler(); if ($listener instanceof EventListenerContract) { - return $eventListener === $listener; + return $eventListener::class === $listener::class; } return $handler === $listener; } - protected function removeListener(string $event, EventListener $listener): void + protected function removeListener(string $event, EventListenerContract $listener): void { if (! isset($this->listeners[$event])) { return; @@ -304,7 +304,7 @@ protected function removeListener(string $event, EventListener $listener): void $this->listeners[$event] = array_filter( $this->listeners[$event], - fn (EventListener $eventListener) => $eventListener !== $listener + fn (EventListenerContract $eventListener): bool => ! $this->isSameListener($eventListener, $listener) ); $this->listenerCounts[$event] = count($this->listeners[$event]); diff --git a/src/Events/EventListener.php b/src/Events/EventListener.php index 3e154304..754d3144 100644 --- a/src/Events/EventListener.php +++ b/src/Events/EventListener.php @@ -10,8 +10,6 @@ class EventListener extends AbstractListener { - protected bool $once = false; - public function __construct( protected Closure|string $handler, int $priority = 0 @@ -41,18 +39,6 @@ public function handle(Event $event): mixed return null; } - public function isOnce(): bool - { - return $this->once; - } - - public function setOnce(bool $once = true): self - { - $this->once = $once; - - return $this; - } - public function getHandler(): Closure|string { return $this->handler; From 2543e7ace349b6175a3d829be4381199d5247d58 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 12 Sep 2025 13:01:19 -0500 Subject: [PATCH 08/49] refactor: add setPriority method to EventListener interface and AbstractListener class --- src/Events/AbstractListener.php | 7 +++++++ src/Events/Contracts/EventListener.php | 2 ++ 2 files changed, 9 insertions(+) diff --git a/src/Events/AbstractListener.php b/src/Events/AbstractListener.php index 3c394023..f11f4093 100644 --- a/src/Events/AbstractListener.php +++ b/src/Events/AbstractListener.php @@ -16,6 +16,13 @@ abstract class AbstractListener implements EventListenerContract abstract public function handle(Event $event): mixed; + public function setPriority(int $priority): self + { + $this->priority = $priority; + + return $this; + } + public function getPriority(): int { return $this->priority; diff --git a/src/Events/Contracts/EventListener.php b/src/Events/Contracts/EventListener.php index 7ff80621..b1efe23f 100644 --- a/src/Events/Contracts/EventListener.php +++ b/src/Events/Contracts/EventListener.php @@ -12,6 +12,8 @@ public function handle(Event $event): mixed; public function getPriority(): int; + public function setPriority(int $priority): self; + public function shouldHandle(Event $event): bool; public function isOnce(): bool; From 49682fc4ca7ae146b23b9da97211d99be5ed67f5 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 12 Sep 2025 13:03:20 -0500 Subject: [PATCH 09/49] refactor: streamline handle method in EventListener for improved readability --- src/Events/EventListener.php | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/Events/EventListener.php b/src/Events/EventListener.php index 754d3144..697f2eff 100644 --- a/src/Events/EventListener.php +++ b/src/Events/EventListener.php @@ -19,24 +19,21 @@ public function __construct( public function handle(Event $event): mixed { - if ($this->handler instanceof Closure) { - return ($this->handler)($event); - } + $result = null; - // Handle string-based class listeners - if (is_string($this->handler)) { + if ($this->handler instanceof Closure) { + $result = ($this->handler)($event); + } elseif (is_string($this->handler)) { $listener = App::make($this->handler); if (method_exists($listener, 'handle')) { - return $listener->handle($event); - } - - if (is_callable($listener)) { - return $listener($event); + $result = $listener->handle($event); + } elseif (is_callable($listener)) { + $result = $listener($event); } } - return null; + return $result; } public function getHandler(): Closure|string From f711cee10ab9e3031a2ec1dd38b406b986d648ce Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 12 Sep 2025 13:11:05 -0500 Subject: [PATCH 10/49] refactor: normalize priority assignment in AbstractListener and EventListener constructors --- src/Events/AbstractListener.php | 7 ++++++- src/Events/EventListener.php | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Events/AbstractListener.php b/src/Events/AbstractListener.php index f11f4093..9314fbfe 100644 --- a/src/Events/AbstractListener.php +++ b/src/Events/AbstractListener.php @@ -18,7 +18,7 @@ abstract public function handle(Event $event): mixed; public function setPriority(int $priority): self { - $this->priority = $priority; + $this->priority = $this->normalizePriority($priority); return $this; } @@ -49,4 +49,9 @@ public function getHandler(): Closure|static|string { return $this; } + + protected function normalizePriority(int $priority): int + { + return max(0, min($priority, 100)); + } } diff --git a/src/Events/EventListener.php b/src/Events/EventListener.php index 697f2eff..b646bcbe 100644 --- a/src/Events/EventListener.php +++ b/src/Events/EventListener.php @@ -14,7 +14,7 @@ public function __construct( protected Closure|string $handler, int $priority = 0 ) { - $this->priority = $priority; + $this->priority = $this->normalizePriority($priority); } public function handle(Event $event): mixed From edc1ec6dc3005ba37a7ec3f429b3b13862e1e995 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 17 Sep 2025 09:13:13 -0500 Subject: [PATCH 11/49] refactor: remove unnecessary docblock from shouldHandle method in listener stub --- src/stubs/listener.stub | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/stubs/listener.stub b/src/stubs/listener.stub index 6a24261b..102d7d47 100644 --- a/src/stubs/listener.stub +++ b/src/stubs/listener.stub @@ -15,9 +15,6 @@ class {name} extends AbstractListener // } - /** - * Determine if the listener should handle the event. - */ public function shouldHandle(Event $event): bool { return true; From 657214a445d9411741b74c920a922a58046c4eec Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 17 Sep 2025 09:19:52 -0500 Subject: [PATCH 12/49] refactor: remove timestamp handling from AbstractEvent and move it to Event class --- src/Events/AbstractEvent.php | 15 ++------------- src/Events/Contracts/Event.php | 2 -- src/Events/Event.php | 10 +++++++++- 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/src/Events/AbstractEvent.php b/src/Events/AbstractEvent.php index f8c289d6..0af03787 100644 --- a/src/Events/AbstractEvent.php +++ b/src/Events/AbstractEvent.php @@ -8,15 +8,9 @@ abstract class AbstractEvent implements EventContract { - protected bool $propagationStopped = false; - - protected float $timestamp; + protected mixed $payload = null; - public function __construct( - protected mixed $payload = null - ) { - $this->timestamp = microtime(true); - } + protected bool $propagationStopped = false; public function getName(): string { @@ -38,11 +32,6 @@ public function stopPropagation(): void $this->propagationStopped = true; } - public function getTimestamp(): float - { - return $this->timestamp; - } - public function __toString(): string { return $this->getName(); diff --git a/src/Events/Contracts/Event.php b/src/Events/Contracts/Event.php index 32e405f4..b8428e87 100644 --- a/src/Events/Contracts/Event.php +++ b/src/Events/Contracts/Event.php @@ -13,6 +13,4 @@ public function getPayload(): mixed; public function isPropagationStopped(): bool; public function stopPropagation(): void; - - public function getTimestamp(): float; } diff --git a/src/Events/Event.php b/src/Events/Event.php index 900ba865..31c66361 100644 --- a/src/Events/Event.php +++ b/src/Events/Event.php @@ -6,13 +6,21 @@ class Event extends AbstractEvent { + protected float $timestamp; + public function __construct( protected string $name, - protected mixed $payload = null + mixed $payload = null ) { + $this->payload = $payload; $this->timestamp = microtime(true); } + public function getTimestamp(): float + { + return $this->timestamp; + } + public function getName(): string { return $this->name; From 819cd0f1590ecd726a80e89463661acfda080532 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 17 Sep 2025 18:55:25 -0500 Subject: [PATCH 13/49] refactor: clean up whitespace in emitAsync method for improved readability --- src/Events/EventEmitter.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Events/EventEmitter.php b/src/Events/EventEmitter.php index c32f8810..3f32cf39 100644 --- a/src/Events/EventEmitter.php +++ b/src/Events/EventEmitter.php @@ -150,6 +150,7 @@ public function emitAsync(string|EventContract $event, mixed $payload = null): F } $results = []; + foreach ($futures as $future) { try { $results[] = $future->await(); @@ -158,6 +159,7 @@ public function emitAsync(string|EventContract $event, mixed $payload = null): F 'event' => $eventObject->getName(), 'error' => $e->getMessage(), ]); + $results[] = null; } } From d5c64a02f90702a058c5407e30cb7ccdf91d1050 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 17 Sep 2025 21:33:14 -0500 Subject: [PATCH 14/49] refactor: add has method to App class and improve event listener handling logic --- src/App.php | 5 ++++ src/Events/EventListener.php | 27 ++++++++++++------- .../Exceptions/EventListenerException.php | 12 +++++++++ 3 files changed, 34 insertions(+), 10 deletions(-) create mode 100644 src/Events/Exceptions/EventListenerException.php diff --git a/src/App.php b/src/App.php index 780b049e..ec7039a3 100644 --- a/src/App.php +++ b/src/App.php @@ -105,6 +105,11 @@ public static function make(string $key): object return self::$container->get($key); } + public static function has(string $key): bool + { + return self::$container->has($key); + } + public static function path(): string { return self::$path; diff --git a/src/Events/EventListener.php b/src/Events/EventListener.php index b646bcbe..c2a14598 100644 --- a/src/Events/EventListener.php +++ b/src/Events/EventListener.php @@ -7,6 +7,11 @@ use Closure; use Phenix\App; use Phenix\Events\Contracts\Event; +use Phenix\Events\Contracts\EventListener as EventListenerContract; +use Phenix\Events\Exceptions\EventListenerException; + +use function class_exists; +use function is_callable; class EventListener extends AbstractListener { @@ -19,21 +24,23 @@ public function __construct( public function handle(Event $event): mixed { - $result = null; - if ($this->handler instanceof Closure) { - $result = ($this->handler)($event); - } elseif (is_string($this->handler)) { + return ($this->handler)($event); + } + + $listener = null; + + if (App::has($this->handler)) { $listener = App::make($this->handler); + } elseif (class_exists($this->handler)) { + $listener = new $this->handler(); + } - if (method_exists($listener, 'handle')) { - $result = $listener->handle($event); - } elseif (is_callable($listener)) { - $result = $listener($event); - } + if (! $listener || ! $listener instanceof EventListenerContract && ! is_callable($listener)) { + throw new EventListenerException("Resolved listener is invalid: {$this->handler}"); } - return $result; + return $listener instanceof EventListenerContract ? $listener->handle($event) : $listener($event); } public function getHandler(): Closure|string diff --git a/src/Events/Exceptions/EventListenerException.php b/src/Events/Exceptions/EventListenerException.php new file mode 100644 index 00000000..92cdfae7 --- /dev/null +++ b/src/Events/Exceptions/EventListenerException.php @@ -0,0 +1,12 @@ + Date: Wed, 17 Sep 2025 21:34:25 -0500 Subject: [PATCH 15/49] refactor: remove event constructor --- src/stubs/event.stub | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/stubs/event.stub b/src/stubs/event.stub index e3caad59..132a05d5 100644 --- a/src/stubs/event.stub +++ b/src/stubs/event.stub @@ -8,9 +8,5 @@ use Phenix\Events\AbstractEvent; class {name} extends AbstractEvent { - public function __construct( - mixed $payload = null - ) { - parent::__construct($payload); - } + // } From 48590a7f49992164e938c7eeeb90ac653d3faacc Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 18 Sep 2025 08:17:42 -0500 Subject: [PATCH 16/49] refactor: simplify event listener handling and remove unused exception class --- src/Events/EventListener.php | 30 +++++++++++-------- .../Exceptions/EventListenerException.php | 12 -------- 2 files changed, 18 insertions(+), 24 deletions(-) delete mode 100644 src/Events/Exceptions/EventListenerException.php diff --git a/src/Events/EventListener.php b/src/Events/EventListener.php index c2a14598..6ae1e04a 100644 --- a/src/Events/EventListener.php +++ b/src/Events/EventListener.php @@ -7,11 +7,10 @@ use Closure; use Phenix\App; use Phenix\Events\Contracts\Event; -use Phenix\Events\Contracts\EventListener as EventListenerContract; -use Phenix\Events\Exceptions\EventListenerException; use function class_exists; use function is_callable; +use function method_exists; class EventListener extends AbstractListener { @@ -28,23 +27,30 @@ public function handle(Event $event): mixed return ($this->handler)($event); } - $listener = null; + $listener = $this->resolveListener(); - if (App::has($this->handler)) { - $listener = App::make($this->handler); - } elseif (class_exists($this->handler)) { - $listener = new $this->handler(); + if (! $listener || ! method_exists($listener, 'handle') || ! is_callable($listener)) { + return null; } - if (! $listener || ! $listener instanceof EventListenerContract && ! is_callable($listener)) { - throw new EventListenerException("Resolved listener is invalid: {$this->handler}"); - } - - return $listener instanceof EventListenerContract ? $listener->handle($event) : $listener($event); + return method_exists($listener, 'handle') ? $listener->handle($event) : $listener($event); } public function getHandler(): Closure|string { return $this->handler; } + + private function resolveListener(): object|null + { + if (App::has($this->handler)) { + $listener = App::make($this->handler); + } elseif (class_exists($this->handler)) { + $listener = new $this->handler(); + } else { + $listener = null; + } + + return $listener; + } } diff --git a/src/Events/Exceptions/EventListenerException.php b/src/Events/Exceptions/EventListenerException.php deleted file mode 100644 index 92cdfae7..00000000 --- a/src/Events/Exceptions/EventListenerException.php +++ /dev/null @@ -1,12 +0,0 @@ - Date: Thu, 18 Sep 2025 08:43:30 -0500 Subject: [PATCH 17/49] test: add test for registering and emitting events with facade syntax --- tests/Unit/Events/EventEmitterTest.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/Unit/Events/EventEmitterTest.php b/tests/Unit/Events/EventEmitterTest.php index ca392e7c..c7212fd8 100644 --- a/tests/Unit/Events/EventEmitterTest.php +++ b/tests/Unit/Events/EventEmitterTest.php @@ -5,6 +5,7 @@ use Phenix\Events\Contracts\Event as EventContract; use Phenix\Events\Event; use Phenix\Events\EventEmitter; +use Phenix\Facades\Event as EventFacade; it('can register and emit basic events', function (): void { $emitter = new EventEmitter(); @@ -21,6 +22,20 @@ expect($called)->toBeTrue(); }); +it('can register and emit events with facade syntax', function (): void { + $called = false; + + EventFacade::on('facade.event', function (EventContract $event) use (&$called): void { + $called = true; + expect($event->getName())->toBe('facade.event'); + expect($event->getPayload())->toBe('facade data'); + }); + + EventFacade::emit('facade.event', 'facade data'); + + expect($called)->toBeTrue(); +}); + it('can register multiple listeners for same event', function (): void { $emitter = new EventEmitter(); $count = 0; From eef9ad8f5d872c9a080505f6cb7ebdb168838a21 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 18 Sep 2025 08:47:14 -0500 Subject: [PATCH 18/49] test: add test for removing non-registered event listeners --- tests/Unit/Events/EventEmitterTest.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/Unit/Events/EventEmitterTest.php b/tests/Unit/Events/EventEmitterTest.php index c7212fd8..b2e9032c 100644 --- a/tests/Unit/Events/EventEmitterTest.php +++ b/tests/Unit/Events/EventEmitterTest.php @@ -104,6 +104,19 @@ expect($called)->toBeFalse(); }); +it('tries to remove non registered event', function (): void { + $emitter = new EventEmitter(); + $called = false; + + $listener = function () use (&$called): void { + $called = true; + }; + + $emitter->off('removable.event', $listener); + + expect($called)->toBeFalse(); +}); + it('can remove all listeners for an event', function (): void { $emitter = new EventEmitter(); $count = 0; From 548ba7e1e31c6044a64868003c60f9855f78211b Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 18 Sep 2025 09:05:02 -0500 Subject: [PATCH 19/49] refactor: remove unused shouldHandle method from listener stub --- src/stubs/listener.stub | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/stubs/listener.stub b/src/stubs/listener.stub index 102d7d47..e4b416aa 100644 --- a/src/stubs/listener.stub +++ b/src/stubs/listener.stub @@ -14,9 +14,4 @@ class {name} extends AbstractListener { // } - - public function shouldHandle(Event $event): bool - { - return true; - } } From 92376292850c3a0ea2d14c805bc262c71a763efa Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 18 Sep 2025 09:05:31 -0500 Subject: [PATCH 20/49] test: add test to skip listener when shouldHandle returns false --- tests/Unit/Events/EventEmitterTest.php | 20 +++++++++++++++++++ .../Unit/Events/Internal/StandardListener.php | 16 +++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 tests/Unit/Events/Internal/StandardListener.php diff --git a/tests/Unit/Events/EventEmitterTest.php b/tests/Unit/Events/EventEmitterTest.php index b2e9032c..f6dfc5c9 100644 --- a/tests/Unit/Events/EventEmitterTest.php +++ b/tests/Unit/Events/EventEmitterTest.php @@ -6,6 +6,7 @@ use Phenix\Events\Event; use Phenix\Events\EventEmitter; use Phenix\Facades\Event as EventFacade; +use Tests\Unit\Events\Internal\StandardListener; it('can register and emit basic events', function (): void { $emitter = new EventEmitter(); @@ -181,6 +182,25 @@ expect($called)->toBeTrue(); }); +it('skip listener when shouldHandle returns false', function (): void { + $emitter = new EventEmitter(); + + $listener = $this->getMockBuilder(StandardListener::class) + ->onlyMethods(['shouldHandle', 'handle']) + ->getMock(); + + $listener->expects($this->once()) + ->method('shouldHandle') + ->willReturn(false); + + $listener->expects($this->never()) + ->method('handle'); + + $emitter->on('custom.event', $listener); + + $emitter->emit('custom.event', 'data'); +}); + it('can check if event has listeners', function (): void { $emitter = new EventEmitter(); diff --git a/tests/Unit/Events/Internal/StandardListener.php b/tests/Unit/Events/Internal/StandardListener.php new file mode 100644 index 00000000..03d20836 --- /dev/null +++ b/tests/Unit/Events/Internal/StandardListener.php @@ -0,0 +1,16 @@ +getName(); + } +} From 2dfeb97bc6e7b405959180da60b494daf76a0121 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 18 Sep 2025 12:40:59 -0500 Subject: [PATCH 21/49] feat: add fake method to extend container with mock implementations --- src/App.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/App.php b/src/App.php index ec7039a3..851a2af3 100644 --- a/src/App.php +++ b/src/App.php @@ -12,6 +12,8 @@ use Amp\Socket; use League\Container\Container; use League\Uri\Uri; +use Mockery\LegacyMockInterface; +use Mockery\MockInterface; use Monolog\Logger; use Phenix\Console\Phenix; use Phenix\Contracts\App as AppContract; @@ -110,6 +112,11 @@ public static function has(string $key): bool return self::$container->has($key); } + public static function fake(string $key, LegacyMockInterface|MockInterface $concrete): void + { + self::$container->extend($key)->setConcrete($concrete); + } + public static function path(): string { return self::$path; From 89c019c10813bbb22461768d2c1ae630ff6042b3 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 18 Sep 2025 12:41:08 -0500 Subject: [PATCH 22/49] feat: add shouldReceive method to Log facade for mocking expectations --- src/Facades/Log.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/Facades/Log.php b/src/Facades/Log.php index 18943404..09a39182 100644 --- a/src/Facades/Log.php +++ b/src/Facades/Log.php @@ -4,7 +4,12 @@ namespace Phenix\Facades; +use Mockery\Expectation; +use Mockery\ExpectationInterface; +use Mockery\HigherOrderMessage; +use Phenix\App; use Phenix\Runtime\Facade; +use Phenix\Testing\Mockery; /** * @method static void info(string $message, array $context = []) @@ -24,4 +29,13 @@ public static function getKeyName(): string { return \Phenix\Runtime\Log::class; } + + public static function shouldReceive(string $method): Expectation|ExpectationInterface|HigherOrderMessage + { + $mock = Mockery::mock(self::getKeyName())->shouldAllowMockingProtectedMethods()->makePartial(); + + App::fake(self::getKeyName(), $mock); + + return $mock->shouldReceive($method); + } } From 872cc28da32d574c8644cd7f2add54a7cb6fba2f Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 18 Sep 2025 12:41:17 -0500 Subject: [PATCH 23/49] test: add test to handle listener error gracefully and log error --- tests/Unit/Events/EventEmitterTest.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/Unit/Events/EventEmitterTest.php b/tests/Unit/Events/EventEmitterTest.php index f6dfc5c9..933649ef 100644 --- a/tests/Unit/Events/EventEmitterTest.php +++ b/tests/Unit/Events/EventEmitterTest.php @@ -5,7 +5,10 @@ use Phenix\Events\Contracts\Event as EventContract; use Phenix\Events\Event; use Phenix\Events\EventEmitter; +use Phenix\Events\Exceptions\EventException; +use Phenix\Exceptions\RuntimeError; use Phenix\Facades\Event as EventFacade; +use Phenix\Facades\Log; use Tests\Unit\Events\Internal\StandardListener; it('can register and emit basic events', function (): void { @@ -201,6 +204,19 @@ $emitter->emit('custom.event', 'data'); }); +it('handle listener error gracefully', function (): void { + $emitter = new EventEmitter(); + + $emitter->on('error.event', function (): never { + throw new RuntimeError('Listener error'); + }); + + Log::shouldReceive('error') + ->once(); + + $emitter->emit('error.event'); +})->throws(EventException::class); + it('can check if event has listeners', function (): void { $emitter = new EventEmitter(); From 730fbbf8415500da819fb9167e01089df2da99f8 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 18 Sep 2025 12:45:19 -0500 Subject: [PATCH 24/49] feat: add async event registration and emission test --- tests/Unit/Events/EventEmitterTest.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/Unit/Events/EventEmitterTest.php b/tests/Unit/Events/EventEmitterTest.php index 933649ef..54b1afd8 100644 --- a/tests/Unit/Events/EventEmitterTest.php +++ b/tests/Unit/Events/EventEmitterTest.php @@ -26,6 +26,18 @@ expect($called)->toBeTrue(); }); +it('can register and emit async events', function (): void { + $emitter = new EventEmitter(); + + $emitter->on('test.event', fn (EventContract $event): string => $event->getPayload()); + + $future = $emitter->emitAsync('test.event', 'test data'); + + $results = $future->await(); + + expect($results)->toBe(['test data']); +}); + it('can register and emit events with facade syntax', function (): void { $called = false; From 150318991db0da7f5fceba51abdc65715d11e3d2 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 18 Sep 2025 19:08:08 -0500 Subject: [PATCH 25/49] test: add test for async event propagation handling --- src/Events/EventEmitter.php | 8 ++++---- tests/Unit/Events/EventEmitterTest.php | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/Events/EventEmitter.php b/src/Events/EventEmitter.php index 3f32cf39..9ae14094 100644 --- a/src/Events/EventEmitter.php +++ b/src/Events/EventEmitter.php @@ -138,10 +138,6 @@ public function emitAsync(string|EventContract $event, mixed $payload = null): F $futures = []; foreach ($listeners as $listener) { - if ($eventObject->isPropagationStopped()) { - break; - } - if (! $listener->shouldHandle($eventObject)) { continue; } @@ -172,6 +168,10 @@ protected function handleListenerAsync(EventListenerContract $listener, EventCon { return async(function () use ($listener, $eventObject): mixed { try { + if ($eventObject->isPropagationStopped()) { + return null; + } + $result = $listener->handle($eventObject); // Remove one-time listeners after execution diff --git a/tests/Unit/Events/EventEmitterTest.php b/tests/Unit/Events/EventEmitterTest.php index 54b1afd8..53ed2c72 100644 --- a/tests/Unit/Events/EventEmitterTest.php +++ b/tests/Unit/Events/EventEmitterTest.php @@ -169,6 +169,26 @@ expect($count)->toBe(1); }); +it('can stop async event propagation', function (): void { + $emitter = new EventEmitter(); + $count = 0; + + $emitter->on('stop.event', function (EventContract $event) use (&$count): void { + $count++; + $event->stopPropagation(); + }); + + $emitter->on('stop.event', function (EventContract $event) use (&$count): void { + $count++; + }); + + $future = $emitter->emitAsync('stop.event'); + + $future->await(); + + expect($count)->toBe(1); +}); + it('returns results from listeners', function (): void { $emitter = new EventEmitter(); From 10d8d2167dee82ccec0bb38b072977c117b7a9af Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 18 Sep 2025 19:22:34 -0500 Subject: [PATCH 26/49] refactor: improve test description for event handling --- tests/Unit/Events/EventEmitterTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Unit/Events/EventEmitterTest.php b/tests/Unit/Events/EventEmitterTest.php index 53ed2c72..c7cb1dcb 100644 --- a/tests/Unit/Events/EventEmitterTest.php +++ b/tests/Unit/Events/EventEmitterTest.php @@ -217,7 +217,7 @@ expect($called)->toBeTrue(); }); -it('skip listener when shouldHandle returns false', function (): void { +it('skip the listener when this should not be handled', function (): void { $emitter = new EventEmitter(); $listener = $this->getMockBuilder(StandardListener::class) From eadfd87f4e8e9d97653c255a334e404a8a4641cb Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 18 Sep 2025 19:23:50 -0500 Subject: [PATCH 27/49] test: add async event handling for listeners that should not be triggered --- tests/Unit/Events/EventEmitterTest.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/Unit/Events/EventEmitterTest.php b/tests/Unit/Events/EventEmitterTest.php index c7cb1dcb..dd0ae1b2 100644 --- a/tests/Unit/Events/EventEmitterTest.php +++ b/tests/Unit/Events/EventEmitterTest.php @@ -236,6 +236,25 @@ $emitter->emit('custom.event', 'data'); }); +it('skip the listener when this should not be handled in async event', function (): void { + $emitter = new EventEmitter(); + + $listener = $this->getMockBuilder(StandardListener::class) + ->onlyMethods(['shouldHandle', 'handle']) + ->getMock(); + + $listener->expects($this->once()) + ->method('shouldHandle') + ->willReturn(false); + + $listener->expects($this->never()) + ->method('handle'); + + $emitter->on('custom.event', $listener); + + $emitter->emitAsync('custom.event', 'data'); +}); + it('handle listener error gracefully', function (): void { $emitter = new EventEmitter(); From a31d046209ea6c8dd819e641d1f5216a3ca67304 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 18 Sep 2025 19:35:25 -0500 Subject: [PATCH 28/49] test: add async handling for listener errors and ensure proper logging --- tests/Unit/Events/EventEmitterTest.php | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/Unit/Events/EventEmitterTest.php b/tests/Unit/Events/EventEmitterTest.php index dd0ae1b2..c424c801 100644 --- a/tests/Unit/Events/EventEmitterTest.php +++ b/tests/Unit/Events/EventEmitterTest.php @@ -252,7 +252,9 @@ $emitter->on('custom.event', $listener); - $emitter->emitAsync('custom.event', 'data'); + $future = $emitter->emitAsync('custom.event', 'data'); + + $future->await(); }); it('handle listener error gracefully', function (): void { @@ -268,6 +270,21 @@ $emitter->emit('error.event'); })->throws(EventException::class); +it('handle listener error gracefully in async event', function (): void { + $emitter = new EventEmitter(); + + $emitter->on('error.event', function (): never { + throw new RuntimeError('Listener error'); + }); + + Log::shouldReceive('error') + ->times(2); + + $future = $emitter->emitAsync('error.event'); + + $future->await(); +}); + it('can check if event has listeners', function (): void { $emitter = new EventEmitter(); From c9cc22d45c5e6d1894df677f28b600629fbb9f7b Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 18 Sep 2025 19:36:07 -0500 Subject: [PATCH 29/49] test: enable warning emission for listener errors in async events --- tests/Unit/Events/EventEmitterTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Unit/Events/EventEmitterTest.php b/tests/Unit/Events/EventEmitterTest.php index c424c801..4b7ce250 100644 --- a/tests/Unit/Events/EventEmitterTest.php +++ b/tests/Unit/Events/EventEmitterTest.php @@ -272,6 +272,7 @@ it('handle listener error gracefully in async event', function (): void { $emitter = new EventEmitter(); + $emitter->setEmitWarnings(true); $emitter->on('error.event', function (): never { throw new RuntimeError('Listener error'); From c3e8cf74333af41a287103ecf3911fb341e3d8c0 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 25 Sep 2025 13:17:55 -0500 Subject: [PATCH 30/49] test: add async handling for one-time listeners and ensure proper removal --- tests/Unit/Events/EventEmitterTest.php | 27 ++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/Unit/Events/EventEmitterTest.php b/tests/Unit/Events/EventEmitterTest.php index 4b7ce250..a5497393 100644 --- a/tests/Unit/Events/EventEmitterTest.php +++ b/tests/Unit/Events/EventEmitterTest.php @@ -257,6 +257,33 @@ $future->await(); }); +it('uses listener once and removes this after use', function (): void { + $emitter = new EventEmitter(); + + $listener = $this->getMockBuilder(StandardListener::class) + ->onlyMethods(['shouldHandle', 'isOnce', 'handle']) + ->getMock(); + + $listener->expects($this->once()) + ->method('shouldHandle') + ->willReturn(true); + + $listener->expects($this->once()) + ->method('handle') + ->willReturn('Event name: custom.event'); + + $listener->expects($this->once()) + ->method('isOnce') + ->willReturn(true); + + + $emitter->on('custom.event', $listener); + + $future = $emitter->emitAsync('custom.event', 'data'); + + $future->await(); +}); + it('handle listener error gracefully', function (): void { $emitter = new EventEmitter(); From 65aefbfcd912aae123954edff4e7f9b3959ac17b Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 25 Sep 2025 13:21:13 -0500 Subject: [PATCH 31/49] test: add handling for listener errors in async events without warnings --- tests/Unit/Events/EventEmitterTest.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/Unit/Events/EventEmitterTest.php b/tests/Unit/Events/EventEmitterTest.php index a5497393..10dca853 100644 --- a/tests/Unit/Events/EventEmitterTest.php +++ b/tests/Unit/Events/EventEmitterTest.php @@ -313,6 +313,21 @@ $future->await(); }); +it('handle listener error gracefully in async event without warnings', function (): void { + $emitter = new EventEmitter(); + $emitter->setEmitWarnings(false); + + $emitter->on('error.event', function (): never { + throw new RuntimeError('Listener error'); + }); + + Log::shouldReceive('error')->once(); + + $future = $emitter->emitAsync('error.event'); + + $future->await(); +}); + it('can check if event has listeners', function (): void { $emitter = new EventEmitter(); From 3e5563765469ffe14f4e853b746424708b4419ae Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 25 Sep 2025 13:23:32 -0500 Subject: [PATCH 32/49] refactor: remove unnecessary check for event listeners in sortListenersByPriority method --- src/Events/EventEmitter.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/Events/EventEmitter.php b/src/Events/EventEmitter.php index 9ae14094..3c62aadc 100644 --- a/src/Events/EventEmitter.php +++ b/src/Events/EventEmitter.php @@ -265,10 +265,6 @@ protected function createEvent(string|EventContract $event, mixed $payload): Eve protected function sortListenersByPriority(string $event): void { - if (! isset($this->listeners[$event])) { - return; - } - usort($this->listeners[$event], function (EventListenerContract $a, EventListenerContract $b): int { return $b->getPriority() <=> $a->getPriority(); }); From bbd318ee1eff1d0c5a0d9a3f230494fa82cadcd6 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 25 Sep 2025 13:27:53 -0500 Subject: [PATCH 33/49] test: add warning for exceeding maximum number of listeners for an event --- tests/Unit/Events/EventEmitterTest.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/Unit/Events/EventEmitterTest.php b/tests/Unit/Events/EventEmitterTest.php index 10dca853..2fa4f686 100644 --- a/tests/Unit/Events/EventEmitterTest.php +++ b/tests/Unit/Events/EventEmitterTest.php @@ -392,3 +392,16 @@ expect($emitter->getEventNames())->toBeEmpty(); }); + +it('warns when exceeding the maximum number of listeners for an event', function (): void { + $emitter = new EventEmitter(); + + $emitter->setMaxListeners(1); + + Log::shouldReceive('warning')->once(); + + $emitter->on('warn.event', fn (): null => null); + $emitter->on('warn.event', fn (): null => null); // This pushes it over the limit and should log a warning + + expect($emitter->getListenerCount('warn.event'))->toBe(2); +}); From 4d63e53c3da23e8ae0b15344891bdd56caa6833b Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 25 Sep 2025 13:35:41 -0500 Subject: [PATCH 34/49] test: enable warning emission when exceeding maximum number of listeners --- tests/Unit/Events/EventEmitterTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Unit/Events/EventEmitterTest.php b/tests/Unit/Events/EventEmitterTest.php index 2fa4f686..5955e96a 100644 --- a/tests/Unit/Events/EventEmitterTest.php +++ b/tests/Unit/Events/EventEmitterTest.php @@ -397,6 +397,7 @@ $emitter = new EventEmitter(); $emitter->setMaxListeners(1); + $emitter->setEmitWarnings(true); Log::shouldReceive('warning')->once(); From cfe099dc71751dfdd67ab02a44b16fc4b1a0f1c8 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 25 Sep 2025 13:35:45 -0500 Subject: [PATCH 35/49] test: add check for warning suppression when exceeding maximum listeners --- tests/Unit/Events/EventEmitterTest.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/Unit/Events/EventEmitterTest.php b/tests/Unit/Events/EventEmitterTest.php index 5955e96a..bd95527f 100644 --- a/tests/Unit/Events/EventEmitterTest.php +++ b/tests/Unit/Events/EventEmitterTest.php @@ -406,3 +406,17 @@ expect($emitter->getListenerCount('warn.event'))->toBe(2); }); + +it('does not warn when exceeding maximum listeners if warnings disabled', function (): void { + $emitter = new EventEmitter(); + + $emitter->setMaxListeners(1); + $emitter->setEmitWarnings(false); + + Log::shouldReceive('warning')->never(); + + $emitter->on('warn.event', fn (): null => null); + $emitter->on('warn.event', fn (): null => null); + + expect($emitter->getListenerCount('warn.event'))->toBe(2); +}); From 7d070ba8b87af1640f22511666148cd65938b646 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 25 Sep 2025 13:36:15 -0500 Subject: [PATCH 36/49] refactor: simplify removeListener method by removing unnecessary check for event listeners --- src/Events/EventEmitter.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/Events/EventEmitter.php b/src/Events/EventEmitter.php index 3c62aadc..0b383f43 100644 --- a/src/Events/EventEmitter.php +++ b/src/Events/EventEmitter.php @@ -296,10 +296,6 @@ protected function isSameListener(EventListenerContract $eventListener, Closure| protected function removeListener(string $event, EventListenerContract $listener): void { - if (! isset($this->listeners[$event])) { - return; - } - $this->listeners[$event] = array_filter( $this->listeners[$event], fn (EventListenerContract $eventListener): bool => ! $this->isSameListener($eventListener, $listener) From 00f0fb9672d43c92901d39ab0aaaa8771f4d08b4 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 25 Sep 2025 13:51:47 -0500 Subject: [PATCH 37/49] refactor: simplify resolveListener method and improve conditional check in handle method --- src/Events/EventListener.php | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/Events/EventListener.php b/src/Events/EventListener.php index 6ae1e04a..d9b638f8 100644 --- a/src/Events/EventListener.php +++ b/src/Events/EventListener.php @@ -29,7 +29,7 @@ public function handle(Event $event): mixed $listener = $this->resolveListener(); - if (! $listener || ! method_exists($listener, 'handle') || ! is_callable($listener)) { + if (! $listener || ! (method_exists($listener, 'handle') || is_callable($listener))) { return null; } @@ -43,14 +43,8 @@ public function getHandler(): Closure|string private function resolveListener(): object|null { - if (App::has($this->handler)) { - $listener = App::make($this->handler); - } elseif (class_exists($this->handler)) { - $listener = new $this->handler(); - } else { - $listener = null; - } - - return $listener; + return class_exists($this->handler) + ? new $this->handler() + : null; } } From ca5f2d1add1e1f455486cb3cfb175e5b72ccaf7f Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 25 Sep 2025 13:52:26 -0500 Subject: [PATCH 38/49] test: add test for registering and emitting events with string-class listeners --- tests/Unit/Events/EventEmitterTest.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/Unit/Events/EventEmitterTest.php b/tests/Unit/Events/EventEmitterTest.php index bd95527f..61c87567 100644 --- a/tests/Unit/Events/EventEmitterTest.php +++ b/tests/Unit/Events/EventEmitterTest.php @@ -38,6 +38,16 @@ expect($results)->toBe(['test data']); }); +it('can register and emit basic events with string-class listeners', function (): void { + $emitter = new EventEmitter(); + + $emitter->on('test.event', StandardListener::class); + + $results = $emitter->emit('test.event', 'test data'); + dump($results); + expect($results)->toBe(['Event name: test.event']); +}); + it('can register and emit events with facade syntax', function (): void { $called = false; From cc041ed9658a33573d1dd0b6dd7e6f3a1e38b484 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 25 Sep 2025 13:53:17 -0500 Subject: [PATCH 39/49] refactor: remove unused has method from App class --- src/App.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/App.php b/src/App.php index 851a2af3..95ded4c7 100644 --- a/src/App.php +++ b/src/App.php @@ -107,11 +107,6 @@ public static function make(string $key): object return self::$container->get($key); } - public static function has(string $key): bool - { - return self::$container->has($key); - } - public static function fake(string $key, LegacyMockInterface|MockInterface $concrete): void { self::$container->extend($key)->setConcrete($concrete); From eb814b0edf3ba2c93d274305dfd9bd7c28520fb0 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 25 Sep 2025 13:54:01 -0500 Subject: [PATCH 40/49] style: php cs --- src/Events/EventListener.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Events/EventListener.php b/src/Events/EventListener.php index d9b638f8..8e30ed5a 100644 --- a/src/Events/EventListener.php +++ b/src/Events/EventListener.php @@ -5,7 +5,6 @@ namespace Phenix\Events; use Closure; -use Phenix\App; use Phenix\Events\Contracts\Event; use function class_exists; From 0e1444b90cfa8618b540d4c7ed09ffd23f02e944 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 25 Sep 2025 14:12:06 -0500 Subject: [PATCH 41/49] refactor: remove debug dump from string-class listeners test --- tests/Unit/Events/EventEmitterTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Unit/Events/EventEmitterTest.php b/tests/Unit/Events/EventEmitterTest.php index 61c87567..d0ff792f 100644 --- a/tests/Unit/Events/EventEmitterTest.php +++ b/tests/Unit/Events/EventEmitterTest.php @@ -44,7 +44,7 @@ $emitter->on('test.event', StandardListener::class); $results = $emitter->emit('test.event', 'test data'); - dump($results); + expect($results)->toBe(['Event name: test.event']); }); From 3280683154d99f0c229b7a90a0c73f31d2609771 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 25 Sep 2025 14:14:12 -0500 Subject: [PATCH 42/49] test: add test for handling invalid listeners and create InvalidListener class --- tests/Unit/Events/EventEmitterTest.php | 11 +++++++++++ tests/Unit/Events/Internal/InvalidListener.php | 10 ++++++++++ 2 files changed, 21 insertions(+) create mode 100644 tests/Unit/Events/Internal/InvalidListener.php diff --git a/tests/Unit/Events/EventEmitterTest.php b/tests/Unit/Events/EventEmitterTest.php index d0ff792f..eb76303e 100644 --- a/tests/Unit/Events/EventEmitterTest.php +++ b/tests/Unit/Events/EventEmitterTest.php @@ -9,6 +9,7 @@ use Phenix\Exceptions\RuntimeError; use Phenix\Facades\Event as EventFacade; use Phenix\Facades\Log; +use Tests\Unit\Events\Internal\InvalidListener; use Tests\Unit\Events\Internal\StandardListener; it('can register and emit basic events', function (): void { @@ -48,6 +49,16 @@ expect($results)->toBe(['Event name: test.event']); }); +it('returns null for invalid listeners', function (): void { + $emitter = new EventEmitter(); + + $emitter->on('test.event', InvalidListener::class); + + $results = $emitter->emit('test.event', 'test data'); + + expect($results)->toBe([null]); +}); + it('can register and emit events with facade syntax', function (): void { $called = false; diff --git a/tests/Unit/Events/Internal/InvalidListener.php b/tests/Unit/Events/Internal/InvalidListener.php new file mode 100644 index 00000000..77e4441f --- /dev/null +++ b/tests/Unit/Events/Internal/InvalidListener.php @@ -0,0 +1,10 @@ + Date: Thu, 25 Sep 2025 14:23:15 -0500 Subject: [PATCH 43/49] test: add test for registering and emitting string-class events --- tests/Unit/Events/EventEmitterTest.php | 11 +++++++++++ tests/Unit/Events/Internal/StandardEvent.php | 12 ++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 tests/Unit/Events/Internal/StandardEvent.php diff --git a/tests/Unit/Events/EventEmitterTest.php b/tests/Unit/Events/EventEmitterTest.php index eb76303e..5c010d85 100644 --- a/tests/Unit/Events/EventEmitterTest.php +++ b/tests/Unit/Events/EventEmitterTest.php @@ -10,6 +10,7 @@ use Phenix\Facades\Event as EventFacade; use Phenix\Facades\Log; use Tests\Unit\Events\Internal\InvalidListener; +use Tests\Unit\Events\Internal\StandardEvent; use Tests\Unit\Events\Internal\StandardListener; it('can register and emit basic events', function (): void { @@ -27,6 +28,16 @@ expect($called)->toBeTrue(); }); +it('can register and emit string-class events', function (): void { + $emitter = new EventEmitter(); + + $emitter->on(StandardEvent::class, fn (EventContract $event): string => 'string result'); + + $results = $emitter->emit(StandardEvent::class, 'test data'); + + expect($results)->toBe(['string result']); +}); + it('can register and emit async events', function (): void { $emitter = new EventEmitter(); diff --git a/tests/Unit/Events/Internal/StandardEvent.php b/tests/Unit/Events/Internal/StandardEvent.php new file mode 100644 index 00000000..15e1b4ef --- /dev/null +++ b/tests/Unit/Events/Internal/StandardEvent.php @@ -0,0 +1,12 @@ + Date: Thu, 25 Sep 2025 14:24:08 -0500 Subject: [PATCH 44/49] refactor: remove __toString method from AbstractEvent class --- src/Events/AbstractEvent.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/Events/AbstractEvent.php b/src/Events/AbstractEvent.php index 0af03787..04f1fcf9 100644 --- a/src/Events/AbstractEvent.php +++ b/src/Events/AbstractEvent.php @@ -31,9 +31,4 @@ public function stopPropagation(): void { $this->propagationStopped = true; } - - public function __toString(): string - { - return $this->getName(); - } } From 57e974f6a517e9d7195c7e6a54c0b2625530ad93 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 25 Sep 2025 14:25:37 -0500 Subject: [PATCH 45/49] test: add test for registering and emitting events with custom listener priority --- tests/Unit/Events/EventEmitterTest.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/Unit/Events/EventEmitterTest.php b/tests/Unit/Events/EventEmitterTest.php index 5c010d85..6d26c287 100644 --- a/tests/Unit/Events/EventEmitterTest.php +++ b/tests/Unit/Events/EventEmitterTest.php @@ -60,6 +60,19 @@ expect($results)->toBe(['Event name: test.event']); }); +it('can register and emit basic events and listener with custom priority', function (): void { + $emitter = new EventEmitter(); + + $listener = new StandardListener(); + $listener->setPriority(10); + + $emitter->on('test.event', $listener); + + $results = $emitter->emit('test.event', 'test data'); + + expect($results)->toBe(['Event name: test.event']); +}); + it('returns null for invalid listeners', function (): void { $emitter = new EventEmitter(); From c0da37ae91c0d4b430950b1ffb4d9bfd50c5a1ed Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 25 Sep 2025 14:28:50 -0500 Subject: [PATCH 46/49] test: add assertion for event timestamp in Event handling test --- tests/Unit/Events/EventEmitterTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Unit/Events/EventEmitterTest.php b/tests/Unit/Events/EventEmitterTest.php index 6d26c287..e12259ab 100644 --- a/tests/Unit/Events/EventEmitterTest.php +++ b/tests/Unit/Events/EventEmitterTest.php @@ -260,6 +260,7 @@ $emitter->emit($event); expect($called)->toBeTrue(); + expect($event->getTimestamp())->toBeFloat(); }); it('skip the listener when this should not be handled', function (): void { From 3e1a87ce51eb3ac907bcfea42c12c6bf85548181 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 25 Sep 2025 14:32:21 -0500 Subject: [PATCH 47/49] style: php cs --- tests/Unit/Events/Internal/StandardEvent.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Unit/Events/Internal/StandardEvent.php b/tests/Unit/Events/Internal/StandardEvent.php index 15e1b4ef..d1924822 100644 --- a/tests/Unit/Events/Internal/StandardEvent.php +++ b/tests/Unit/Events/Internal/StandardEvent.php @@ -9,4 +9,4 @@ class StandardEvent extends AbstractEvent { // This is a standard event class for testing purposes -} \ No newline at end of file +} From 883969551dfa65edd0225f44b9b9a1b05bfb88e7 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 25 Sep 2025 15:08:42 -0500 Subject: [PATCH 48/49] refactor: remove unused Log facade import from listener stub [skip ci] --- src/stubs/listener.stub | 1 - 1 file changed, 1 deletion(-) diff --git a/src/stubs/listener.stub b/src/stubs/listener.stub index e4b416aa..c5a6bf74 100644 --- a/src/stubs/listener.stub +++ b/src/stubs/listener.stub @@ -6,7 +6,6 @@ namespace {namespace}; use Phenix\Events\AbstractListener; use Phenix\Events\Contracts\Event; -use Phenix\Facades\Log; class {name} extends AbstractListener { From 190cda132516238f2b8aa974e820e49c7216e75b Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 26 Sep 2025 14:37:08 -0500 Subject: [PATCH 49/49] docs: update CHANGELOG for v0.7.0 release notes --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5393c452..31f5bfdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +# Release Notes for 0.7.x + +## [v0.7.0 (2025-09-26)](https://github.com/phenixphp/framework/compare/0.6.0...0.7.0) + +### Added + +- Event system. ([#67](https://github.com/phenixphp/framework/pull/67)) + # Release Notes for 0.6.x ## [v0.6.0 (2025-08-22)](https://github.com/phenixphp/framework/compare/0.5.2...0.6.0)