diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..caf529a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,146 @@ +name: CI + +on: + push: + branches: [main, "2.x", "feat/*", "fix/*"] + pull_request: + branches: [main, "2.x"] + +jobs: + tests: + name: Tests · PHP ${{ matrix.php }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ["7.3", "7.4", "8.0", "8.1", "8.2", "8.3", "8.4"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP ${{ matrix.php }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + tools: composer:v2 + ini-values: error_reporting=E_ALL, display_errors=On + + - name: Validate composer.json + run: composer validate --strict + + - name: Get Composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Cache Composer packages + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-php${{ matrix.php }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: | + ${{ runner.os }}-php${{ matrix.php }}-composer- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: Run PHPUnit + run: vendor/bin/phpunit --testdox + + static-analysis: + name: Static analysis · PHPStan + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP 8.3 + uses: shivammathur/setup-php@v2 + with: + php-version: "8.3" + coverage: none + tools: composer:v2 + + - name: Get Composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Cache Composer packages + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-php8.3-composer-${{ hashFiles('**/composer.json') }} + restore-keys: | + ${{ runner.os }}-php8.3-composer- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: PHPStan (level 8) + run: vendor/bin/phpstan analyse --no-progress + + code-style: + name: Code style · PHP-CS-Fixer + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP 8.3 + uses: shivammathur/setup-php@v2 + with: + php-version: "8.3" + coverage: none + tools: composer:v2 + + - name: Get Composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Cache Composer packages + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-php8.3-composer-${{ hashFiles('**/composer.json') }} + restore-keys: | + ${{ runner.os }}-php8.3-composer- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: PHP-CS-Fixer (dry-run + diff) + run: vendor/bin/php-cs-fixer fix --dry-run --diff + + lowest-php-syntax: + name: Syntax · PHP ${{ matrix.php }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ["5.6", "7.0", "7.1", "7.2"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP ${{ matrix.php }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + # No Composer install on these PHP versions: PHPUnit 9.6 + # requires PHP >= 7.3, but composer.json keeps a "php: >=5.6" + # contract for library consumers. We only verify that the + # source files themselves parse cleanly on these interpreters. + + - name: Lint src/ + run: find src -type f -name '*.php' -print0 | xargs -0 -n1 php -l + + - name: Autoload smoke test + # Standalone script (not `php -r`) — on PHP 5.6 / 7.0 / 7.1 / 7.2 + # `__DIR__` is not defined inside `php -r '...'`, which made the + # autoloader resolve `/src/...` and load nothing. As a file + # `__DIR__` reliably points at tests/compat/, so the relative + # path to src/ works on every PHP version we support. + run: php tests/compat/autoload-smoke.php diff --git a/.gitignore b/.gitignore index a879886..f384c7c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,8 @@ /.vs/ /.vscode/ /vendor/ -/composer.lock \ No newline at end of file +/composer.lock +/.phpunit.cache/ +/.phpunit.result.cache +/.php-cs-fixer.cache +/build/ diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..9b154b7 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,101 @@ += 5.6` and those + * constructs would silently break that promise. + */ + +$finder = PhpCsFixer\Finder::create() + ->in([ + __DIR__ . '/src', + __DIR__ . '/tests', + ]) + ->name('*.php') + ->ignoreDotFiles(true) + ->ignoreVCS(true); + +return (new PhpCsFixer\Config()) + ->setRiskyAllowed(false) + ->setUsingCache(true) + ->setCacheFile(__DIR__ . '/.php-cs-fixer.cache') + ->setFinder($finder) + ->setRules([ + '@PSR12' => true, + + // PSR-12 makes constant visibility (`public const`) mandatory, + // but that syntax requires PHP 7.1+. We support PHP 5.6, so + // we restrict the modifier-keywords fixer (the new name for + // the deprecated `visibility_required`) to method/property + // only and leave bare `const` declarations alone. + 'modifier_keywords' => ['elements' => ['method', 'property']], + + // Keep empty closure bodies (`function () {}`) on one line — + // they read better than the 2-line equivalent. Non-empty + // single-line bodies still get expanded by PSR-12's + // statement_indentation rule, which is fine. + 'single_line_empty_body' => true, + 'braces_position' => [ + 'anonymous_functions_opening_brace' => 'same_line', + ], + + // PHPDoc separation produces noisy blank lines around + // @return / @throws / @param. The existing house style packs + // them together; keep that. + 'phpdoc_separation' => false, + + // Array & syntax preferences (match the existing source). + 'array_syntax' => ['syntax' => 'short'], + 'trailing_comma_in_multiline' => ['elements' => ['arrays']], + 'no_whitespace_before_comma_in_array' => true, + 'whitespace_after_comma_in_array' => true, + + // Imports. + 'no_unused_imports' => true, + 'ordered_imports' => [ + 'sort_algorithm' => 'alpha', + 'imports_order' => ['class', 'function', 'const'], + ], + + // Strings. + 'single_quote' => true, + + // Whitespace. + 'blank_line_after_opening_tag' => true, + 'no_extra_blank_lines' => [ + 'tokens' => [ + 'extra', + 'throw', + 'use', + 'curly_brace_block', + 'parenthesis_brace_block', + 'square_brace_block', + ], + ], + 'no_trailing_whitespace' => true, + 'no_trailing_whitespace_in_comment' => true, + 'single_blank_line_at_eof' => true, + + // Operators. + 'binary_operator_spaces' => ['default' => 'single_space'], + 'concat_space' => ['spacing' => 'one'], + 'not_operator_with_successor_space' => false, + 'unary_operator_spaces' => true, + + // Phpdoc. + 'phpdoc_align' => ['align' => 'left'], + 'phpdoc_indent' => true, + 'phpdoc_no_useless_inheritdoc' => false, // @inheritDoc is meaningful for IDE/PHPStan in our interface impls. + 'phpdoc_scalar' => true, + 'phpdoc_single_line_var_spacing' => true, + 'phpdoc_trim' => true, + 'phpdoc_trim_consecutive_blank_line_separation' => true, + ]); diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b40d9df --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,131 @@ +# Changelog + +All notable changes to `initphp/events` are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Breaking changes + +- **`EventEmitter` now honours priority.** Listeners are dispatched in + ascending numeric priority order regardless of the order in which + they were registered. In 1.x they ran in registration order; the + `ksort()` in `EventEmitter::listeners()` was applied to the wrong + array level and effectively did nothing. Code that registered + listeners in ascending priority order (the obvious style) sees no + visible change. Code that relied on the old "registration order + wins" behaviour now sees a different invocation order — that + reliance was almost certainly unintentional, but it is a + user-visible behaviour change. +- **`Event::on()` default priority changed** from + `Event::PRIORITY_LOW` (200) to `Event::PRIORITY_NORMAL` (100). + Calls that omitted the third argument now run earlier relative to + listeners registered with an explicit `PRIORITY_LOW`. Pass an + explicit priority to restore the old positioning. +- **`EventEmitterInterface` gained `clearOnceListeners(?string)`.** + Anyone shipping their own implementation of this interface must add + the method. The bundled `EventEmitter` of course implements it. +- **PHPUnit 9.6 requires PHP `>= 7.3`** for the dev environment. + Runtime `require` is unchanged (`php: >=5.6`); CI lints the source + on PHP 5.6 / 7.0 / 7.1 / 7.2 so the runtime contract is verified, + but the unit-test suite only runs on PHP 7.3 and newer. + +### Added + +- **`Event::once($name, $callback, $priority)`** — register a one-shot + listener via the high-level dispatcher (previously only available + on `EventEmitter`). +- **`Event::off($name, $callback)`** — remove a specific listener + (alias-style; forwards to `EventEmitter::removeListener()`). +- **`Event::removeAllListeners(?string $name)`** — wipe one event, or + every event when called with no arguments. +- **`Event::clearDebug()`** — empty the debug log without dropping the + dispatcher. +- **`Event::getEmitter()`** — expose the underlying `EventEmitter` for + callers that need both high-level dispatch and low-level emit on the + same listener registry. +- **`Events::reset()`** — drop the shared singleton so the next facade + call rebuilds a fresh one. Intended for test setUp/tearDown and + long-running processes that need a clean slate. +- **`Events::setInstance(Event $event)`** — inject a pre-configured + dispatcher, e.g. one with simulate or debug already toggled on. +- **`Events::getInstance()` is now public** (was `protected`). +- **`EventEmitter::clearOnceListeners(?string $event)`** — drop + one-shot listeners without invoking them. Used by the high-level + `Event::trigger()` loop to keep the once-contract intact when the + chain is stopped by a `false` return. + +### Fixed + +- **Once-listeners registered through `Event` (or `Events`) now fire + at most once.** In 1.x, `Event::trigger()` pulled the listener list + via `EventEmitter::listeners()` (which includes one-shot listeners) + but never cleaned them up, so they fired on every trigger. This is + now handled in a `try/finally` block, so the once contract is + honoured even when a listener throws or when the chain is halted by + a `false` return. +- **Typo / Turkish-only docblock on `Event::trigger()`** — replaced + with English documentation consistent with the rest of the + ecosystem. +- **`Event.php` license header** — was a different format and pointed + at a non-existent license URL; aligned with the rest of the + package. + +### Internal / housekeeping + +- Removed the empty `Event::__destruct()` and the defensive `isset()` + checks it forced on the getters; properties are always initialised + by the constructor. +- Removed redundant `(bool)` casts after the matching `is_bool()` + guards in `setSimulate()` / `setDebugMode()`. +- Replaced `::class` arguments to `class_exists()` / `interface_exists()` + in `src/aliases.php` with plain string literals — functionally + equivalent, but removes the compile-time constant dependency. +- 69 unit tests, 110 assertions covering the priority contract, + short-circuit semantics, simulate / debug modes, once + removal, + fluent API, exception paths, the static facade lifecycle, and the + backwards-compatibility alias for `\InitPHP\EventEmitter\*`. + **Coverage: 100% lines / 100% methods / 100% classes** across `src/` + (excluding `aliases.php`, which is verified by a dedicated BC alias + test instead). +- **PHPStan static analysis at level 8**, clean across `src/` and + `tests/`. Configuration in `phpstan.neon.dist`; runtime contract + preserved (no scalar type-hints injected). `composer analyse` runs it. +- New CI workflow (`.github/workflows/ci.yml`) with four jobs: + - `tests` — PHP 7.3 / 7.4 / 8.0 / 8.1 / 8.2 / 8.3 / 8.4 — `composer + install` + `phpunit`. + - `static-analysis` — PHPStan level 8 on PHP 8.3. + - `code-style` — PHP-CS-Fixer dry-run on PHP 8.3. + - `lowest-php-syntax` — PHP 5.6 / 7.0 / 7.1 / 7.2 — `php -l` on every + source file and a Composer-free autoload smoke test, to keep the + `composer.json: php >= 5.6` contract honest. +- `composer.json` now declares `autoload-dev` for the test suite, + `keywords`, `support` URLs, a `scripts.test` entry, and + `config.sort-packages`. Runtime `require` is unchanged. + +## [1.0.2] + +### Added + +- Bundled the low-level `EventEmitter` primitive previously distributed + as the separate + [`initphp/event-emitter`](https://github.com/InitPHP/EventEmitter) + package, which is now deprecated. A class alias keeps the legacy + `\InitPHP\EventEmitter\*` fully-qualified names working. +- Minimum PHP requirement set to 5.6. + +### Notes + +- The standalone `initphp/event-emitter` package was retired; this + package declares a Composer `replace` for it. + +## [Earlier] + +Pre-1.0.2 history was not maintained in a `CHANGELOG.md`. Refer to the +Git log for individual fix commits (`git log src/Event.php`, +`git log src/Events.php`). + +[Unreleased]: https://github.com/InitPHP/Events/compare/v1.0.2...HEAD +[1.0.2]: https://github.com/InitPHP/Events/releases/tag/v1.0.2 diff --git a/README.md b/README.md index 6861c36..786b57b 100644 --- a/README.md +++ b/README.md @@ -1,104 +1,285 @@ -# Events - -It allows you to run functions from outside in different places within your software. It allows you to set up a similar structure known as a hook in the Wordpress ecosystem. - -Starting with **2.0**, this package also bundles the low-level `EventEmitter` primitive that used to ship as the separate [`initphp/event-emitter`](https://github.com/InitPHP/EventEmitter) package (now deprecated). See the [migration section](#migrating-from-initphpevent-emitter) below if you are coming from that package. - -[![Latest Stable Version](http://poser.pugx.org/initphp/events/v)](https://packagist.org/packages/initphp/events) [![Total Downloads](http://poser.pugx.org/initphp/events/downloads)](https://packagist.org/packages/initphp/events) [![Latest Unstable Version](http://poser.pugx.org/initphp/events/v/unstable)](https://packagist.org/packages/initphp/events) [![License](http://poser.pugx.org/initphp/events/license)](https://packagist.org/packages/initphp/events) [![PHP Version Require](http://poser.pugx.org/initphp/events/require/php)](https://packagist.org/packages/initphp/events) +# InitPHP Events + +A small, dependency-free event / hook library for PHP. Ships both a +high-level dispatcher with WordPress-style `do_action`-like semantics +(stop the chain when a listener returns `false`, simulate mode, debug +log) and a plain low-level `EventEmitter` you can instantiate and pass +around like any other object. + +[![CI](https://github.com/InitPHP/Events/actions/workflows/ci.yml/badge.svg)](https://github.com/InitPHP/Events/actions/workflows/ci.yml) +[![Latest Stable Version](https://poser.pugx.org/initphp/events/v)](https://packagist.org/packages/initphp/events) +[![Total Downloads](https://poser.pugx.org/initphp/events/downloads)](https://packagist.org/packages/initphp/events) +[![License](https://poser.pugx.org/initphp/events/license)](https://packagist.org/packages/initphp/events) +[![PHP Version Require](https://poser.pugx.org/initphp/events/require/php)](https://packagist.org/packages/initphp/events) + +> **Heads-up — v2.0 fixes a long-standing priority-ordering bug.** In +> 1.x, listeners ran in the order they were registered, *not* in the +> order their priorities asked for. v2.0 makes priority honour its +> name. If your 1.x code happened to register listeners in ascending +> priority order, the visible behaviour does not change; if it didn't, +> please re-read [Priorities and ordering](#priorities-and-ordering) +> and the [v2.0 changelog](./CHANGELOG.md). + +## At a glance + +- **`Events`** — a static facade backed by a lazily-built singleton. + Convenient for application-wide hooks where threading a dispatcher + through every layer is overkill. +- **`Event`** — the high-level dispatcher itself. Adds priority-ordered + dispatch with a "return `false` stops the chain" contract, plus + optional *simulate* and *debug* modes. Use this directly when you + want a dispatcher you own. +- **`EventEmitter`** — the low-level primitive. Plain `on` / `once` / + `emit` / `removeListener`. No simulate, no debug, no short-circuit. + Use this when you need a dispatcher with the smallest possible + surface — or when you are migrating from the deprecated + [`initphp/event-emitter`](https://github.com/InitPHP/EventEmitter) + package (the legacy `\InitPHP\EventEmitter\*` class names still work + via a BC alias). + +Everything is in a single PSR-4 namespace (`InitPHP\Events\`) and has +zero runtime dependencies. ## Requirements -- PHP 5.6 or higher +| Requirement | Version | +| --- | --- | +| PHP | `>= 5.6` (runtime); PHP `>= 7.3` to run the test suite (PHPUnit 9.6) | +| Extensions | none | +| Composer dependencies | none | ## Installation -``` +```bash composer require initphp/events ``` -## Usage +If you are coming from `initphp/event-emitter`, replace your dependency +— `initphp/events` declares a Composer `replace` for it and ships a +class alias so the old fully-qualified names still resolve. See +[Migration](#migrating-from-initphpevent-emitter) for details. -Call the `trigger()` method where the events will be added. Send event with `on()` method. +## Quick start ```php -require_once "vendor/autoload.php"; -use \InitPHP\Events\Events; +require __DIR__ . '/vendor/autoload.php'; -Events::on('helloTrigger', function(){ +use InitPHP\Events\Events; + +Events::on('helloTrigger', function (): void { echo 'Hello World' . PHP_EOL; }, 100); -Events::on('helloTrigger', function(){ +Events::on('helloTrigger', function (): void { echo 'Hi, World' . PHP_EOL; }, 99); Events::trigger('helloTrigger'); ``` -**Output :** +Output: ``` -Hi World +Hi, World Hello World ``` -### Use of Arguments - -```php -require_once "vendor/autoload.php"; -use \InitPHP\Events\Events; +The listener registered with priority **99** runs before the one with +priority **100** — lower numeric value means *runs first*. The three +named constants `Events::PRIORITY_HIGH` (10), `Events::PRIORITY_NORMAL` +(100), and `Events::PRIORITY_LOW` (200) exist for readability. -Events::on('helloTrigger', function($name, $myName){ - echo 'Hello ' . $name . '. I am ' . $myName . '.' . PHP_EOL; -}, 100); +### Passing arguments to listeners -Events::on('helloTrigger', function($name, $myName){ - echo 'Hi ' . $name . '. I am ' . $myName . '.' . PHP_EOL; +```php +Events::on('greet', function (string $name, string $me): void { + echo "Hi {$name}. I am {$me}." . PHP_EOL; }, 99); -Events::trigger('helloTrigger', 'World', 'John'); +Events::trigger('greet', 'World', 'John'); +// Hi World. I am John. ``` -**Output :** +### Stopping the chain + +A listener that returns boolean `false` halts the chain — subsequent +listeners are not invoked and `trigger()` itself returns `false`. This +is the same convention as WordPress's `apply_filters` short-circuit: + +```php +Events::on('save', function ($payload) { + if (!isValid($payload)) { + return false; // stops the chain + } +}, 10); + +Events::on('save', function ($payload): void { + persist($payload); // never runs if validation returned false +}, 20); +if (!Events::trigger('save', $payload)) { + // at least one listener vetoed the operation +} ``` -Hi World. I am John. -Hello World. I am John. + +## Working with `Event` directly + +If you prefer a dispatcher you instantiate and pass around (easier to +test, easier to scope, no global state), use `Event`: + +```php +use InitPHP\Events\Event; + +$dispatcher = new Event(); + +$dispatcher + ->on('user.registered', function ($user): void { + sendWelcomeEmail($user); + }) + ->on('user.registered', function ($user): void { + trackSignup($user); + }, Event::PRIORITY_LOW); // run last + +$dispatcher->trigger('user.registered', $user); ``` -## Low-level `EventEmitter` +`Event` and `Events` expose the same surface — `Events::on(...)` simply +forwards to a shared `Event` instance. -In addition to the high-level static `Events` facade, this package also exposes the underlying `EventEmitter` primitive for cases where you want a plain object you can instantiate and pass around: +## Working with the low-level `EventEmitter` + +For libraries that want the smallest possible surface, the underlying +emitter is also public: ```php -require_once "vendor/autoload.php"; use InitPHP\Events\EventEmitter; $emitter = new EventEmitter(); -$emitter->on('hello', function ($name) { - echo 'Hello ' . $name . '!' . PHP_EOL; +$emitter->on('hello', function (string $name): void { + echo "Hello {$name}!" . PHP_EOL; }, 99); -$emitter->on('hello', function ($name) { - echo 'Hi ' . $name . '!' . PHP_EOL; +$emitter->once('hello', function (string $name): void { + echo "Hi {$name}, this only fires once." . PHP_EOL; }, 10); +$emitter->emit('hello', ['World']); $emitter->emit('hello', ['World']); ``` -## Migrating from `initphp/event-emitter` +Differences from `Event`: + +- `emit()` returns `void` (no short-circuit on `false`). +- Listener arguments come in as a single array, not as varargs. +- No simulate or debug mode. +- No singleton. -The standalone [`initphp/event-emitter`](https://github.com/InitPHP/EventEmitter) package has been merged into this one starting with **2.0** and is now deprecated. +`EventEmitter` is what `Event` is built on. The same instance is +reachable via `$dispatcher->getEmitter()` if you ever need both +high-level dispatch and low-level emit on the same listener registry. -If your code currently uses `\InitPHP\EventEmitter\EventEmitter`, **no source changes are required** — this package ships a backwards-compatibility alias that keeps the old fully-qualified class name working. Just switch your dependency: +## Priorities and ordering + +``` +PRIORITY_HIGH = 10 ← runs first +PRIORITY_NORMAL = 100 +PRIORITY_LOW = 200 ← runs last +``` + +Rules: + +1. **Lower numeric priority runs first.** The names are about + *importance* (high-priority work runs early); the numbers are about + *position*. +2. Within the same priority, listeners run in **registration order + (FIFO)**. +3. Priority is global per-event — once-listeners and regular listeners + are merged into a single priority-sorted queue when an event fires. +4. Event names are matched **case-insensitively**: `on('User.Created')` + and `emit('user.created')` see the same listener. + +See [docs/04-priorities-and-ordering.md](docs/04-priorities-and-ordering.md) +for the full ordering contract, including how `once()` interacts with +the priority queue. + +## One-shot listeners, removal, and cleanup + +```php +$dispatcher->once('boot', $listener); // fires at most once +$dispatcher->off('boot', $listener); // removes a specific listener +$dispatcher->removeAllListeners('boot'); // wipes one event +$dispatcher->removeAllListeners(); // wipes every event +``` + +The `once()` contract is honoured even when the chain is stopped by a +`false` return — a one-shot listener that runs (or that *would have* +run, but for a short-circuit earlier in the queue) is dropped after +the trigger completes. See +[docs/05-once-and-removal.md](docs/05-once-and-removal.md). + +## Simulate mode and debug log + +`Event` (and therefore `Events`) carry two opt-in instrumentation +modes: + +```php +$dispatcher = new InitPHP\Events\Event(); +$dispatcher->setSimulate(true); // listeners are not invoked; trigger() still returns true +$dispatcher->setDebugMode(true); // each trigger() appends {start, end, event} to the log + +$dispatcher->trigger('checkout.complete', $order); + +$dispatcher->getDebug(); // [['start' => ..., 'end' => ..., 'event' => 'checkout.complete']] +$dispatcher->clearDebug(); // empty the log +``` + +See [docs/06-debug-and-simulate.md](docs/06-debug-and-simulate.md). + +## Public API + +| Class | Purpose | +| --- | --- | +| `InitPHP\Events\Events` | Static facade; thin shim over a shared `Event` instance. | +| `InitPHP\Events\Event` | High-level dispatcher (priority, short-circuit, simulate, debug). | +| `InitPHP\Events\EventEmitter` | Low-level primitive (`on` / `once` / `emit` / `removeListener` / `clearOnceListeners`). | +| `InitPHP\Events\EventEmitterInterface` | Contract that `EventEmitter` implements. | + +| Method | Class | Purpose | +| --- | --- | --- | +| `trigger(string $name, ...$arguments): bool` | `Event` · `Events` | Dispatch with priority + short-circuit semantics. | +| `on(string, callable, int $priority = PRIORITY_NORMAL): self` | `Event` · `Events` · `EventEmitter` | Register a listener. | +| `once(string, callable, int $priority = PRIORITY_NORMAL): self` | `Event` · `Events` · `EventEmitter` | Register a one-shot listener. | +| `off(string, callable): self` | `Event` · `Events` | Remove a specific listener (alias for `removeListener`). | +| `removeListener(string, callable): void` | `EventEmitter` | Remove a specific listener. | +| `removeAllListeners(?string = null): self/void` | `Event` · `Events` · `EventEmitter` | Wipe one event, or every event. | +| `emit(string, array $args = []): void` | `EventEmitter` | Low-level dispatch (no short-circuit). | +| `clearOnceListeners(?string = null): void` | `EventEmitter` | Drop one-shot listeners without invoking them. | +| `listeners(?string = null): array` | `EventEmitter` | Inspect the current listener registry. | +| `setSimulate(bool): self` / `getSimulate(): bool` | `Event` · `Events` | Toggle / inspect simulate mode. | +| `setDebugMode(bool): self` / `getDebugMode(): bool` | `Event` · `Events` | Toggle / inspect debug mode. | +| `getDebug(): array` / `clearDebug(): self` | `Event` · `Events` | Read / clear the debug log. | +| `getEmitter(): EventEmitter` | `Event` · `Events` | Access the backing emitter. | +| `getInstance(): Event` | `Events` | Return the shared dispatcher. | +| `setInstance(Event): void` | `Events` | Inject a pre-configured dispatcher. | +| `reset(): void` | `Events` | Drop the shared instance (tests, lifecycle resets). | + +Full signatures and exception behaviour: [docs/09-api-reference.md](docs/09-api-reference.md). + +## Migrating from `initphp/event-emitter` + +The standalone `initphp/event-emitter` package has been merged into +this one and is now deprecated. If your code currently uses +`\InitPHP\EventEmitter\EventEmitter`, **no source changes are +required** — this package ships a backwards-compatibility alias: ```diff - "initphp/event-emitter": "^1.0", + "initphp/events": "^2.0" ``` -(`initphp/events:^2.0` declares a Composer `replace` for `initphp/event-emitter`, so Composer will not install both side-by-side.) +Composer will not install both packages side-by-side because +`initphp/events:^2.0` declares a `replace` for `initphp/event-emitter`. When you next touch the code, prefer the new canonical namespace: @@ -110,11 +291,47 @@ use InitPHP\EventEmitter\EventEmitter; use InitPHP\Events\EventEmitter; ``` -The alias is intended as a transition aid and may be removed in a future major release. +The alias is intended as a transition aid and may be removed in a +future major release. **One important behaviour change** that comes +with v2.0: in 1.x, `EventEmitter::emit()` had a bug where the entire +listeners array was passed to `call_user_func_array` instead of each +individual listener, so emitted events silently fired no listeners at +all. That is fixed in 2.0 — if you had quietly broken `emit()` calls +in 1.x, they will now actually run their listeners. + +See [docs/07-migration-from-event-emitter.md](docs/07-migration-from-event-emitter.md) +for the full migration checklist. + +## Documentation + +The full guide lives under [`docs/`](./docs/README.md): + +1. [Getting started](docs/01-getting-started.md) +2. [The `Events` facade](docs/02-events-facade.md) +3. [Using `EventEmitter` directly](docs/03-event-emitter.md) +4. [Priorities and ordering](docs/04-priorities-and-ordering.md) +5. [Once-listeners, removal, and cleanup](docs/05-once-and-removal.md) +6. [Debug and simulate modes](docs/06-debug-and-simulate.md) +7. [Migrating from `initphp/event-emitter`](docs/07-migration-from-event-emitter.md) +8. [Recipes (plugin systems, request lifecycle, WordPress-style hooks)](docs/08-recipes.md) +9. [API reference](docs/09-api-reference.md) + +## Development + +```bash +composer install +composer test # PHPUnit +``` + +CI runs the test suite across PHP 7.3 – 8.4 and also lints the source +on PHP 5.6 / 7.0 / 7.1 / 7.2 so the `php >= 5.6` library contract stays +honest. -### Notable change: `emit()` bug fix +## Contributing & Security -The 1.x line of `initphp/event-emitter` shipped with a bug in `EventEmitter::emit()` where the listeners array (rather than each individual listener) was passed to `call_user_func_array`, causing emitted events to silently fail. This is fixed in `initphp/events:^2.0`. If you relied on `EventEmitter::emit()` and saw no listeners firing, you should expect them to fire now — review any code that depended on the broken behavior. +- [Contributing guidelines](https://github.com/InitPHP/.github/blob/main/CONTRIBUTING.md) +- [Code of Conduct](https://github.com/InitPHP/.github/blob/main/CODE_OF_CONDUCT.md) +- [Security policy](https://github.com/InitPHP/.github/blob/main/SECURITY.md) ## Credits @@ -122,4 +339,4 @@ The 1.x line of `initphp/event-emitter` shipped with a bug in `EventEmitter::emi ## License -Copyright © 2022 [MIT License](./LICENSE) +Released under the [MIT License](./LICENSE). diff --git a/composer.json b/composer.json index 16a80b2..c78586e 100644 --- a/composer.json +++ b/composer.json @@ -1,16 +1,17 @@ { "name": "initphp/events", - "description": "Events (Hook) Library — now bundles the EventEmitter primitive previously distributed as initphp/event-emitter.", + "description": "Events (Hook) library — bundles the EventEmitter primitive previously distributed as initphp/event-emitter.", "type": "library", "license": "MIT", - "autoload": { - "psr-4": { - "InitPHP\\Events\\": "src/" - }, - "files": [ - "src/aliases.php" - ] - }, + "keywords": [ + "events", + "event-dispatcher", + "event-emitter", + "hooks", + "pubsub", + "observer", + "initphp" + ], "authors": [ { "name": "Muhammet ŞAFAK", @@ -19,11 +20,43 @@ "homepage": "https://www.muhammetsafak.com.tr" } ], - "minimum-stability": "stable", + "support": { + "source": "https://github.com/InitPHP/Events", + "issues": "https://github.com/InitPHP/Events/issues" + }, "require": { "php": ">=5.6" }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.0", + "phpstan/phpstan": "^1.12", + "phpunit/phpunit": "^9.6" + }, + "autoload": { + "psr-4": { + "InitPHP\\Events\\": "src/" + }, + "files": [ + "src/aliases.php" + ] + }, + "autoload-dev": { + "psr-4": { + "InitPHP\\Events\\Tests\\": "tests/" + } + }, "replace": { "initphp/event-emitter": "*" - } + }, + "scripts": { + "test": "phpunit", + "analyse": "phpstan analyse --no-progress", + "cs:check": "php-cs-fixer fix --dry-run --diff", + "cs:fix": "php-cs-fixer fix" + }, + "config": { + "sort-packages": true + }, + "minimum-stability": "stable", + "prefer-stable": true } diff --git a/docs/01-getting-started.md b/docs/01-getting-started.md new file mode 100644 index 0000000..af260cf --- /dev/null +++ b/docs/01-getting-started.md @@ -0,0 +1,114 @@ +# Getting started + +## Install + +```bash +composer require initphp/events +``` + +The package has zero runtime dependencies and supports PHP `>= 5.6`. + +If you are migrating from +[`initphp/event-emitter`](https://github.com/InitPHP/EventEmitter), +just swap the dependency — `initphp/events` declares a Composer +`replace` for it and provides a BC alias for the legacy +`\InitPHP\EventEmitter\*` class names. See +[chapter 7](07-migration-from-event-emitter.md). + +## The three classes you will touch + +| Class | Use when… | +| --- | --- | +| `InitPHP\Events\Events` | You want application-wide hooks and the convenience of a static facade outweighs the cost of shared global state. | +| `InitPHP\Events\Event` | You want a dispatcher you instantiate, own, and pass around. Easier to scope and to test. | +| `InitPHP\Events\EventEmitter` | You want the smallest possible primitive — plain `on` / `once` / `emit`, no simulate, no debug, no short-circuit on `false`. Library authors usually want this. | + +`Events` is a thin shim that forwards every static call to a single +lazily-built `Event` instance. `Event`, in turn, is built on top of +`EventEmitter`. So the three classes are layers, not alternatives — +pick the highest one that fits and you get everything below it for +free. + +## Smallest possible example + +```php +require __DIR__ . '/vendor/autoload.php'; + +use InitPHP\Events\Events; + +Events::on('helloTrigger', function (): void { + echo 'Hello World' . PHP_EOL; +}, 100); + +Events::on('helloTrigger', function (): void { + echo 'Hi, World' . PHP_EOL; +}, 99); + +Events::trigger('helloTrigger'); +``` + +Output: + +``` +Hi, World +Hello World +``` + +Two things to notice here: + +1. **Lower numeric priority runs first.** The listener registered with + priority `99` runs before the one with priority `100`. The + chapter on [Priorities and ordering](04-priorities-and-ordering.md) + explains why and how to use the named constants (`PRIORITY_HIGH`, + `PRIORITY_NORMAL`, `PRIORITY_LOW`). +2. **Listeners receive whatever you pass to `trigger()`.** The + signature is `trigger(string $name, ...$arguments)` — the + `$arguments` are forwarded to each listener via + `call_user_func_array`. + +```php +Events::on('greet', function (string $name, string $me): void { + echo "Hi {$name}. I am {$me}." . PHP_EOL; +}, 99); + +Events::trigger('greet', 'World', 'John'); +// Hi World. I am John. +``` + +## Owning your own dispatcher + +If you would rather not lean on the static facade — for example, +because you want one dispatcher per request, or because a hard-to-test +global is a smell — use `Event` directly: + +```php +use InitPHP\Events\Event; + +$dispatcher = new Event(); + +$dispatcher + ->on('user.registered', function ($user): void { + sendWelcomeEmail($user); + }) + ->on('user.registered', function ($user): void { + trackSignup($user); + }, Event::PRIORITY_LOW); + +$dispatcher->trigger('user.registered', $user); +``` + +`Event` and `Events` expose the same surface. The decision is purely +about scope and testability, not about features. See +[chapter 2](02-events-facade.md) for a side-by-side discussion. + +## What's next + +- [Chapter 2 — The `Events` facade](02-events-facade.md) covers the + static API in full, including the new `reset()` and + `setInstance()` test hooks. +- [Chapter 3 — Using `EventEmitter` directly](03-event-emitter.md) + covers the low-level primitive — useful when you do not want or + need short-circuit / simulate / debug. +- [Chapter 4 — Priorities and ordering](04-priorities-and-ordering.md) + is the contract you should read before writing code that depends on + *when* listeners fire. diff --git a/docs/02-events-facade.md b/docs/02-events-facade.md new file mode 100644 index 0000000..d55b63a --- /dev/null +++ b/docs/02-events-facade.md @@ -0,0 +1,184 @@ +# The `Events` facade + +`InitPHP\Events\Events` is a thin static facade over a single, +lazily-built `Event` instance. It exists because most applications +have a small number of cross-cutting hooks — request start, user +registered, payment captured — and threading a dispatcher through +every layer to publish those is more ceremony than the use-case +warrants. + +If you do want to thread a dispatcher around (it's the more +testable choice), skip this chapter and read +[Chapter 3](03-event-emitter.md) or use `Event` directly. + +## How the facade works + +```php +public static function __callStatic($name, $arguments) +{ + return self::getInstance()->{$name}(...$arguments); +} +``` + +That's the entire body. Every static call goes to the same `Event` +instance, which is created on first use: + +```php +public static function getInstance() +{ + if (!isset(self::$Instance)) { + self::$Instance = new Event(); + } + return self::$Instance; +} +``` + +So `Events::on(...)` and `(new Event())->on(...)` behave identically +— the facade just removes the `new Event()` boilerplate from the +caller. + +## Public API + +| Method | Forwards to | Notes | +| --- | --- | --- | +| `Events::trigger(string $name, ...$args)` | `Event::trigger()` | Returns `bool`. Returns `false` if a listener returned `false`. | +| `Events::on(string, callable, int $priority = PRIORITY_NORMAL)` | `Event::on()` | Returns the `Event` instance (chainable on the dispatcher itself). | +| `Events::once(string, callable, int $priority = PRIORITY_NORMAL)` | `Event::once()` | One-shot listener. | +| `Events::off(string, callable)` | `Event::off()` | Remove a specific listener. | +| `Events::removeAllListeners(?string = null)` | `Event::removeAllListeners()` | Wipe one event, or every event. | +| `Events::setSimulate(bool)` / `Events::getSimulate()` | `Event` getters/setters | Toggle simulate mode. | +| `Events::setDebugMode(bool)` / `Events::getDebugMode()` / `Events::getDebug()` / `Events::clearDebug()` | `Event` debug methods | Opt-in debug log. | +| `Events::getEmitter()` | `Event::getEmitter()` | Access the underlying `EventEmitter`. | +| `Events::getInstance(): Event` | — | Return the shared dispatcher. | +| `Events::setInstance(Event)` | — | Inject a pre-configured dispatcher. | +| `Events::reset()` | — | Drop the shared instance. | + +## Constants + +```php +Events::PRIORITY_HIGH // 10 — runs first +Events::PRIORITY_NORMAL // 100 — default +Events::PRIORITY_LOW // 200 — runs last +``` + +These mirror `Event::PRIORITY_*`. Use the names for readability — +nothing else in the package looks at the numeric values. + +## Worked example + +```php +require __DIR__ . '/vendor/autoload.php'; + +use InitPHP\Events\Events; + +// One-shot bootstrap notification. +Events::once('app.boot', function (): void { + echo "Booted at " . date('c') . PHP_EOL; +}); + +// A "filter" listener that can short-circuit. +Events::on('save.user', function (array $user) { + if ($user['email'] === '') { + return false; // halts the chain — subsequent listeners do not run + } +}, Events::PRIORITY_HIGH); + +// A "side effect" listener that runs after validation. +Events::on('save.user', function (array $user): void { + audit_log($user); +}, Events::PRIORITY_LOW); + +Events::trigger('app.boot'); + +if (Events::trigger('save.user', $user)) { + persist($user); +} else { + flash('user validation failed'); +} +``` + +## When `Events` is the right choice + +- You publish a small set of well-known hooks for plugins or extensions + to attach to and the publisher / subscriber are deliberately + decoupled. +- You want the call site to read as `Events::on(...)` rather than + `$this->dispatcher->on(...)` to make the cross-cutting nature of + the hook obvious. +- You only need *one* dispatcher — there is no scenario where two + parts of the application would want independent listener + registries. + +## When `Events` is the wrong choice + +- You have several dispatchers (one per tenant, one per + long-running worker, one per HTTP request in a long-lived + process). The facade serves one shared instance, period. +- You test heavily and shared global state hurts. The + [Testing](#testing-against-the-facade) section below describes how + to keep tests honest, but the friction is real. +- You're a library author. Don't make your consumers reach into a + facade — accept an `Event` or `EventEmitterInterface` in your + constructor and let the caller decide. + +## Testing against the facade + +`Events::reset()` empties the singleton so the next call rebuilds a +fresh one. Use it in `setUp()` (and ideally `tearDown()` too, to keep +test failures from leaking state into subsequent suites): + +```php +final class MyTest extends \PHPUnit\Framework\TestCase +{ + protected function setUp(): void + { + Events::reset(); + } + + protected function tearDown(): void + { + Events::reset(); + } + + public function test_something_publishes_an_event(): void + { + $captured = null; + Events::on('e', function ($payload) use (&$captured): void { + $captured = $payload; + }); + + publishSomething(['k' => 'v']); + + $this->assertSame(['k' => 'v'], $captured); + } +} +``` + +`Events::setInstance($event)` lets you inject a pre-configured +dispatcher. Useful when a test needs simulate or debug toggled on +from the very first call: + +```php +$debugDispatcher = (new Event())->setDebugMode(true); +Events::setInstance($debugDispatcher); + +doSomethingThatTriggers(); + +$log = Events::getDebug(); // every trigger() landed here +``` + +## Lifecycle in long-running workers + +In long-running processes (queue workers, HTTP servers, scheduled +daemons) the facade keeps state between requests / jobs. That is +usually a bug. Either: + +- Call `Events::reset()` at the boundary of each request / job, or +- Stop using the facade and pass an `Event` instance per request / + job (see [Chapter 1](01-getting-started.md#owning-your-own-dispatcher)). + +## Next + +- [Chapter 3 — Using `EventEmitter` directly](03-event-emitter.md) +- [Chapter 4 — Priorities and ordering](04-priorities-and-ordering.md) +- [Chapter 6 — Debug and simulate modes](06-debug-and-simulate.md) diff --git a/docs/03-event-emitter.md b/docs/03-event-emitter.md new file mode 100644 index 0000000..f2b7ecb --- /dev/null +++ b/docs/03-event-emitter.md @@ -0,0 +1,166 @@ +# Using `EventEmitter` directly + +`InitPHP\Events\EventEmitter` is the low-level primitive on top of +which `Event` (and therefore `Events`) is built. It implements +`InitPHP\Events\EventEmitterInterface` and provides the smallest +possible event-dispatcher surface: `on`, `once`, `emit`, +`removeListener`, `removeAllListeners`, `listeners`, +`clearOnceListeners`. + +Reach for `EventEmitter` when: + +- You are writing a library and do not want to make your consumers + depend on the higher-level `Event` (with its simulate / debug + state). +- You do not need the "return `false` stops the chain" contract. +- You want a plain object you can pass around (no static facade, no + shared singleton). + +If any of those bullet points don't fit, you probably want `Event` +(see [chapter 2](02-events-facade.md)). + +## Public surface + +```php +namespace InitPHP\Events; + +interface EventEmitterInterface +{ + public function on($event, $listener, $priority = 100); + public function once($event, $listener, $priority = 100); + public function removeListener($event, $listener); + public function removeAllListeners($event = null); + public function listeners($event = null); + public function emit($event, $arguments = []); + public function clearOnceListeners($event = null); +} +``` + +| Method | Returns | Behaviour | +| --- | --- | --- | +| `on($event, $listener, $priority = 100)` | `$this` | Register a regular listener. Repeated `on()` calls for the same listener register it multiple times. | +| `once($event, $listener, $priority = 100)` | `$this` | Register a one-shot listener. Dropped after the next `emit()` (or `clearOnceListeners()`). | +| `emit($event, array $arguments = [])` | `void` | Invoke every registered listener for `$event`, in ascending priority order. `$arguments` are unpacked to each listener via `call_user_func_array`. Once-listeners are dropped after the call. **Return values are ignored.** | +| `removeListener($event, $listener)` | `void` | Remove every occurrence of `$listener` for `$event` (across both the regular and one-shot registries, and across all priorities for which it was registered). | +| `removeAllListeners(?string $event = null)` | `void` | Wipe a single event's listeners, or every listener for every event when `$event` is `null`. | +| `listeners(?string $event = null)` | `array` | Return the listeners for `$event` (or for every event, if `$event` is null), already merged across regular + once registries and sorted by priority. | +| `clearOnceListeners(?string $event = null)` | `void` | Drop one-shot listeners *without invoking them*. Useful for higher-level dispatchers that run listeners themselves but still need to honour the once-contract. | + +All methods throw `\InvalidArgumentException` for type-incorrect +arguments (non-string event names, non-callable listeners, non-int +priorities, non-array `emit()` arguments). + +## Worked example + +```php +require __DIR__ . '/vendor/autoload.php'; + +use InitPHP\Events\EventEmitter; + +$bus = new EventEmitter(); + +$bus->on('user.created', function (array $user): void { + audit_log('user created: ' . $user['id']); +}, EventEmitter::PRIORITY_HIGH ?? 10); // PRIORITY_HIGH lives on Event, not EventEmitter; literal works too. + +$bus->once('user.created', function (array $user): void { + send_welcome_email($user); +}, 100); + +$bus->emit('user.created', [['id' => 42, 'email' => 'x@example.com']]); +$bus->emit('user.created', [['id' => 43, 'email' => 'y@example.com']]); +// audit_log fires both times; send_welcome_email fires only for id=42. +``` + +> The named priority constants (`PRIORITY_HIGH`, `PRIORITY_NORMAL`, +> `PRIORITY_LOW`) live on `Event`, not on `EventEmitter`. The default +> priority on the emitter's `on()` / `once()` is the integer `100` +> (= `PRIORITY_NORMAL`). If you want the names at the low level, use +> `Event::PRIORITY_HIGH` and friends — the integer values match. + +## Argument shape: `emit` vs `trigger` + +This is the most common pitfall when moving between the two layers: + +```php +// EventEmitter: arguments are an *array*, unpacked to each listener. +$bus->emit('e', ['a', 'b']); +// → each listener called as $listener('a', 'b') + +// Event / Events: arguments are *variadic*. +$dispatcher->trigger('e', 'a', 'b'); +// → each listener called as $listener('a', 'b') +``` + +Internally `Event::trigger()` builds an arguments array from its +varargs and forwards them to each listener — the listener call shape +is identical, only the dispatcher-side signature differs. + +## What `EventEmitter` does *not* do + +- **No short-circuit on `false`.** `emit()` is `void`; listener return + values are discarded. If you want short-circuit semantics, use + `Event::trigger()` (or call `listeners()` yourself and iterate). +- **No simulate or debug mode.** Those live one layer up. +- **No singleton.** Construct as many emitters as you need. + +## Event names are case-insensitive + +Event names are lower-cased before being stored or looked up: + +```php +$bus->on('User.Created', $listener); +$bus->emit('user.created', [...]); // listener fires +$bus->emit('USER.CREATED', [...]); // listener fires +``` + +This is enforced by `strtolower()` in every method that accepts an +event name. Keep this in mind if you are namespacing events with a +case-sensitive convention — pick lower-case (or some other folded +form) up front to avoid surprises. + +## Inspecting the registry + +`listeners()` returns the listeners as a plain `array`, +already merged across both the regular and once registries and sorted +by priority. Useful for diagnostics: + +```php +foreach ($bus->listeners('user.created') as $listener) { + var_dump($listener); +} +``` + +`listeners()` with no argument returns *every* listener across *every* +event, which is mostly useful for "is anything listening at all?" +checks. + +## Removing listeners + +```php +$cb = function (): void { /* ... */ }; + +$bus->on('e', $cb); +$bus->removeListener('e', $cb); // drops just this listener +$bus->removeAllListeners('e'); // drops every listener for 'e' +$bus->removeAllListeners(); // drops every listener for every event +``` + +Two subtleties to know: + +1. **Listener identity uses strict `array_search`** under the hood — + `Closure`s must be the *same instance*. A textually identical + `function () { ... }` you constructed twice will not match. +2. **`removeListener` removes from every priority slot** in which the + listener appears. If you registered the same listener at multiple + priorities, one call removes all of them. This is rarely the + shape callers want; if it matters for your use case, register the + listener only once. + +See [chapter 5](05-once-and-removal.md) for the full removal contract. + +## Next + +- [Chapter 4 — Priorities and ordering](04-priorities-and-ordering.md) +- [Chapter 5 — Once-listeners, removal, and cleanup](05-once-and-removal.md) +- [Chapter 7 — Migrating from `initphp/event-emitter`](07-migration-from-event-emitter.md) diff --git a/docs/04-priorities-and-ordering.md b/docs/04-priorities-and-ordering.md new file mode 100644 index 0000000..553b3c4 --- /dev/null +++ b/docs/04-priorities-and-ordering.md @@ -0,0 +1,177 @@ +# Priorities and ordering + +This chapter is the contract: read it once and you know exactly what +will happen when `trigger()` (or `emit()`) runs. + +## The three rules + +1. **Lower numeric priority runs first.** Priority `10` fires before + priority `100`, which fires before priority `200`. The names of + the three convenience constants reflect *importance*, not + *position*: + + ```php + Event::PRIORITY_HIGH = 10 // most important → runs first + Event::PRIORITY_NORMAL = 100 // default + Event::PRIORITY_LOW = 200 // least important → runs last + ``` + +2. **Within the same priority, listeners run in registration order + (FIFO).** Add listener A then listener B at priority 50, and A + runs before B. + +3. **Registration order does not matter across priorities.** Add a + priority-200 listener first and a priority-10 listener second: + the priority-10 listener still runs first. + +That's the whole contract. + +## Concrete example + +```php +$dispatcher = new Event(); + +$dispatcher + ->on('boot', function () { echo 'low' . PHP_EOL; }, Event::PRIORITY_LOW) + ->on('boot', function () { echo 'high-a' . PHP_EOL; }, Event::PRIORITY_HIGH) + ->on('boot', function () { echo 'normal' . PHP_EOL; }) // default = NORMAL + ->on('boot', function () { echo 'high-b' . PHP_EOL; }, Event::PRIORITY_HIGH); + +$dispatcher->trigger('boot'); +``` + +Output: + +``` +high-a +high-b +normal +low +``` + +- `high-a` runs before `high-b` because rule 2 (FIFO inside a + priority) keeps their registration order. +- `normal` runs after both `high-*` because it has a larger numeric + priority (rule 1). +- `low` runs last because it has the largest numeric priority. + +## How `once()` interacts with priorities + +`once()` listeners are stored in a *separate* internal registry from +regular `on()` listeners, but `trigger()` / `emit()` merge them into a +single, priority-sorted queue at dispatch time. So: + +```php +$dispatcher + ->on('e', function () { echo 'reg-100' . PHP_EOL; }, 100) + ->once('e', function () { echo 'once-50' . PHP_EOL; }, 50) + ->on('e', function () { echo 'reg-200' . PHP_EOL; }, 200); + +$dispatcher->trigger('e'); +$dispatcher->trigger('e'); +``` + +First trigger: + +``` +once-50 +reg-100 +reg-200 +``` + +Second trigger: + +``` +reg-100 +reg-200 +``` + +The `once-50` listener is dropped after the first trigger, even +though it had the lowest priority and ran *before* the regular +listeners. The once-contract is "fire at most once", regardless of +priority. + +## How the priority sort changed in v2.0 + +In 1.x, `EventEmitter::listeners()` had a long-standing bug: it +applied `ksort()` to the wrong array level (the inner per-priority +listener list, which is already numerically indexed, instead of the +outer priority map). The visible effect was that listeners ran in +*registration order*, not priority order. v2.0 fixes that — see the +[v2.0 changelog](../CHANGELOG.md) and +[chapter 7](07-migration-from-event-emitter.md) if you are upgrading +and want the full background. + +If your 1.x code happened to register listeners in ascending priority +order (which is the obvious style), the visible behaviour does not +change in v2.0. If you registered them in some other order and were +relying on the broken behaviour, you'll see a different invocation +order now. + +## The "return false stops the chain" rule + +`Event::trigger()` (and therefore `Events::trigger()`) has one more +ordering contract on top of the priority sort: + +> If any listener returns boolean `false`, the chain is *halted*. +> Subsequent listeners are not invoked, and `trigger()` itself +> returns `false`. + +This is the same convention as WordPress's `apply_filters`. Note that +the listener has to return the literal `false`, not a falsy value — +returning `null` (the implicit return of every `function () { ... }` +that does not say otherwise) or `0` does *not* halt the chain. + +```php +$dispatcher + ->on('chain', function () { return 'continue'; }) + ->on('chain', function () { return false; }) // halts here + ->on('chain', function () { echo 'never runs'; }); + +$result = $dispatcher->trigger('chain'); +// $result === false +// the third listener never executed +``` + +`EventEmitter::emit()` does **not** have this contract — it returns +`void` and ignores return values. If you want short-circuit +semantics, use `Event::trigger()` (or call `listeners()` and loop +yourself). + +## Case-insensitive event names + +Event names are folded to lower-case before storage and lookup: + +```php +$dispatcher->on('User.Created', $listener); +$dispatcher->trigger('user.created', ...); // listener fires +$dispatcher->trigger('USER.CREATED', ...); // listener fires too +``` + +Pick one casing convention in your codebase (lower-case is the +obvious choice) so reading the source still tells you which event +is which. + +## Removing listeners and priorities + +A few non-obvious cases: + +- `removeAllListeners('e')` drops both regular *and* one-shot + listeners for `'e'`. +- `removeListener('e', $cb)` removes every occurrence of `$cb` + across every priority slot in which it was registered (rare, but + it can bite you if you registered the same callback at multiple + priorities deliberately). +- `clearOnceListeners('e')` drops one-shot listeners for `'e'` + *without invoking them*. This is mostly an internal helper used by + `Event::trigger()` to honour the once-contract when the chain + short-circuits — most application code will never call it + directly. + +See [chapter 5](05-once-and-removal.md) for the full removal contract. + +## Next + +- [Chapter 5 — Once-listeners, removal, and cleanup](05-once-and-removal.md) +- [Chapter 6 — Debug and simulate modes](06-debug-and-simulate.md) +- [Chapter 9 — API reference](09-api-reference.md) diff --git a/docs/05-once-and-removal.md b/docs/05-once-and-removal.md new file mode 100644 index 0000000..64ea33f --- /dev/null +++ b/docs/05-once-and-removal.md @@ -0,0 +1,162 @@ +# Once-listeners, removal, and cleanup + +This chapter covers the four operations that let you control a +listener's lifetime: `once`, `off` (or `removeListener` on the +emitter), `removeAllListeners`, and `clearOnceListeners`. + +## `once()` — fire at most one time + +```php +$dispatcher->once('tick', function (): void { + echo "first tick" . PHP_EOL; +}); + +$dispatcher->trigger('tick'); // "first tick" +$dispatcher->trigger('tick'); // (nothing) +$dispatcher->trigger('tick'); // (nothing) +``` + +The listener is dropped from the registry after the first `trigger()` +(or `emit()`) of the event. + +### Two non-obvious guarantees + +1. **The once-contract survives a short-circuit.** If a listener + *earlier* in the chain returns `false` and halts dispatch, the + one-shot listeners that did not get a chance to run are still + dropped. The contract is "fire at most once", not "fire exactly + once". This is implemented via a `try/finally` block in + `Event::trigger()` that calls + `EventEmitter::clearOnceListeners()` regardless of how the loop + exited. + + ```php + $dispatcher + ->on('halt', function () { return false; }, 10) + ->once('halt', function () { echo "never runs"; }, 20); + + $dispatcher->trigger('halt'); // halted at priority 10 + $dispatcher->trigger('halt'); // once-listener still gone — does not fire + ``` + +2. **The once-contract survives a listener exception.** Same + mechanism — `try/finally` ensures the once registry is cleaned up + even if an earlier listener throws. The exception still + propagates; you just don't end up with a dangling once-listener. + +### `once()` vs `on()` semantics aside from "how many times" + +Everything else — priority, FIFO within priority, case-insensitive +event names, argument unpacking — is identical to `on()`. See +[chapter 4](04-priorities-and-ordering.md). + +## `off()` — remove a specific listener (high-level) + +```php +$cb = function (): void { /* ... */ }; + +$dispatcher->on('e', $cb); +$dispatcher->off('e', $cb); +$dispatcher->trigger('e'); // $cb does not fire +``` + +On `Event` (and therefore `Events`), `off()` is a thin alias that +forwards to `EventEmitter::removeListener()`. The shorter name reads +naturally as a counterpart to `on()`. + +### Identity, not equality + +Listener identity is determined by **strict `array_search`** — the +same callable instance, not a textually-equivalent rebuild: + +```php +$dispatcher->on('e', function () { echo 'a'; }); +$dispatcher->off('e', function () { echo 'a'; }); // does NOT remove anything +``` + +Two `Closure`s built from identical source code are two different +PHP objects. If you need to remove a closure, hold a reference to +the original. + +For methods, both `[$obj, 'method']` arrays and first-class callable +syntax (`$obj->method(...)`) produce a new callable each time you +write the expression. Build it once, pass the same value to `on()` +and `off()`: + +```php +$listener = [$service, 'onTick']; + +$dispatcher->on('tick', $listener); +$dispatcher->off('tick', $listener); // matches — same array +``` + +### Removes from every priority slot + +If you registered the same listener at multiple priorities (rare but +possible), one `off()` call removes *all* of them. This is rarely the +shape callers want; if you need per-priority removal, register the +listener only once. + +## `removeAllListeners()` — wipe one event or every event + +```php +$dispatcher->removeAllListeners('save.user'); // drops every listener for this event +$dispatcher->removeAllListeners(); // drops every listener for every event +``` + +This is the right tool for: + +- **Tests** that need to reset state between cases. (Though + `Events::reset()` is even cleaner for the facade — it drops the + whole singleton.) +- **Long-running workers** that need to reset listener state at the + start of each job, where building a fresh dispatcher per job is + not an option. +- **Plugin systems** that unload a plugin and want to scrub every + listener it registered. (Though "remember exactly which listeners + I registered" is a better pattern when feasible.) + +`removeAllListeners()` drops both regular and one-shot listeners for +the targeted event(s). + +## `clearOnceListeners()` — low-level cleanup of one-shots + +```php +$bus = new EventEmitter(); + +$bus->once('e', $listener); +$bus->clearOnceListeners('e'); // drops $listener without invoking it +$bus->clearOnceListeners(); // drops every one-shot for every event +``` + +This is an `EventEmitter`-level primitive (not exposed on `Event` / +`Events`, because applications rarely need it). It exists so that +higher-level dispatchers like `Event::trigger()` — which run +listeners themselves and need to handle short-circuit / exception +paths — can honour the once-contract without going through `emit()`. + +If you find yourself reaching for `clearOnceListeners()` from +application code, you are probably either: + +- Building your own dispatcher on top of `EventEmitter` (totally + fine, that is exactly what `Event` does), or +- Doing something the package's higher-level layers already do for + you. Double-check before adding the call. + +## Summary table + +| Goal | High-level (`Event` / `Events`) | Low-level (`EventEmitter`) | +| --- | --- | --- | +| Register a regular listener | `on()` | `on()` | +| Register a one-shot listener | `once()` | `once()` | +| Remove a specific listener | `off()` | `removeListener()` | +| Remove every listener for an event | `removeAllListeners('e')` | `removeAllListeners('e')` | +| Remove every listener for every event | `removeAllListeners()` | `removeAllListeners()` | +| Drop one-shots without invoking them | (handled internally by `trigger()`) | `clearOnceListeners()` | +| Reset the static facade entirely | `Events::reset()` | — | + +## Next + +- [Chapter 6 — Debug and simulate modes](06-debug-and-simulate.md) +- [Chapter 8 — Recipes](08-recipes.md) — plugin systems, request + lifecycle hooks, WordPress-style hooks. diff --git a/docs/06-debug-and-simulate.md b/docs/06-debug-and-simulate.md new file mode 100644 index 0000000..c3b15e0 --- /dev/null +++ b/docs/06-debug-and-simulate.md @@ -0,0 +1,188 @@ +# Debug and simulate modes + +`Event` (and therefore `Events`) carries two opt-in instrumentation +modes that are not part of the low-level `EventEmitter`: + +- **Simulate mode** — `trigger()` does not actually invoke the + listeners, but still walks the priority queue and still returns + `true`. Useful for dry-runs and "what would happen if I triggered + this?" diagnostics. +- **Debug mode** — every `trigger()` invocation appends a record to + an internal log. Useful for timing measurements, tracing dispatch + order, or asserting in tests that a particular event was triggered. + +Both modes are off by default and can be flipped independently. The +two modes compose: with both on, the dispatcher walks the queue, +skips listener invocation, and still records each event in the debug +log. + +## Simulate mode + +```php +$dispatcher = new Event(); + +$dispatcher->setSimulate(true); +$dispatcher->on('e', function () { + echo "called!" . PHP_EOL; // never prints while simulate is on + return false; // even this is ignored +}); + +$result = $dispatcher->trigger('e'); +// nothing prints, $result === true +``` + +Key properties: + +- `trigger()` always returns `true` in simulate mode, regardless of + what the listeners *would have* returned. +- One-shot listeners registered via `once()` are still dropped after + the trigger (the once-contract is "fire at most once" — simulate + mode counts as a "fire"). +- The debug log (if debug mode is also on) still records the event. + +When you turn simulate mode off, the dispatcher returns to running +listeners normally — no listener registry state is touched by +toggling the flag. + +### Common uses + +- **Dry-run of destructive operations.** Hook a `dispatch.payment` + event up to real bank-call listeners, but trigger it under + `setSimulate(true)` in a development environment so the UI can + exercise the full path without moving any money. +- **Disabling all hooks temporarily.** Setting simulate is faster + than calling `removeAllListeners()` because it preserves the + registry — flip it back off and everything still works. +- **Performance baselines.** Compare the cost of "dispatcher walk + without listeners" vs "dispatcher walk with listeners" by toggling + simulate. + +## Debug mode + +```php +$dispatcher = new Event(); +$dispatcher->setDebugMode(true); + +$dispatcher->on('a', function (): void { /* ... */ }); +$dispatcher->on('b', function (): void { /* ... */ }); + +$dispatcher->trigger('a'); +$dispatcher->trigger('b'); +$dispatcher->trigger('a'); + +$log = $dispatcher->getDebug(); +// $log === [ +// ['start' => , 'end' => , 'event' => 'a'], +// ['start' => , 'end' => , 'event' => 'b'], +// ['start' => , 'end' => , 'event' => 'a'], +// ]; +``` + +Properties: + +- One entry is appended per **listener invocation**, not per + `trigger()` call. An event with three listeners produces three + entries (all with the same `event` name). +- `start` is captured with `microtime(true)` *before* the listener + call. +- `end` is captured with `microtime(true)` *after* the listener + returns (or throws — see below). +- The log persists for the lifetime of the dispatcher (or until + `clearDebug()`). It is **not** automatically capped — long-running + workers with debug mode on should call `clearDebug()` periodically. + +### Reading the log + +```php +$totalEventCalls = count($log); + +$byEvent = []; +foreach ($log as $entry) { + $byEvent[$entry['event']][] = $entry['end'] - $entry['start']; +} + +foreach ($byEvent as $event => $durations) { + $avg = array_sum($durations) / count($durations); + echo sprintf("%-30s avg=%.4fs (n=%d)\n", $event, $avg, count($durations)); +} +``` + +### Clearing the log + +```php +$dispatcher->clearDebug(); // returns $this, so chainable +``` + +Useful between test cases, between worker jobs, or whenever the log +has served its purpose and you do not want it eating memory. + +## Composing simulate and debug + +The two modes are independent. The most useful combination is +"simulate **on**, debug **on**" — that gives you the cost-free +equivalent of "what would happen if I ran this": + +```php +$dispatcher + ->setSimulate(true) + ->setDebugMode(true); + +$dispatcher->trigger('payment.captured', $order); + +$dispatcher->getDebug(); +// [['start' => ..., 'end' => ..., 'event' => 'payment.captured']] +// — registry was walked, but no listener was actually invoked. +``` + +In tests this is also a clean way to assert "this code path triggers +event X" without needing to register a sentinel listener: + +```php +public function test_checkout_publishes_a_payment_event(): void +{ + $dispatcher = (new Event())->setSimulate(true)->setDebugMode(true); + Events::setInstance($dispatcher); + + handleCheckout($order); + + $events = array_column($dispatcher->getDebug(), 'event'); + $this->assertContains('payment.captured', $events); +} +``` + +## Reading dispatcher state via `__debugInfo` + +`Event` defines `__debugInfo()` so that `var_dump($dispatcher)` +returns a concise snapshot rather than the raw internal arrays: + +```php +var_dump(new Event()); + +// object(InitPHP\Events\Event)#1 (3) { +// ["simulate"]=> bool(false) +// ["debugMode"]=> bool(false) +// ["debugData"]=> array(0) { } +// } +``` + +This is purely a `var_dump` convenience — the listener registry +itself is reachable via `getEmitter()->listeners()` if you need to +introspect it. + +## What `EventEmitter` does not have + +Neither simulate nor debug mode exists on `EventEmitter`. If you are +working at the low level and want comparable behaviour: + +- For dry-runs, walk `$emitter->listeners($event)` manually and skip + the `call_user_func_array` step. +- For tracing, wrap each registered listener in a logging adapter + before `on()`'ing it. + +If both are something you need often, just step up to `Event` — that +is exactly the gap it fills. + +## Next + +- [Chapter 7 — Migrating from `initphp/event-emitter`](07-migration-from-event-emitter.md) +- [Chapter 8 — Recipes](08-recipes.md) diff --git a/docs/07-migration-from-event-emitter.md b/docs/07-migration-from-event-emitter.md new file mode 100644 index 0000000..fc95951 --- /dev/null +++ b/docs/07-migration-from-event-emitter.md @@ -0,0 +1,186 @@ +# Migrating from `initphp/event-emitter` + +The standalone +[`initphp/event-emitter`](https://github.com/InitPHP/EventEmitter) +package was merged into `initphp/events` and is now deprecated. + +This chapter is a checklist for the migration: + +1. Swap the Composer dependency. +2. Decide whether to rely on the BC alias or move to the canonical + namespace. +3. Re-read your `emit()` and priority assumptions — both had bugs in + 1.x that are fixed in 2.0. + +## 1. Swap the dependency + +```diff +- "initphp/event-emitter": "^1.0", ++ "initphp/events": "^2.0" +``` + +```bash +composer update +``` + +Composer will not install both packages side-by-side because +`initphp/events:^2.0` declares a `replace` for +`initphp/event-emitter`. If you don't see `initphp/event-emitter` +disappear from `vendor/`, you have something else (a transitive +dependency, an explicit `require-dev`) holding it in place — search +your lockfile. + +## 2. Decide on the namespace + +The package ships a backwards-compatibility alias for the legacy +fully-qualified names: + +```php +// src/aliases.php in initphp/events (composer files autoload): +class_alias( + \InitPHP\Events\EventEmitter::class, + 'InitPHP\\EventEmitter\\EventEmitter' +); +class_alias( + \InitPHP\Events\EventEmitterInterface::class, + 'InitPHP\\EventEmitter\\EventEmitterInterface' +); +``` + +So **no source change is required**: + +```php +// Existing 1.x code — still works in 2.0: +use InitPHP\EventEmitter\EventEmitter; + +$bus = new EventEmitter(); +$bus->on('e', $listener); +$bus->emit('e', [$payload]); +``` + +When you next touch the code, prefer the new canonical names: + +```php +// Recommended for new / touched code: +use InitPHP\Events\EventEmitter; +``` + +The alias is a transition aid. It may be removed in a future major +version, so do not put it off forever — but you do not need a +big-bang rename either. + +## 3. Re-read your `emit()` and priority assumptions + +Two long-standing 1.x bugs are fixed in 2.0. Both are *silent* +behaviour changes: nothing about your code is wrong; the dispatcher +just does what it was always supposed to do. + +### Fix 1 — `emit()` actually invokes listeners + +The 1.x `EventEmitter::emit()` had a bug where the **entire +listeners array** (not each individual listener) was passed to +`call_user_func_array()`. The result was that emitted events +silently fired no listeners at all. + +```php +// 1.x reality: +$bus->emit('e', [$payload]); +// → call_user_func_array($listeners, [$payload]) +// → "Array to callable" warning, nothing fires. + +// 2.x: +$bus->emit('e', [$payload]); +// → for each $listener: call_user_func_array($listener, [$payload]) +// → listeners actually fire. +``` + +If any of your 1.x `emit()` calls had no observable effect, they +*will* now have observable effects. Audit: + +- Listeners that mutate state — they now actually mutate. +- Listeners that send notifications / log / call external services + — they now actually do. +- Tests that asserted "this listener wasn't called" — they may now + fail. The 1.x behaviour was the bug; the test was passing for the + wrong reason. + +### Fix 2 — listeners run in priority order + +The 1.x `EventEmitter::listeners()` had a bug where `ksort()` was +applied to the wrong array level: the inner per-priority listener +list (which is already numerically indexed `[0, 1, 2, ...]`) instead +of the outer priority map. The effect was that listeners ran in +**registration order**, not in priority order. + +```php +// 1.x reality: +$bus->on('boot', $a, 100); // registered first → ran first +$bus->on('boot', $b, 10); // registered second → ran second +$bus->emit('boot'); +// Output: a, then b + +// 2.x: +$bus->on('boot', $a, 100); +$bus->on('boot', $b, 10); +$bus->emit('boot'); +// Output: b, then a (priority 10 < 100) +``` + +If your 1.x code happened to register listeners in ascending +priority order (the obvious style), the visible behaviour does not +change in 2.0. If you registered them in some other order, you'll +see the order flip. + +See [chapter 4](04-priorities-and-ordering.md) for the full +ordering contract you can now rely on. + +## 4. Things that did *not* change + +- The argument-shape of `emit()` is unchanged: it still takes + `(string $event, array $arguments = [])`. Each listener is invoked + with the array unpacked. +- The fluent return of `on()` / `once()` is unchanged (they return + the emitter). +- Event-name case-folding is unchanged (`strtolower` on the way in + and out). +- `removeListener()` still returns `void`, not `$this`. (The + high-level `Event::off()` returns `$this`, but the underlying + emitter does not — that part of the interface is unchanged.) + +## 5. New surface available in 2.0 + +If you choose to take advantage of it after migrating: + +- **`Events::reset()`** and **`Events::setInstance(Event)`** — + finally usable test hooks for the static facade. +- **`Event::once()` / `Event::off()` / `Event::removeAllListeners()`** + — the high-level dispatcher now exposes the same lifecycle + controls that the low-level emitter has had all along. +- **`EventEmitter::clearOnceListeners()`** — drop one-shot listeners + without invoking them. New on the interface; you only need to + worry about this if you ship your own `EventEmitterInterface` + implementation. + +Full list in the [v2.0 changelog](../CHANGELOG.md). + +## Quick checklist + +- [ ] Updated `composer.json` to depend on `initphp/events:^2.0`. +- [ ] Ran `composer update` and confirmed the old + `initphp/event-emitter` is gone from `vendor/`. +- [ ] Searched the codebase for `\InitPHP\EventEmitter\` and decided + whether to rename now or leave the alias in place for later. +- [ ] Looked at every `EventEmitter::emit()` call and confirmed the + listeners actually firing is the desired behaviour (1.x had + them silently no-op). +- [ ] Looked at every `on()` registration that did not follow + ascending-priority order and confirmed the priority-sorted + execution is the desired behaviour. +- [ ] If you ship your own `EventEmitterInterface` implementation, + added a `clearOnceListeners()` method to it. + +## Next + +- [Chapter 4 — Priorities and ordering](04-priorities-and-ordering.md) +- [Chapter 8 — Recipes](08-recipes.md) +- [Chapter 9 — API reference](09-api-reference.md) diff --git a/docs/08-recipes.md b/docs/08-recipes.md new file mode 100644 index 0000000..07b6f5a --- /dev/null +++ b/docs/08-recipes.md @@ -0,0 +1,295 @@ +# Recipes + +Concrete patterns built on top of the package. Each one is a worked +example — copy it, adapt the event names to your domain, ship. + +- [WordPress-style action hooks](#wordpress-style-action-hooks) +- [WordPress-style filter hooks (value-passing)](#wordpress-style-filter-hooks-value-passing) +- [Request lifecycle hooks in an HTTP application](#request-lifecycle-hooks-in-an-http-application) +- [A tiny plugin system](#a-tiny-plugin-system) +- [Per-request dispatcher in a long-running worker](#per-request-dispatcher-in-a-long-running-worker) +- [Testing code that publishes events](#testing-code-that-publishes-events) + +## WordPress-style action hooks + +"Do this when X happens" — listeners are notified, no return value +matters. + +```php +use InitPHP\Events\Events; + +// publish +function do_action(string $hook, ...$args): void +{ + Events::trigger($hook, ...$args); +} + +// subscribe +function add_action(string $hook, callable $listener, int $priority = Events::PRIORITY_NORMAL): void +{ + Events::on($hook, $listener, $priority); +} + +// usage +add_action('user.registered', function (array $user): void { + sendWelcomeEmail($user); +}); + +do_action('user.registered', ['id' => 42, 'email' => 'x@example.com']); +``` + +The fact that `Events::trigger()` returns a `bool` is irrelevant +here — actions don't care about return values. Side-effect-only +listeners can return `null` (the default). + +## WordPress-style filter hooks (value-passing) + +"Let every interested listener massage this value before I use it" — +the value threads through the listeners. The package's +short-circuit-on-false contract is *not* quite the same as +`apply_filters`, so this recipe uses an explicit reference. + +```php +use InitPHP\Events\Events; + +function apply_filters(string $hook, $value, ...$args) +{ + // listeners receive (&$value, ...$args); they mutate $value. + Events::trigger($hook, ...array_merge([&$value], $args)); + return $value; +} + +Events::on('the.title', function (string &$title): void { + $title = ucfirst($title); +}); + +Events::on('the.title', function (string &$title): void { + $title = '› ' . $title; +}, Events::PRIORITY_LOW); + +echo apply_filters('the.title', 'hello world'); +// › Hello world +``` + +If you'd rather use the package's "return false stops the chain" +contract for a veto-style filter: + +```php +$value = 'untrusted input'; +if (!Events::trigger('validate.input', $value)) { + throw new \DomainException('input failed validation'); +} +``` + +## Request lifecycle hooks in an HTTP application + +```php +use InitPHP\Events\Event; + +final class App +{ + private Event $events; + + public function __construct() + { + $this->events = new Event(); + } + + public function on(string $hook, callable $listener, int $priority = Event::PRIORITY_NORMAL): self + { + $this->events->on($hook, $listener, $priority); + return $this; + } + + public function handle(Request $request): Response + { + $this->events->trigger('request.received', $request); + + try { + $response = $this->dispatch($request); + } catch (\Throwable $e) { + $this->events->trigger('request.exception', $request, $e); + throw $e; + } + + $this->events->trigger('response.ready', $request, $response); + + return $response; + } +} + +$app = new App(); + +$app->on('request.received', function (Request $req): void { + Log::info('inbound', ['method' => $req->method(), 'path' => $req->path()]); +}, Event::PRIORITY_HIGH); + +$app->on('response.ready', function (Request $req, Response $res): void { + Metrics::record('http.duration', $req->elapsedMs(), ['status' => $res->status()]); +}, Event::PRIORITY_LOW); + +$response = $app->handle($request); +``` + +`App` owns its own `Event` dispatcher rather than reaching into the +static facade — important for testability and for long-running +servers that handle many requests in one process. + +## A tiny plugin system + +```php +use InitPHP\Events\Event; + +interface Plugin +{ + public function register(Event $events): void; +} + +final class PluginHost +{ + /** @var Plugin[] */ + private array $plugins = []; + + public function __construct(private Event $events) {} + + public function install(Plugin $plugin): void + { + $plugin->register($this->events); + $this->plugins[] = $plugin; + } +} + +// A plugin +final class AuditLogPlugin implements Plugin +{ + public function register(Event $events): void + { + $events->on('user.created', [$this, 'logCreate']); + $events->on('user.deleted', [$this, 'logDelete']); + $events->on('user.modified', [$this, 'logModify'], Event::PRIORITY_LOW); + } + + public function logCreate(array $user): void { /* ... */ } + public function logDelete(array $user): void { /* ... */ } + public function logModify(array $user): void { /* ... */ } +} + +// Wire it up +$events = new Event(); +$host = new PluginHost($events); +$host->install(new AuditLogPlugin()); + +// Now every part of the app that triggers user.* events automatically +// hits the plugin's listeners. +$events->trigger('user.created', ['id' => 1, 'email' => 'a@example.com']); +``` + +If you want plugins to be removable, have the `register()` method +return a list of `[$event, $listener]` pairs and store them on the +host, then call `$events->off(...)` for each on uninstall. + +## Per-request dispatcher in a long-running worker + +In a long-lived process (queue worker, HTTP server with persistent +PHP) the static `Events` facade keeps listeners between requests. +That is almost always a bug. Two ways to avoid it: + +### Option A — reset the facade at the boundary + +```php +while ($job = $queue->reserve()) { + \InitPHP\Events\Events::reset(); + handle($job); +} +``` + +Simple, but relies on every part of your code remembering to +register its listeners on every iteration. Fragile. + +### Option B — own a dispatcher, scope it to the request + +```php +while ($job = $queue->reserve()) { + $events = new \InitPHP\Events\Event(); + + // Register the per-request listeners + JobHandlers::register($events, $job); + + handle($job, $events); +} +``` + +Robust, and the boundary is explicit. Recommended. + +## Testing code that publishes events + +### Pattern 1 — capture with a closure + +```php +public function test_user_registration_publishes_event(): void +{ + Events::reset(); + + $captured = null; + Events::on('user.registered', function (array $user) use (&$captured): void { + $captured = $user; + }); + + registerUser(['email' => 'x@example.com']); + + $this->assertNotNull($captured); + $this->assertSame('x@example.com', $captured['email']); +} +``` + +### Pattern 2 — assert via the debug log + +When you have many events to assert on and don't want to register a +listener for each: + +```php +public function test_checkout_flow_publishes_the_expected_sequence(): void +{ + $dispatcher = (new Event())->setDebugMode(true); + Events::setInstance($dispatcher); + + runCheckout($order); + + $names = array_column(Events::getDebug(), 'event'); + $this->assertSame( + ['cart.locked', 'payment.captured', 'order.confirmed'], + $names + ); +} +``` + +### Pattern 3 — use simulate mode for "what would happen" + +```php +public function test_dry_run_destructive_listener_is_not_executed(): void +{ + $dispatcher = (new Event())->setSimulate(true)->setDebugMode(true); + Events::setInstance($dispatcher); + + $fired = false; + Events::on('payment.capture', function () use (&$fired): void { + $fired = true; + }); + + Events::trigger('payment.capture', $order); + + $this->assertFalse($fired, 'simulate mode must not invoke listeners'); + $this->assertCount(1, Events::getDebug(), 'but the event was still recorded'); +} +``` + +### Always call `Events::reset()` in `setUp()` / `tearDown()` + +Otherwise listeners from one test bleed into the next. This is the +single most common test-stability bug when working with a static +event facade. + +## Next + +- [Chapter 9 — API reference](09-api-reference.md) — for the dry + details once you know what shape you want your code to take. diff --git a/docs/09-api-reference.md b/docs/09-api-reference.md new file mode 100644 index 0000000..1f76f67 --- /dev/null +++ b/docs/09-api-reference.md @@ -0,0 +1,339 @@ +# API reference + +Every public symbol in the package, by class. + +## `InitPHP\Events\Events` (static facade) + +Forwards every static call to a shared `Event` instance via +`__callStatic`. See [chapter 2](02-events-facade.md) for context. + +### Constants + +```php +const PRIORITY_LOW = 200; +const PRIORITY_NORMAL = 100; +const PRIORITY_HIGH = 10; +``` + +These mirror `Event::PRIORITY_*`. Lower numeric value runs first. + +### Lifecycle + +```php +public static function getInstance(): Event +``` + +Return the shared `Event` instance, building one on first call. + +```php +public static function setInstance(Event $event): void +``` + +Replace the shared instance. Useful for injecting a pre-configured +dispatcher (e.g. with simulate or debug already enabled) before +the rest of your code touches the facade. + +```php +public static function reset(): void +``` + +Drop the shared instance so the next facade call rebuilds a fresh +one. Call this in test setUp/tearDown and at the boundary of each +request / job in long-running workers. + +### Forwarded methods + +Every method that exists on `Event` is reachable through `Events` +via `__callStatic`. The most common ones are listed here for +discoverability, but the source of truth is `Event` (next section). + +```php +public static function trigger(string $name, ...$arguments): bool +public static function on(string $name, callable $callback, int $priority = Event::PRIORITY_NORMAL): Event +public static function once(string $name, callable $callback, int $priority = Event::PRIORITY_NORMAL): Event +public static function off(string $name, callable $callback): Event +public static function removeAllListeners(?string $name = null): Event +public static function setSimulate(bool $simulate = false): Event +public static function getSimulate(): bool +public static function setDebugMode(bool $debugMode = false): Event +public static function getDebugMode(): bool +public static function getDebug(): array +public static function clearDebug(): Event +public static function getEmitter(): EventEmitter +``` + +--- + +## `InitPHP\Events\Event` (high-level dispatcher) + +Built on top of `EventEmitter`. Adds priority-ordered dispatch with +"return `false` stops the chain" semantics, plus opt-in simulate +and debug modes. + +### Constants + +```php +const PRIORITY_LOW = 200; +const PRIORITY_NORMAL = 100; +const PRIORITY_HIGH = 10; +``` + +### Constructor + +```php +public function __construct() +``` + +Builds an `EventEmitter` internally. No arguments. + +### Listener registration + +```php +public function on(string $name, callable $callback, int $priority = self::PRIORITY_NORMAL): self +``` + +Register a regular listener. Returns `$this` for chaining. + +- **Throws** `\InvalidArgumentException` if `$name` is not a string, + `$callback` is not callable, or `$priority` is not an integer. +- The underlying `EventEmitter` folds `$name` to lower-case. + +```php +public function once(string $name, callable $callback, int $priority = self::PRIORITY_NORMAL): self +``` + +Register a one-shot listener — dropped after the next `trigger()` +of `$name`. Same exception contract as `on()`. + +```php +public function off(string $name, callable $callback): self +``` + +Remove a specific listener (regular or one-shot) for `$name`. +Forwards to `EventEmitter::removeListener()`. Listener identity is +strict `array_search` — same callable instance, not a +textually-equivalent rebuild. + +```php +public function removeAllListeners(?string $name = null): self +``` + +Drop every listener for `$name`, or every listener for every event +when `$name` is null. Throws `\InvalidArgumentException` if `$name` +is neither a string nor null. + +### Dispatch + +```php +public function trigger(string $name, ...$arguments): bool +``` + +Dispatch `$name` — invoke every registered listener in ascending +priority order, forwarding `...$arguments` to each via +`call_user_func_array`. + +Returns: + +- `true` if every listener ran to completion without returning + `false`, +- `false` if any listener returned boolean `false` (the chain is + halted at that point — subsequent listeners are not invoked). + +One-shot listeners are dropped after the dispatch, regardless of +whether the chain was halted or a listener threw. The cleanup is in +a `try/finally` block. + +Throws `\InvalidArgumentException` if `$name` is not a string. + +### Simulate mode + +```php +public function setSimulate(bool $simulate = false): self +public function getSimulate(): bool +``` + +When `true`, `trigger()` walks the priority queue but does not +invoke the listeners — and always returns `true`. Setter throws +`\InvalidArgumentException` if `$simulate` is not a boolean. + +### Debug mode + +```php +public function setDebugMode(bool $debugMode = false): self +public function getDebugMode(): bool +public function getDebug(): array +public function clearDebug(): self +``` + +When `setDebugMode(true)`, every listener invocation appends an +entry to an internal log: + +```php +['start' => float, 'end' => float, 'event' => string] +``` + +`start` is captured via `microtime(true)` *before* the listener +runs; `end` is captured *after* it returns (or throws — though the +throw propagates). + +`getDebug()` returns the log as a plain array. +`clearDebug()` empties the log and returns `$this`. + +The log is not capped automatically — long-running workers with +debug mode on should call `clearDebug()` periodically. + +### Backing emitter + +```php +public function getEmitter(): EventEmitter +``` + +Returns the underlying `EventEmitter`. Useful when you need both +the high-level `trigger()` and the low-level `emit()` / +`clearOnceListeners()` on the same listener registry. + +### Magic + +```php +public function __debugInfo(): array +``` + +Returns `['simulate' => bool, 'debugMode' => bool, 'debugData' => array]` +— a `var_dump`-friendly snapshot. Does not include the raw listener +registry; reach into `getEmitter()->listeners()` if you need that. + +--- + +## `InitPHP\Events\EventEmitter` (low-level primitive) + +Plain `on` / `once` / `emit` / `removeListener` event emitter. +Implements `EventEmitterInterface`. No simulate, no debug, no +short-circuit on `false`. + +### Public methods + +```php +public function on(string $event, callable $listener, int $priority = 100): self +public function once(string $event, callable $listener, int $priority = 100): self +``` + +Register a listener. `on()` is permanent; `once()` is one-shot +(dropped after the next `emit()` or `clearOnceListeners()`). + +Both throw `\InvalidArgumentException` for type-incorrect arguments +(non-string event, non-callable listener, non-int priority). +Both return `$this`. + +```php +public function emit(string $event, array $arguments = []): void +``` + +Dispatch `$event`. Listeners are invoked in ascending priority +order; within a priority, FIFO by registration order. `$arguments` +is unpacked and forwarded to each listener via `call_user_func_array`. + +Once-listeners for `$event` are dropped *after* the dispatch. + +Throws `\InvalidArgumentException` if `$event` is not a string or +`$arguments` is not an array. + +```php +public function removeListener(string $event, callable $listener): void +``` + +Remove every occurrence of `$listener` for `$event`, across both +the regular and one-shot registries and across every priority slot. + +Throws `\InvalidArgumentException` for non-string event / non-callable +listener. + +```php +public function removeAllListeners(?string $event = null): void +``` + +Wipe one event's listeners (both regular and one-shot), or every +listener for every event when `$event` is null. + +```php +public function clearOnceListeners(?string $event = null): void +``` + +Drop one-shot listeners *without invoking them*. Used internally by +higher-level dispatchers (`Event::trigger()`) that run listeners +themselves and need to honour the once-contract. + +```php +public function listeners(?string $event = null): array +``` + +Return the listeners for `$event` (or for every event, if null), +already merged across both registries and sorted by priority. +Useful for diagnostics. + +### Default priority + +The integer literal `100` — equivalent to `Event::PRIORITY_NORMAL`. +The named constants live on `Event`, not on `EventEmitter`. + +--- + +## `InitPHP\Events\EventEmitterInterface` + +The contract `EventEmitter` implements. If you ship your own +implementation, your class must define: + +```php +public function on($event, $listener, $priority = 100); +public function once($event, $listener, $priority = 100); +public function removeListener($event, $listener); +public function removeAllListeners($event = null); +public function listeners($event = null); +public function emit($event, $arguments = []); +public function clearOnceListeners($event = null); +``` + +`clearOnceListeners()` was added in 2.0 — it is a BC break for +anyone shipping their own implementation. + +--- + +## Backwards-compatibility aliases + +`src/aliases.php` (autoloaded by Composer via the `files` autoload +entry) registers: + +```php +class_alias('InitPHP\\Events\\EventEmitter', 'InitPHP\\EventEmitter\\EventEmitter'); +class_alias('InitPHP\\Events\\EventEmitterInterface', 'InitPHP\\EventEmitter\\EventEmitterInterface'); +``` + +So code written against the deprecated `initphp/event-emitter` +package keeps working unchanged. See +[chapter 7](07-migration-from-event-emitter.md). + +--- + +## Exceptions + +Every type-validation error in the package raises +`\InvalidArgumentException`. The package does not define its own +exception types. + +| Method | Raises `\InvalidArgumentException` when | +| --- | --- | +| `Event::trigger()` | `$name` is not a string. | +| `Event::on()` / `Event::once()` | `$name` is not a string, `$callback` is not callable, or `$priority` is not an integer. | +| `Event::off()` | `$name` is not a string or `$callback` is not callable. | +| `Event::removeAllListeners()` | `$name` is neither a string nor null. | +| `Event::setSimulate()` | `$simulate` is not a boolean. | +| `Event::setDebugMode()` | `$debugMode` is not a boolean. | +| `EventEmitter::on()` / `EventEmitter::once()` | Non-string event, non-callable listener, or non-int priority. | +| `EventEmitter::emit()` | Non-string event, or `$arguments` is not an array. | +| `EventEmitter::removeListener()` | Non-string event or non-callable listener. | +| `EventEmitter::removeAllListeners()` / `clearOnceListeners()` | `$event` is neither a string nor null. | +| `EventEmitter::listeners()` | `$event` is neither a string nor null. | + +The package does not throw any other exception type on its own — +exceptions propagated *from listeners* of course pass through +untouched. (`trigger()` still cleans up once-listeners on the way +out via `try/finally`.) diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..2423744 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,37 @@ +# InitPHP Events — Documentation + +This is the long-form documentation for +[`initphp/events`](https://github.com/InitPHP/Events). The package +README covers the same material at a glance; these chapters go into +the details. + +If you are new to the package, read in order: + +1. [Getting started](01-getting-started.md) — install it, run the + smallest possible example, learn the three classes you will touch. +2. [The `Events` facade](02-events-facade.md) — the static + application-wide dispatcher, when to use it, when not to. +3. [Using `EventEmitter` directly](03-event-emitter.md) — the + low-level primitive: instantiate, `on` / `once` / `emit`, no + global state. +4. [Priorities and ordering](04-priorities-and-ordering.md) — the + full contract for what runs when, including how `once()` interacts + with the priority queue and the case-insensitive event-name rule. +5. [Once-listeners, removal, and cleanup](05-once-and-removal.md) — + `once`, `off`, `removeAllListeners`, `clearOnceListeners`, and the + guarantees each one gives you. +6. [Debug and simulate modes](06-debug-and-simulate.md) — opt-in + instrumentation for dry-runs and timing measurements. +7. [Migrating from `initphp/event-emitter`](07-migration-from-event-emitter.md) + — the BC alias, the `emit()` bug fix, the v2.0 priority-sort fix, + what to change and what to leave alone. +8. [Recipes](08-recipes.md) — concrete patterns: plugin systems, + request lifecycle hooks, WordPress-style action / filter hooks, + testing strategies. +9. [API reference](09-api-reference.md) — every public method, every + exception, every constant. + +Spotted a gap or something that doesn't match what the code actually +does? Please [open an issue](https://github.com/InitPHP/Events/issues) +— the documentation tries to match the code character for character, +and divergence is a bug we want to hear about. diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..2cae8a5 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,23 @@ +parameters: + level: 8 + paths: + - src + - tests + excludePaths: + # Bootstrap shim — verified by BackwardsCompatibilityAliasTest + # at runtime. PHPStan would otherwise need to inspect the + # alias targets and complain about them not existing in the + # legacy namespace, which is the whole point of the shim. + - src/aliases.php + treatPhpDocTypesAsCertain: false + reportUnmatchedIgnoredErrors: true + ignoreErrors: + # We intentionally pass values of the wrong type into the public + # API so we can assert that the runtime guards fire. PHPStan is + # right that the call would never compile in well-typed code, + # but that is the whole point of the test. Restricted to *Test.php + # under tests/ so the pattern cannot mask genuine bugs elsewhere. + - + message: '#^Parameter \#\d+ \$\w+ of method InitPHP\\Events\\.+ expects .+, .+ given\.$#' + paths: + - tests/*Test.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..79538c2 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,25 @@ + + + + + tests + + + + + src + + + src/aliases.php + + + diff --git a/src/Event.php b/src/Event.php index 05abbd9..5e62041 100644 --- a/src/Event.php +++ b/src/Event.php @@ -1,23 +1,42 @@ - * @copyright Copyright © 2022 InitPHP - * @license http://initphp.github.io/license.txt MIT - * @version 1.0.2 + * @copyright Copyright © 2022 Muhammet ŞAFAK + * @license ./LICENSE MIT * @link https://www.muhammetsafak.com.tr */ namespace InitPHP\Events; -use function microtime; +use InvalidArgumentException; + use function call_user_func_array; use function is_bool; use function is_string; +use function microtime; +/** + * High-level event dispatcher with WordPress-style hook semantics. + * + * Wraps an EventEmitter and adds three features that the low-level + * emitter does not concern itself with: + * + * - Priority-ordered dispatch via {@see Event::trigger()} that *stops* + * the chain when a listener returns boolean false (this is the + * "hook" behaviour the WordPress action API also exposes). + * - Simulate mode: registered listeners are not actually invoked when + * trigger() runs. Useful for dry-runs. + * - Debug mode: each trigger() invocation appends a record (start, end, + * event name) to an internal log, retrievable via getDebug(). + * + * Listener registration and removal (on/once/off/removeAllListeners) + * are forwarded to the underlying EventEmitter. + */ final class Event { const PRIORITY_LOW = 200; @@ -27,6 +46,7 @@ final class Event /** @var EventEmitter */ protected $emitter; + /** @var list */ protected $debug = []; /** @var bool */ @@ -40,15 +60,10 @@ public function __construct() $this->emitter = new EventEmitter(); } - public function __destruct() - { - unset($this->emitter, $this->simulate, $this->debug, $this->debugMode); - } - public function __debugInfo() { return [ - 'simulate' => $this->simulate, + 'simulate' => $this->simulate, 'debugMode' => $this->debugMode, 'debugData' => $this->debug, ]; @@ -59,19 +74,20 @@ public function __debugInfo() */ public function getSimulate() { - return isset($this->simulate) ? $this->simulate : false; + return $this->simulate; } /** * @param bool $simulate * @return $this + * @throws InvalidArgumentException */ public function setSimulate($simulate = false) { - if(!is_bool($simulate)){ - throw new \InvalidArgumentException('$simulate must be a boolean.'); + if (!is_bool($simulate)) { + throw new InvalidArgumentException('$simulate must be a boolean.'); } - $this->simulate = (bool)$simulate; + $this->simulate = $simulate; return $this; } @@ -80,74 +96,167 @@ public function setSimulate($simulate = false) */ public function getDebugMode() { - return isset($this->debugMode) ? $this->debugMode : false; + return $this->debugMode; } /** * @param bool $debugMode * @return $this + * @throws InvalidArgumentException */ public function setDebugMode($debugMode = false) { - if(!is_bool($debugMode)){ - throw new \InvalidArgumentException('$debugMode must be a boolean.'); + if (!is_bool($debugMode)) { + throw new InvalidArgumentException('$debugMode must be a boolean.'); } $this->debugMode = $debugMode; return $this; } /** - * @return array + * @return array */ public function getDebug() { - if(!isset($this->debug)){ - $this->debug = []; - } return $this->debug; } /** - * Eventlerin çalıştırılacağı/kanca atılacak bölgeyi tanımlar. + * Clears any previously collected debug records. + * + * @return $this + */ + public function clearDebug() + { + $this->debug = []; + return $this; + } + + /** + * Dispatches an event by invoking every registered listener in + * ascending priority order. + * + * Behaviour notes: + * - When a listener returns boolean false the chain is *stopped* and + * trigger() itself returns false. Subsequent listeners are not + * invoked. This mirrors WordPress's apply_filters short-circuit. + * - One-shot listeners registered with {@see Event::once()} are + * always discarded after this call, even if the chain was halted + * by a false return — the once contract is "fire at most once". + * - In simulate mode the listener bodies are not executed and + * trigger() always returns true. * * @param string $name - * @param ...$arguments - * @return bool + * @param mixed ...$arguments + * @return bool false if a listener short-circuited the chain, true otherwise. + * @throws InvalidArgumentException */ public function trigger($name, ...$arguments) { - if(!is_string($name)){ - throw new \InvalidArgumentException('$name must be a string.'); + if (!is_string($name)) { + throw new InvalidArgumentException('$name must be a string.'); } - $events = $this->emitter->listeners($name); - foreach ($events as $event) { - $start = $this->debugMode ? microtime(true) : 0; - $res = ($this->simulate === FALSE) ? call_user_func_array($event, $arguments) : true; - if ($this->debugMode) { - $this->debug[] = [ - 'start' => $start, - 'end' => microtime(true), - 'event' => $name, - ]; - } - if ($res === FALSE) { - return false; + + $listeners = $this->emitter->listeners($name); + + try { + foreach ($listeners as $listener) { + $start = $this->debugMode ? microtime(true) : 0; + $result = $this->simulate + ? true + : call_user_func_array($listener, $arguments); + + if ($this->debugMode) { + $this->debug[] = [ + 'start' => $start, + 'end' => microtime(true), + 'event' => $name, + ]; + } + + if ($result === false) { + return false; + } } + } finally { + // Honour the once() contract even when the chain is stopped + // by a false return or a listener exception. + $this->emitter->clearOnceListeners($name); } + return true; } /** - * Bellirtilen bölgede çalıştırılacak işlemi tanımlar. + * Registers a listener for the given event. * * @param string $name * @param callable $callback - * @param int $priority - * @return void + * @param int $priority Lower numeric value runs first. + * Use the PRIORITY_HIGH / PRIORITY_NORMAL / + * PRIORITY_LOW constants for readability. + * @return $this + * @throws InvalidArgumentException */ - public function on($name, $callback, $priority = self::PRIORITY_LOW) + public function on($name, $callback, $priority = self::PRIORITY_NORMAL) { $this->emitter->on($name, $callback, $priority); + return $this; + } + + /** + * Registers a one-shot listener that is automatically removed after + * the next trigger() of the event. + * + * @param string $name + * @param callable $callback + * @param int $priority + * @return $this + * @throws InvalidArgumentException + */ + public function once($name, $callback, $priority = self::PRIORITY_NORMAL) + { + $this->emitter->once($name, $callback, $priority); + return $this; } + /** + * Removes a previously registered listener (regular or one-shot) for + * the given event. + * + * @param string $name + * @param callable $callback + * @return $this + * @throws InvalidArgumentException + */ + public function off($name, $callback) + { + $this->emitter->removeListener($name, $callback); + return $this; + } + + /** + * Removes every listener registered for the given event, or every + * listener for every event when called with no arguments. + * + * @param null|string $name + * @return $this + * @throws InvalidArgumentException + */ + public function removeAllListeners($name = null) + { + $this->emitter->removeAllListeners($name); + return $this; + } + + /** + * Returns the EventEmitter instance backing this dispatcher, for + * cases that need the low-level emit() / clearOnceListeners() API. + * + * @return EventEmitter + */ + public function getEmitter() + { + return $this->emitter; + } } diff --git a/src/EventEmitter.php b/src/EventEmitter.php index b851dd3..bc52146 100644 --- a/src/EventEmitter.php +++ b/src/EventEmitter.php @@ -1,4 +1,5 @@ >>. Event names are + * always stored lower-cased so lookups are case-insensitive. + * + * @var array>> + */ protected $listeners = []; - /** @var array */ + /** @var array>> */ protected $onceListeners = []; /** @@ -57,34 +63,34 @@ public function once($event, $listener, $priority = 100) */ public function removeListener($event, $listener) { - if(!is_string($event)){ + if (!is_string($event)) { throw new InvalidArgumentException('$event must be a string.'); } - if(!is_callable($listener)){ + if (!is_callable($listener)) { throw new InvalidArgumentException('$listener must be a callable.'); } $event = strtolower($event); - if(isset($this->listeners[$event])){ + if (isset($this->listeners[$event])) { foreach ($this->listeners[$event] as $key => $value) { - if(($index = array_search($listener, $value, true)) === FALSE){ + if (($index = array_search($listener, $value, true)) === false) { continue; } unset($this->listeners[$event][$key][$index]); - if(empty($this->listeners[$event][$key])){ + if (empty($this->listeners[$event][$key])) { unset($this->listeners[$event][$key]); } } } - if(isset($this->onceListeners[$event])){ + if (isset($this->onceListeners[$event])) { foreach ($this->onceListeners[$event] as $key => $value) { - if(($index = array_search($listener, $value, true)) === FALSE){ + if (($index = array_search($listener, $value, true)) === false) { continue; } unset($this->onceListeners[$event][$key][$index]); - if(empty($this->onceListeners[$event][$key])){ + if (empty($this->onceListeners[$event][$key])) { unset($this->onceListeners[$event][$key]); } } @@ -96,51 +102,67 @@ public function removeListener($event, $listener) */ public function removeAllListeners($event = null) { - if($event === null){ + if ($event === null) { $this->listeners = []; $this->onceListeners = []; return; } - if(!is_string($event)){ + if (!is_string($event)) { throw new InvalidArgumentException('$event must be a string or null.'); } $event = strtolower($event); - if(isset($this->listeners[$event])){ + if (isset($this->listeners[$event])) { unset($this->listeners[$event]); } - if(isset($this->onceListeners[$event])){ + if (isset($this->onceListeners[$event])) { unset($this->onceListeners[$event]); } } /** * @inheritDoc + * + * @return list */ public function listeners($event = null) { $events = []; - if($event === null){ + if ($event === null) { $eventNames = array_unique(array_merge(array_keys($this->listeners), array_keys($this->onceListeners))); - }else{ - if(!is_string($event)){ + } else { + if (!is_string($event)) { throw new InvalidArgumentException('$event must be a string or null.'); } $eventNames = [$event]; } foreach ($eventNames as $eventName) { - $event = strtolower($eventName); - $listeners = isset($this->listeners[$event]) ? $this->listeners[$event] : []; - foreach ($listeners as $values) { - ksort($values); - foreach ($values as $value) { - $events[] = $value; + $key = strtolower($eventName); + + // Merge regular and once listeners by priority, preserving + // insertion order (FIFO) inside each priority bucket. + $byPriority = []; + if (isset($this->listeners[$key])) { + foreach ($this->listeners[$key] as $priority => $bucket) { + foreach ($bucket as $listener) { + $byPriority[$priority][] = $listener; + } + } + } + if (isset($this->onceListeners[$key])) { + foreach ($this->onceListeners[$key] as $priority => $bucket) { + foreach ($bucket as $listener) { + $byPriority[$priority][] = $listener; + } } } - $listeners = isset($this->onceListeners[$event]) ? $this->onceListeners[$event] : []; - foreach ($listeners as $values) { - ksort($values); - foreach ($values as $value) { - $events[] = $value; + + // Lower numeric priority must fire first (PRIORITY_HIGH = 10, + // PRIORITY_LOW = 200), independent of registration order. + ksort($byPriority); + + foreach ($byPriority as $bucket) { + foreach ($bucket as $listener) { + $events[] = $listener; } } } @@ -149,50 +171,77 @@ public function listeners($event = null) /** * @inheritDoc + * + * @param array $arguments */ public function emit($event, $arguments = []) { - if(!is_string($event)){ + if (!is_string($event)) { throw new InvalidArgumentException('$event must be a string.'); } - if(!is_array($arguments)){ + if (!is_array($arguments)) { throw new InvalidArgumentException('$arguments must be an array.'); } $listeners = $this->listeners($event); $event = strtolower($event); - if(isset($this->onceListeners[$event])){ + if (isset($this->onceListeners[$event])) { unset($this->onceListeners[$event]); } - if(!empty($listeners)){ + if (!empty($listeners)) { foreach ($listeners as $listener) { call_user_func_array($listener, $arguments); } } } + /** + * @inheritDoc + */ + public function clearOnceListeners($event = null) + { + if ($event === null) { + $this->onceListeners = []; + return; + } + if (!is_string($event)) { + throw new InvalidArgumentException('$event must be a string or null.'); + } + $key = strtolower($event); + if (isset($this->onceListeners[$key])) { + unset($this->onceListeners[$key]); + } + } + + /** + * @param 'listeners'|'onceListeners' $property + * @param string $event + * @param callable $listener + * @param int $priority + * @return void + * @throws InvalidArgumentException + */ private function addListener($property, $event, $listener, $priority = 100) { - if(!is_string($event)){ + if (!is_string($event)) { throw new InvalidArgumentException('$event must be a string.'); } - if(!is_callable($listener)){ + if (!is_callable($listener)) { throw new InvalidArgumentException('$listener must be a callable.'); } - if(!is_int($priority)){ + if (!is_int($priority)) { throw new InvalidArgumentException('$priority must be an integer.'); } $event = strtolower($event); - if(!isset($this->{$property}[$event])){ + if (!isset($this->{$property}[$event])) { $this->{$property}[$event] = []; } - if(!isset($this->{$property}[$event][$priority])){ + if (!isset($this->{$property}[$event][$priority])) { $this->{$property}[$event][$priority] = []; } $this->{$property}[$event][$priority][] = $listener; } - } diff --git a/src/EventEmitterInterface.php b/src/EventEmitterInterface.php index e015544..4561722 100644 --- a/src/EventEmitterInterface.php +++ b/src/EventEmitterInterface.php @@ -1,4 +1,5 @@ * @throws \InvalidArgumentException

If $event is not string or null.

*/ public function listeners($event = null); /** * @param string $event - * @param array $arguments + * @param array $arguments * @return void * @throws \InvalidArgumentException */ public function emit($event, $arguments = []); + /** + * Drops the registered one-shot listeners for the given event without + * invoking them. Pass null to drop every event's one-shot listeners. + * + * Use cases: + * - higher-level dispatchers that run listeners themselves (e.g. with + * "return false stops the chain" semantics) but still need to honour + * the once() contract. + * + * @param null|string $event + * @return void + * @throws \InvalidArgumentException

If $event is not string or null.

+ */ + public function clearOnceListeners($event = null); } diff --git a/src/Events.php b/src/Events.php index 8c504a9..3fcadae 100644 --- a/src/Events.php +++ b/src/Events.php @@ -1,57 +1,106 @@ - * @copyright Copyright © 2022 InitPHP - * @license http://initphp.github.io/license.txt MIT - * @version 1.0.2 + * @copyright Copyright © 2022 Muhammet ŞAFAK + * @license ./LICENSE MIT * @link https://www.muhammetsafak.com.tr */ namespace InitPHP\Events; /** + * Static facade over a single shared Event dispatcher instance. + * + * Convenient for application-wide hooks where carrying a dispatcher + * around is overkill. Internally it lazily builds one Event instance + * and forwards every static call to it. Use {@see Events::reset()} in + * tests (or whenever you need a clean slate) and {@see Events::setInstance()} + * to inject a pre-configured dispatcher. + * * @mixin Event + * * @method static bool trigger(string $name, ...$arguments) - * @method static void on(string $name, callable $callback, int $priority = Event::PRIORITY_LOW) + * @method static Event on(string $name, callable $callback, int $priority = Event::PRIORITY_NORMAL) + * @method static Event once(string $name, callable $callback, int $priority = Event::PRIORITY_NORMAL) + * @method static Event off(string $name, callable $callback) + * @method static Event removeAllListeners(string|null $name = null) * @method static bool getSimulate() * @method static Event setSimulate(bool $simulate = false) * @method static bool getDebugMode() * @method static Event setDebugMode(bool $debugMode = false) * @method static array getDebug() + * @method static Event clearDebug() + * @method static EventEmitter getEmitter() */ class Events { - const PRIORITY_LOW = Event::PRIORITY_LOW; const PRIORITY_NORMAL = Event::PRIORITY_NORMAL; const PRIORITY_HIGH = Event::PRIORITY_HIGH; - /** @var Event */ + /** @var Event|null */ protected static $Instance; + /** + * @param string $name + * @param array $arguments + * @return mixed + */ public function __call($name, $arguments) { return self::getInstance()->{$name}(...$arguments); } + /** + * @param string $name + * @param array $arguments + * @return mixed + */ public static function __callStatic($name, $arguments) { return self::getInstance()->{$name}(...$arguments); } /** + * Returns the shared Event dispatcher instance, lazily building one + * the first time it is requested. + * * @return Event */ - protected static function getInstance() + public static function getInstance() { - if(!isset(self::$Instance)){ + if (!isset(self::$Instance)) { self::$Instance = new Event(); } return self::$Instance; } + /** + * Replaces the shared instance. Mainly useful in tests, or when an + * application wants to inject a pre-configured Event dispatcher. + * + * @param Event $event + * @return void + */ + public static function setInstance(Event $event) + { + self::$Instance = $event; + } + + /** + * Drops the shared instance so the next facade call rebuilds a + * fresh one. Tests should call this in setUp() / tearDown() to + * keep state from bleeding across them. + * + * @return void + */ + public static function reset() + { + self::$Instance = null; + } } diff --git a/src/aliases.php b/src/aliases.php index e124e66..f32b7a4 100644 --- a/src/aliases.php +++ b/src/aliases.php @@ -1,4 +1,5 @@ assertTrue( + class_exists('InitPHP\\EventEmitter\\EventEmitter'), + 'The legacy class \\InitPHP\\EventEmitter\\EventEmitter must remain available via the BC alias.' + ); + } + + public function test_legacy_event_emitter_interface_alias_is_registered(): void + { + $this->assertTrue( + interface_exists('InitPHP\\EventEmitter\\EventEmitterInterface'), + 'The legacy interface \\InitPHP\\EventEmitter\\EventEmitterInterface must remain available via the BC alias.' + ); + } + + public function test_legacy_class_resolves_to_the_canonical_implementation(): void + { + $legacy = 'InitPHP\\EventEmitter\\EventEmitter'; + $instance = new $legacy(); + + $this->assertInstanceOf(EventEmitter::class, $instance); + $this->assertInstanceOf(EventEmitterInterface::class, $instance); + } + + public function test_instance_created_via_legacy_name_behaves_identically(): void + { + $legacy = 'InitPHP\\EventEmitter\\EventEmitter'; + /** @var EventEmitter $emitter */ + $emitter = new $legacy(); + + $received = null; + $emitter->on('msg', function ($payload) use (&$received): void { + $received = $payload; + }); + + $emitter->emit('msg', ['ok']); + + $this->assertSame('ok', $received); + } +} diff --git a/tests/EventEmitterTest.php b/tests/EventEmitterTest.php new file mode 100644 index 0000000..de0b20e --- /dev/null +++ b/tests/EventEmitterTest.php @@ -0,0 +1,320 @@ +emitter = new EventEmitter(); + } + + public function test_it_implements_the_event_emitter_interface(): void + { + $this->assertInstanceOf(EventEmitterInterface::class, $this->emitter); + } + + public function test_on_returns_self_for_fluent_chaining(): void + { + $returned = $this->emitter->on('evt', function (): void {}); + + $this->assertSame($this->emitter, $returned); + } + + public function test_once_returns_self_for_fluent_chaining(): void + { + $returned = $this->emitter->once('evt', function (): void {}); + + $this->assertSame($this->emitter, $returned); + } + + public function test_emit_invokes_each_listener_with_the_given_arguments(): void + { + $received = []; + + $this->emitter->on('greet', function ($name, $greeting) use (&$received): void { + $received[] = $greeting . ' ' . $name; + }); + + $this->emitter->emit('greet', ['World', 'Hello']); + + $this->assertSame(['Hello World'], $received); + } + + public function test_emit_with_no_listeners_is_a_silent_noop(): void + { + $this->emitter->emit('no.subscribers'); + $this->addToAssertionCount(1); + } + + /** + * Regression test for the priority bug present in 1.x. + * + * The documented contract is: a listener registered with a *numerically + * lower* priority runs before a listener registered with a higher one, + * regardless of registration order. Prior to the fix in 2.0, + * EventEmitter::listeners() only ksort()ed the innermost (already + * numerically-indexed) listener array, leaving the priority map sorted + * by insertion order instead — so a listener with priority 99 added + * *after* a listener with priority 100 would still run second. + */ + public function test_listeners_run_in_ascending_priority_order_regardless_of_registration_order(): void + { + $order = []; + + $this->emitter->on('boot', function () use (&$order): void { + $order[] = 'priority-100'; + }, 100); + + $this->emitter->on('boot', function () use (&$order): void { + $order[] = 'priority-99'; + }, 99); + + $this->emitter->on('boot', function () use (&$order): void { + $order[] = 'priority-10'; + }, 10); + + $this->emitter->emit('boot'); + + $this->assertSame( + ['priority-10', 'priority-99', 'priority-100'], + $order, + 'Listeners must execute in ascending priority order regardless of the order in which they were registered.' + ); + } + + public function test_listeners_at_the_same_priority_run_in_registration_order_fifo(): void + { + $order = []; + + $this->emitter->on('e', function () use (&$order): void { $order[] = 'first'; }, 50); + $this->emitter->on('e', function () use (&$order): void { $order[] = 'second'; }, 50); + $this->emitter->on('e', function () use (&$order): void { $order[] = 'third'; }, 50); + + $this->emitter->emit('e'); + + $this->assertSame(['first', 'second', 'third'], $order); + } + + public function test_once_listeners_are_invoked_only_on_the_first_emit(): void + { + $count = 0; + + $this->emitter->once('tick', function () use (&$count): void { + $count++; + }); + + $this->emitter->emit('tick'); + $this->emitter->emit('tick'); + $this->emitter->emit('tick'); + + $this->assertSame(1, $count); + } + + public function test_event_names_are_compared_case_insensitively(): void + { + $hits = 0; + $this->emitter->on('User.Created', function () use (&$hits): void { + $hits++; + }); + + $this->emitter->emit('user.created'); + $this->emitter->emit('USER.CREATED'); + + $this->assertSame(2, $hits); + } + + public function test_listeners_returns_all_registered_callbacks_for_an_event(): void + { + $a = function (): void {}; + $b = function (): void {}; + + $this->emitter->on('e', $a, 50); + $this->emitter->once('e', $b, 10); + + $listeners = $this->emitter->listeners('e'); + + $this->assertCount(2, $listeners); + $this->assertSame($b, $listeners[0], 'lower-priority listener should appear first'); + $this->assertSame($a, $listeners[1]); + } + + public function test_listeners_with_no_argument_returns_listeners_across_all_events(): void + { + $this->emitter->on('a', function (): void {}); + $this->emitter->on('b', function (): void {}); + $this->emitter->once('c', function (): void {}); + + $this->assertCount(3, $this->emitter->listeners()); + } + + public function test_remove_listener_drops_only_the_targeted_callback(): void + { + $kept = function (): void {}; + $removed = function (): void {}; + + $this->emitter->on('e', $kept, 10); + $this->emitter->on('e', $removed, 10); + + $this->emitter->removeListener('e', $removed); + + $listeners = $this->emitter->listeners('e'); + $this->assertCount(1, $listeners); + $this->assertSame($kept, $listeners[0]); + } + + public function test_remove_all_listeners_for_a_single_event_clears_only_that_event(): void + { + $this->emitter->on('a', function (): void {}); + $this->emitter->on('b', function (): void {}); + + $this->emitter->removeAllListeners('a'); + + $this->assertSame([], $this->emitter->listeners('a')); + $this->assertCount(1, $this->emitter->listeners('b')); + } + + public function test_remove_all_listeners_with_no_argument_clears_every_event(): void + { + $this->emitter->on('a', function (): void {}); + $this->emitter->once('b', function (): void {}); + + $this->emitter->removeAllListeners(); + + $this->assertSame([], $this->emitter->listeners()); + } + + public function test_on_rejects_non_string_event_names(): void + { + $this->expectException(InvalidArgumentException::class); + $this->emitter->on(123, function (): void {}); + } + + public function test_on_rejects_non_callable_listeners(): void + { + $this->expectException(InvalidArgumentException::class); + $this->emitter->on('e', 'this-is-not-callable-anywhere'); + } + + public function test_on_rejects_non_integer_priority(): void + { + $this->expectException(InvalidArgumentException::class); + $this->emitter->on('e', function (): void {}, '100'); + } + + public function test_emit_rejects_non_string_event_names(): void + { + $this->expectException(InvalidArgumentException::class); + $this->emitter->emit(123); + } + + public function test_emit_rejects_non_array_arguments(): void + { + $this->expectException(InvalidArgumentException::class); + $this->emitter->emit('e', 'not-an-array'); + } + + public function test_remove_listener_rejects_non_string_event(): void + { + $this->expectException(InvalidArgumentException::class); + $this->emitter->removeListener(42, function (): void {}); + } + + public function test_remove_listener_rejects_non_callable_listener(): void + { + $this->expectException(InvalidArgumentException::class); + $this->emitter->removeListener('e', 'definitely-not-a-callable-anywhere'); + } + + public function test_remove_listener_is_a_silent_noop_when_the_listener_is_not_registered(): void + { + $registered = function (): void {}; + $stranger = function (): void {}; + + // Hit both branches: removeListener walks BOTH the regular and + // once registries and, in each one, skips priority buckets + // where the listener is not found. + $this->emitter->on('e', $registered, 10); + $this->emitter->once('e', $registered, 20); + + $this->emitter->removeListener('e', $stranger); + + // The actually-registered listener must still be there. + $this->assertCount(2, $this->emitter->listeners('e')); + } + + public function test_remove_all_listeners_rejects_non_string_non_null_event(): void + { + $this->expectException(InvalidArgumentException::class); + $this->emitter->removeAllListeners(42); + } + + public function test_listeners_rejects_non_string_non_null_event(): void + { + $this->expectException(InvalidArgumentException::class); + $this->emitter->listeners(42); + } + + public function test_clear_once_listeners_for_a_specific_event_drops_only_that_events_once_listeners(): void + { + $aFires = 0; + $bFires = 0; + + $this->emitter->once('a', function () use (&$aFires): void { $aFires++; }); + $this->emitter->once('b', function () use (&$bFires): void { $bFires++; }); + + $this->emitter->clearOnceListeners('a'); + + $this->emitter->emit('a'); + $this->emitter->emit('b'); + + $this->assertSame(0, $aFires, 'once-listener for "a" must have been dropped without firing.'); + $this->assertSame(1, $bFires, 'once-listener for "b" must still fire.'); + } + + public function test_clear_once_listeners_with_no_argument_drops_every_one_shot_listener(): void + { + $fired = 0; + + $this->emitter->once('a', function () use (&$fired): void { $fired++; }); + $this->emitter->once('b', function () use (&$fired): void { $fired++; }); + // Regular listener must NOT be touched. + $this->emitter->on('c', function () use (&$fired): void { $fired++; }); + + $this->emitter->clearOnceListeners(); + + $this->emitter->emit('a'); + $this->emitter->emit('b'); + $this->emitter->emit('c'); + + $this->assertSame(1, $fired, 'Only the regular listener for "c" must have fired.'); + } + + public function test_clear_once_listeners_rejects_non_string_non_null_event(): void + { + $this->expectException(InvalidArgumentException::class); + $this->emitter->clearOnceListeners(42); + } + + public function test_clear_once_listeners_for_an_unknown_event_is_a_silent_noop(): void + { + $this->emitter->clearOnceListeners('never.registered'); + $this->addToAssertionCount(1); + } +} diff --git a/tests/EventTest.php b/tests/EventTest.php new file mode 100644 index 0000000..f8f08d5 --- /dev/null +++ b/tests/EventTest.php @@ -0,0 +1,300 @@ +event = new Event(); + } + + public function test_priority_constants_match_documented_ordering(): void + { + $this->assertLessThan(Event::PRIORITY_NORMAL, Event::PRIORITY_HIGH); + $this->assertLessThan(Event::PRIORITY_LOW, Event::PRIORITY_NORMAL); + } + + public function test_trigger_runs_listeners_in_priority_order(): void + { + $order = []; + + $this->event->on('boot', function () use (&$order): void { + $order[] = 'low'; + }, Event::PRIORITY_LOW); + + $this->event->on('boot', function () use (&$order): void { + $order[] = 'high'; + }, Event::PRIORITY_HIGH); + + $this->event->on('boot', function () use (&$order): void { + $order[] = 'normal'; + }, Event::PRIORITY_NORMAL); + + $this->event->trigger('boot'); + + $this->assertSame(['high', 'normal', 'low'], $order); + } + + public function test_trigger_forwards_variadic_arguments_to_listeners(): void + { + $captured = null; + + $this->event->on('greet', function ($name, $greeting) use (&$captured): void { + $captured = $greeting . ' ' . $name; + }); + + $this->event->trigger('greet', 'World', 'Hello'); + + $this->assertSame('Hello World', $captured); + } + + public function test_trigger_returns_true_when_no_listener_short_circuits(): void + { + $this->event->on('e', function () { return true; }); + $this->event->on('e', function () { /* implicit null */ }); + + $this->assertTrue($this->event->trigger('e')); + } + + public function test_trigger_returns_false_and_stops_chain_when_a_listener_returns_false(): void + { + $invocations = []; + + $this->event->on('chain', function () use (&$invocations) { + $invocations[] = 'first'; + return true; + }, 10); + + $this->event->on('chain', function () use (&$invocations) { + $invocations[] = 'second'; + return false; + }, 20); + + $this->event->on('chain', function () use (&$invocations) { + $invocations[] = 'third'; + return true; + }, 30); + + $this->assertFalse($this->event->trigger('chain')); + $this->assertSame(['first', 'second'], $invocations); + } + + public function test_simulate_mode_skips_listener_invocation_and_returns_true(): void + { + $called = false; + + $this->event->setSimulate(true); + $this->event->on('e', function () use (&$called) { + $called = true; + return false; + }); + + $this->assertTrue($this->event->trigger('e')); + $this->assertFalse($called, 'Listener must not be called in simulate mode.'); + } + + public function test_simulate_mode_round_trips_through_getter(): void + { + $this->assertFalse($this->event->getSimulate()); + + $this->event->setSimulate(true); + $this->assertTrue($this->event->getSimulate()); + + $this->event->setSimulate(false); + $this->assertFalse($this->event->getSimulate()); + } + + public function test_set_simulate_rejects_non_boolean(): void + { + $this->expectException(InvalidArgumentException::class); + $this->event->setSimulate('yes'); + } + + public function test_debug_mode_records_each_trigger_invocation(): void + { + $this->event->setDebugMode(true); + $this->event->on('measured', function (): void {}); + + $this->event->trigger('measured'); + $this->event->trigger('measured'); + + $debug = $this->event->getDebug(); + $this->assertCount(2, $debug); + + foreach ($debug as $entry) { + $this->assertSame('measured', $entry['event']); + $this->assertArrayHasKey('start', $entry); + $this->assertArrayHasKey('end', $entry); + $this->assertGreaterThanOrEqual($entry['start'], $entry['end']); + } + } + + public function test_debug_mode_disabled_by_default_collects_nothing(): void + { + $this->event->on('quiet', function (): void {}); + $this->event->trigger('quiet'); + + $this->assertSame([], $this->event->getDebug()); + } + + public function test_debug_mode_round_trips_through_getter(): void + { + $this->assertFalse($this->event->getDebugMode()); + + $this->event->setDebugMode(true); + $this->assertTrue($this->event->getDebugMode()); + } + + public function test_set_debug_mode_rejects_non_boolean(): void + { + $this->expectException(InvalidArgumentException::class); + $this->event->setDebugMode(1); + } + + public function test_trigger_rejects_non_string_event_name(): void + { + $this->expectException(InvalidArgumentException::class); + $this->event->trigger(42); + } + + public function test_set_simulate_and_set_debug_mode_are_fluent(): void + { + $this->assertSame($this->event, $this->event->setSimulate(false)); + $this->assertSame($this->event, $this->event->setDebugMode(false)); + } + + public function test_on_off_once_remove_all_listeners_are_fluent(): void + { + $cb = function (): void {}; + + $this->assertSame($this->event, $this->event->on('e', $cb)); + $this->assertSame($this->event, $this->event->once('e', $cb)); + $this->assertSame($this->event, $this->event->off('e', $cb)); + $this->assertSame($this->event, $this->event->removeAllListeners('e')); + $this->assertSame($this->event, $this->event->removeAllListeners()); + } + + public function test_default_priority_is_normal(): void + { + $order = []; + + $this->event->on('boot', function () use (&$order): void { $order[] = 'default'; }); + $this->event->on('boot', function () use (&$order): void { $order[] = 'high'; }, Event::PRIORITY_HIGH); + $this->event->on('boot', function () use (&$order): void { $order[] = 'low'; }, Event::PRIORITY_LOW); + + $this->event->trigger('boot'); + + $this->assertSame(['high', 'default', 'low'], $order); + } + + /** + * Regression test for the once-leak bug present in 1.x: Event::trigger() + * fetched listeners via emitter->listeners() (which includes one-shot + * listeners) but never cleaned them up, so once() listeners registered + * via Event ended up firing on every trigger. + */ + public function test_once_listener_is_dropped_after_first_trigger(): void + { + $calls = 0; + + $this->event->once('tick', function () use (&$calls): void { + $calls++; + }); + + $this->event->trigger('tick'); + $this->event->trigger('tick'); + $this->event->trigger('tick'); + + $this->assertSame(1, $calls); + } + + public function test_once_contract_is_honoured_even_when_chain_is_stopped_by_false(): void + { + $onceCalls = 0; + + $this->event->once('halt', function () use (&$onceCalls) { + $onceCalls++; + return false; + }, Event::PRIORITY_HIGH); + + $this->event->trigger('halt'); + $this->event->trigger('halt'); + + $this->assertSame(1, $onceCalls, 'once() listener must not be re-armed when the chain is halted.'); + } + + public function test_off_removes_a_specific_listener(): void + { + $hits = []; + $a = function () use (&$hits): void { $hits[] = 'a'; }; + $b = function () use (&$hits): void { $hits[] = 'b'; }; + + $this->event->on('e', $a)->on('e', $b)->off('e', $a)->trigger('e'); + + $this->assertSame(['b'], $hits); + } + + public function test_remove_all_listeners_for_event_clears_that_event_only(): void + { + $aHits = 0; + $bHits = 0; + + $this->event + ->on('a', function () use (&$aHits): void { $aHits++; }) + ->on('b', function () use (&$bHits): void { $bHits++; }); + + $this->event->removeAllListeners('a'); + + $this->event->trigger('a'); + $this->event->trigger('b'); + + $this->assertSame(0, $aHits); + $this->assertSame(1, $bHits); + } + + public function test_clear_debug_empties_the_collected_log(): void + { + $this->event->setDebugMode(true); + $this->event->on('e', function (): void {}); + $this->event->trigger('e'); + + $this->assertNotEmpty($this->event->getDebug()); + + $this->assertSame($this->event, $this->event->clearDebug()); + $this->assertSame([], $this->event->getDebug()); + } + + public function test_get_emitter_returns_the_backing_event_emitter(): void + { + $this->assertInstanceOf(\InitPHP\Events\EventEmitter::class, $this->event->getEmitter()); + } + + public function test_debug_info_exposes_simulate_debug_and_collected_data(): void + { + $this->event->setSimulate(true)->setDebugMode(true); + + $info = (new \ReflectionMethod($this->event, '__debugInfo'))->invoke($this->event); + + $this->assertArrayHasKey('simulate', $info); + $this->assertArrayHasKey('debugMode', $info); + $this->assertArrayHasKey('debugData', $info); + $this->assertTrue($info['simulate']); + $this->assertTrue($info['debugMode']); + } +} diff --git a/tests/EventsFacadeTest.php b/tests/EventsFacadeTest.php new file mode 100644 index 0000000..f9545fa --- /dev/null +++ b/tests/EventsFacadeTest.php @@ -0,0 +1,192 @@ +assertSame(Event::PRIORITY_HIGH, Events::PRIORITY_HIGH); + $this->assertSame(Event::PRIORITY_NORMAL, Events::PRIORITY_NORMAL); + $this->assertSame(Event::PRIORITY_LOW, Events::PRIORITY_LOW); + } + + public function test_on_and_trigger_match_the_readme_example(): void + { + $output = []; + + Events::on('helloTrigger', function () use (&$output): void { + $output[] = 'Hello World'; + }, 100); + + Events::on('helloTrigger', function () use (&$output): void { + $output[] = 'Hi, World'; + }, 99); + + Events::trigger('helloTrigger'); + + $this->assertSame(['Hi, World', 'Hello World'], $output); + } + + public function test_trigger_forwards_arguments_to_listeners(): void + { + $received = null; + + Events::on('greet', function ($name, $me) use (&$received): void { + $received = sprintf('Hi %s. I am %s.', $name, $me); + }, 99); + + Events::trigger('greet', 'World', 'John'); + + $this->assertSame('Hi World. I am John.', $received); + } + + public function test_trigger_returns_false_when_a_listener_returns_false(): void + { + Events::on('halt', function () { return false; }); + + $this->assertFalse(Events::trigger('halt')); + } + + public function test_simulate_round_trips_through_facade(): void + { + $this->assertFalse(Events::getSimulate()); + + Events::setSimulate(true); + $this->assertTrue(Events::getSimulate()); + } + + public function test_debug_mode_round_trips_through_facade(): void + { + $this->assertFalse(Events::getDebugMode()); + + Events::setDebugMode(true); + $this->assertTrue(Events::getDebugMode()); + + Events::on('e', function (): void {}); + Events::trigger('e'); + + $this->assertCount(1, Events::getDebug()); + } + + public function test_get_instance_returns_the_same_singleton_on_repeated_calls(): void + { + $first = Events::getInstance(); + $second = Events::getInstance(); + + $this->assertSame($first, $second); + $this->assertInstanceOf(Event::class, $first); + } + + public function test_reset_drops_the_singleton_so_the_next_call_builds_a_fresh_one(): void + { + $first = Events::getInstance(); + Events::reset(); + $second = Events::getInstance(); + + $this->assertNotSame($first, $second); + } + + public function test_reset_drops_previously_registered_listeners(): void + { + $hits = 0; + Events::on('e', function () use (&$hits): void { $hits++; }); + + Events::reset(); + Events::trigger('e'); + + $this->assertSame(0, $hits); + } + + public function test_set_instance_lets_callers_inject_a_preconfigured_dispatcher(): void + { + $injected = (new Event())->setDebugMode(true); + Events::setInstance($injected); + + $this->assertSame($injected, Events::getInstance()); + $this->assertTrue(Events::getDebugMode()); + } + + public function test_once_through_the_facade_fires_only_once(): void + { + $calls = 0; + Events::once('tick', function () use (&$calls): void { $calls++; }); + + Events::trigger('tick'); + Events::trigger('tick'); + + $this->assertSame(1, $calls); + } + + public function test_off_through_the_facade_removes_a_listener(): void + { + $hits = 0; + $cb = function () use (&$hits): void { $hits++; }; + + Events::on('e', $cb); + Events::off('e', $cb); + Events::trigger('e'); + + $this->assertSame(0, $hits); + } + + public function test_remove_all_listeners_through_the_facade(): void + { + Events::on('a', function (): void {}); + Events::on('b', function (): void {}); + + Events::removeAllListeners(); + + $this->assertSame([], Events::getEmitter()->listeners()); + } + + /** + * The Events class also defines a non-static __call() that mirrors + * its __callStatic(). It's there so `(new Events())->on(...)` works + * (e.g. an instance accidentally created via reflection or + * dependency-injection). Both magic methods forward to the same + * shared singleton. + */ + public function test_instance_call_magic_forwards_to_the_shared_singleton(): void + { + $facade = new Events(); + + $hits = 0; + $facade->on('e', function () use (&$hits): void { $hits++; }); + + // The listener landed on the shared instance — the static + // trigger sees it too. + Events::trigger('e'); + + $this->assertSame(1, $hits); + } +} diff --git a/tests/compat/autoload-smoke.php b/tests/compat/autoload-smoke.php new file mode 100644 index 0000000..37a4150 --- /dev/null +++ b/tests/compat/autoload-smoke.php @@ -0,0 +1,75 @@ +on('ping', function () use (&$hit) { + $hit = true; +}); +$emitter->emit('ping'); + +if (!$hit) { + fwrite(STDERR, "smoke test failed: listener was not invoked\n"); + exit(1); +} + +// Backwards-compatibility alias is registered and usable. +if (!class_exists('InitPHP\\EventEmitter\\EventEmitter')) { + fwrite(STDERR, "smoke test failed: BC alias \\InitPHP\\EventEmitter\\EventEmitter is not registered\n"); + exit(1); +} + +if (!interface_exists('InitPHP\\EventEmitter\\EventEmitterInterface')) { + fwrite(STDERR, "smoke test failed: BC alias \\InitPHP\\EventEmitter\\EventEmitterInterface is not registered\n"); + exit(1); +} + +// Instance built through the legacy class name must behave like the +// canonical one. +$legacy = 'InitPHP\\EventEmitter\\EventEmitter'; +$legacyInstance = new $legacy(); +if (!($legacyInstance instanceof InitPHP\Events\EventEmitter)) { + fwrite(STDERR, "smoke test failed: legacy alias does not resolve to the canonical class\n"); + exit(1); +} + +echo "smoke ok\n";