From d68babe29d506dc369e1190e286af29ecdacd8c8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Muhammet=20=C5=9Eafak?=
Date: Mon, 25 May 2026 08:03:56 +0300
Subject: [PATCH 1/8] Update composer.json and .gitignore, add phpunit
configuration and tests
---
.gitignore | 5 +-
composer.json | 50 ++++++--
phpunit.xml.dist | 25 ++++
src/EventEmitter.php | 36 ++++--
tests/EventEmitterTest.php | 231 +++++++++++++++++++++++++++++++++++++
5 files changed, 324 insertions(+), 23 deletions(-)
create mode 100644 phpunit.xml.dist
create mode 100644 tests/EventEmitterTest.php
diff --git a/.gitignore b/.gitignore
index a879886..ec78f24 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,4 +2,7 @@
/.vs/
/.vscode/
/vendor/
-/composer.lock
\ No newline at end of file
+/composer.lock
+/.phpunit.cache/
+/.phpunit.result.cache
+/build/
diff --git a/composer.json b/composer.json
index 16a80b2..4308fd8 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,38 @@
"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": {
+ "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"
+ },
+ "config": {
+ "sort-packages": true
+ },
+ "minimum-stability": "stable",
+ "prefer-stable": true
}
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/EventEmitter.php b/src/EventEmitter.php
index b851dd3..8ae45fa 100644
--- a/src/EventEmitter.php
+++ b/src/EventEmitter.php
@@ -128,19 +128,33 @@ public function listeners($event = 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;
+ }
}
}
- $listeners = isset($this->onceListeners[$event]) ? $this->onceListeners[$event] : [];
- foreach ($listeners as $values) {
- ksort($values);
- foreach ($values as $value) {
- $events[] = $value;
+ if (isset($this->onceListeners[$key])) {
+ foreach ($this->onceListeners[$key] as $priority => $bucket) {
+ foreach ($bucket as $listener) {
+ $byPriority[$priority][] = $listener;
+ }
+ }
+ }
+
+ // 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;
}
}
}
diff --git a/tests/EventEmitterTest.php b/tests/EventEmitterTest.php
new file mode 100644
index 0000000..8d33512
--- /dev/null
+++ b/tests/EventEmitterTest.php
@@ -0,0 +1,231 @@
+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');
+ }
+}
From 3e0ed767542c5f05b4dfa830a58eb3d2b6553b73 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Muhammet=20=C5=9Eafak?=
Date: Mon, 25 May 2026 08:19:50 +0300
Subject: [PATCH 2/8] Update event dispatcher with WordPress-style hook
semantics
---
src/Event.php | 196 ++++++++++----
src/EventEmitter.php | 18 ++
src/EventEmitterInterface.php | 15 ++
src/Events.php | 62 ++++-
tests/BackwardsCompatibilityAliasTest.php | 63 +++++
tests/EventTest.php | 299 ++++++++++++++++++++++
tests/EventsFacadeTest.php | 170 ++++++++++++
7 files changed, 767 insertions(+), 56 deletions(-)
create mode 100644 tests/BackwardsCompatibilityAliasTest.php
create mode 100644 tests/EventTest.php
create mode 100644 tests/EventsFacadeTest.php
diff --git a/src/Event.php b/src/Event.php
index 05abbd9..e5884fb 100644
--- a/src/Event.php
+++ b/src/Event.php
@@ -2,22 +2,40 @@
/**
* Event.php
*
- * This file is part of InitPHP.
+ * This file is part of InitPHP Events.
*
* @author Muhammet ŞAFAK
- * @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 +45,7 @@ final class Event
/** @var EventEmitter */
protected $emitter;
+ /** @var array */
protected $debug = [];
/** @var bool */
@@ -40,11 +59,6 @@ public function __construct()
$this->emitter = new EventEmitter();
}
- public function __destruct()
- {
- unset($this->emitter, $this->simulate, $this->debug, $this->debugMode);
- }
-
public function __debugInfo()
{
return [
@@ -59,19 +73,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 +95,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 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 8ae45fa..7bb99a3 100644
--- a/src/EventEmitter.php
+++ b/src/EventEmitter.php
@@ -187,6 +187,24 @@ public function emit($event, $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]);
+ }
+ }
+
private function addListener($property, $event, $listener, $priority = 100)
{
if(!is_string($event)){
diff --git a/src/EventEmitterInterface.php b/src/EventEmitterInterface.php
index e015544..3c8fdd5 100644
--- a/src/EventEmitterInterface.php
+++ b/src/EventEmitterInterface.php
@@ -62,4 +62,19 @@ public function listeners($event = null);
*/
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..9a667b3 100644
--- a/src/Events.php
+++ b/src/Events.php
@@ -2,35 +2,47 @@
/**
* Events.php
*
- * This file is part of InitPHP.
+ * This file is part of InitPHP Events.
*
* @author Muhammet ŞAFAK
- * @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 bool getSimulate()
+ *
+ * @method static bool trigger(string $name, ...$arguments)
+ * @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 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;
public function __call($name, $arguments)
@@ -44,14 +56,40 @@ public static function __callStatic($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/tests/BackwardsCompatibilityAliasTest.php b/tests/BackwardsCompatibilityAliasTest.php
new file mode 100644
index 0000000..6f30a55
--- /dev/null
+++ b/tests/BackwardsCompatibilityAliasTest.php
@@ -0,0 +1,63 @@
+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/EventTest.php b/tests/EventTest.php
new file mode 100644
index 0000000..cd526b8
--- /dev/null
+++ b/tests/EventTest.php
@@ -0,0 +1,299 @@
+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..e579789
--- /dev/null
+++ b/tests/EventsFacadeTest.php
@@ -0,0 +1,170 @@
+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());
+ }
+}
From 0a53000a52b8cd775e4a1b2ee1fec2c5abab8915 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Muhammet=20=C5=9Eafak?=
Date: Mon, 25 May 2026 08:28:25 +0300
Subject: [PATCH 3/8] Add CI workflow for PHP versions 7.3 to 8.4 and PHPUnit
tests
---
.github/workflows/ci.yml | 105 +++++++++++++++++++++++++++++++++++++++
src/aliases.php | 4 +-
2 files changed, 107 insertions(+), 2 deletions(-)
create mode 100644 .github/workflows/ci.yml
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..1e11a4a
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,105 @@
+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
+
+ 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
+ run: |
+ php -r '
+ spl_autoload_register(function ($class) {
+ $prefix = "InitPHP\\\\Events\\\\";
+ if (strpos($class, $prefix) !== 0) {
+ return;
+ }
+ $relative = substr($class, strlen($prefix));
+ $file = __DIR__ . "/src/" . str_replace("\\\\", "/", $relative) . ".php";
+ if (is_file($file)) {
+ require $file;
+ }
+ });
+ require __DIR__ . "/src/aliases.php";
+ $emitter = new InitPHP\Events\EventEmitter();
+ $hit = false;
+ $emitter->on("ping", function () use (&$hit) { $hit = true; });
+ $emitter->emit("ping");
+ if (!$hit) {
+ fwrite(STDERR, "smoke test failed: listener not invoked\n");
+ exit(1);
+ }
+ $legacy = "InitPHP\\\\EventEmitter\\\\EventEmitter";
+ if (!class_exists($legacy)) {
+ fwrite(STDERR, "smoke test failed: BC alias missing\n");
+ exit(1);
+ }
+ echo "smoke ok\n";
+ '
diff --git a/src/aliases.php b/src/aliases.php
index e124e66..603a741 100644
--- a/src/aliases.php
+++ b/src/aliases.php
@@ -15,14 +15,14 @@
* @see https://github.com/InitPHP/Events#migrating-from-initphpevent-emitter
*/
-if (!class_exists(\InitPHP\EventEmitter\EventEmitter::class, false)) {
+if (!class_exists('InitPHP\\EventEmitter\\EventEmitter', false)) {
class_alias(
\InitPHP\Events\EventEmitter::class,
'InitPHP\\EventEmitter\\EventEmitter'
);
}
-if (!interface_exists(\InitPHP\EventEmitter\EventEmitterInterface::class, false)) {
+if (!interface_exists('InitPHP\\EventEmitter\\EventEmitterInterface', false)) {
class_alias(
\InitPHP\Events\EventEmitterInterface::class,
'InitPHP\\EventEmitter\\EventEmitterInterface'
From a71fa26c3243af04681270c73c48c1ae62657632 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Muhammet=20=C5=9Eafak?=
Date: Mon, 25 May 2026 08:46:52 +0300
Subject: [PATCH 4/8] Add `CHANGELOG.md` for breaking changes, additions,
fixes, and updates
---
CHANGELOG.md | 123 +++++++++
README.md | 309 +++++++++++++++++----
docs/01-getting-started.md | 114 ++++++++
docs/02-events-facade.md | 184 +++++++++++++
docs/03-event-emitter.md | 166 ++++++++++++
docs/04-priorities-and-ordering.md | 177 +++++++++++++
docs/05-once-and-removal.md | 162 +++++++++++
docs/06-debug-and-simulate.md | 188 +++++++++++++
docs/07-migration-from-event-emitter.md | 186 +++++++++++++
docs/08-recipes.md | 295 +++++++++++++++++++++
docs/09-api-reference.md | 339 ++++++++++++++++++++++++
docs/README.md | 37 +++
12 files changed, 2234 insertions(+), 46 deletions(-)
create mode 100644 CHANGELOG.md
create mode 100644 docs/01-getting-started.md
create mode 100644 docs/02-events-facade.md
create mode 100644 docs/03-event-emitter.md
create mode 100644 docs/04-priorities-and-ordering.md
create mode 100644 docs/05-once-and-removal.md
create mode 100644 docs/06-debug-and-simulate.md
create mode 100644 docs/07-migration-from-event-emitter.md
create mode 100644 docs/08-recipes.md
create mode 100644 docs/09-api-reference.md
create mode 100644 docs/README.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..8e34078
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,123 @@
+# 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.
+- 59 unit tests, 99 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\*`.
+- New CI workflow (`.github/workflows/ci.yml`):
+ - PHP 7.3 / 7.4 / 8.0 / 8.1 / 8.2 / 8.3 / 8.4 — `composer install`
+ + `phpunit`.
+ - 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.
-
-[](https://packagist.org/packages/initphp/events) [](https://packagist.org/packages/initphp/events) [](https://packagist.org/packages/initphp/events) [](https://packagist.org/packages/initphp/events) [](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.
+
+[](https://github.com/InitPHP/Events/actions/workflows/ci.yml)
+[](https://packagist.org/packages/initphp/events)
+[](https://packagist.org/packages/initphp/events)
+[](https://packagist.org/packages/initphp/events)
+[](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/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.
From ea98a1829bbfd8e1b46870d4b11947e82193f044 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Muhammet=20=C5=9Eafak?=
Date: Mon, 25 May 2026 08:57:46 +0300
Subject: [PATCH 5/8] Add PHP-CS-Fixer code style check in CI workflow
---
.github/workflows/ci.yml | 32 +++++++
.gitignore | 1 +
.php-cs-fixer.dist.php | 101 ++++++++++++++++++++++
composer.json | 5 +-
src/Event.php | 21 ++---
src/EventEmitter.php | 70 +++++++--------
src/EventEmitterInterface.php | 3 +-
src/Events.php | 7 +-
src/aliases.php | 1 +
tests/BackwardsCompatibilityAliasTest.php | 1 +
tests/EventEmitterTest.php | 3 +-
tests/EventTest.php | 1 +
tests/EventsFacadeTest.php | 1 +
13 files changed, 195 insertions(+), 52 deletions(-)
create mode 100644 .php-cs-fixer.dist.php
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1e11a4a..c2a3c53 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -48,6 +48,38 @@ jobs:
- name: Run PHPUnit
run: vendor/bin/phpunit --testdox
+ 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
diff --git a/.gitignore b/.gitignore
index ec78f24..f384c7c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,4 +5,5 @@
/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/composer.json b/composer.json
index 4308fd8..0437e35 100644
--- a/composer.json
+++ b/composer.json
@@ -28,6 +28,7 @@
"php": ">=5.6"
},
"require-dev": {
+ "friendsofphp/php-cs-fixer": "^3.0",
"phpunit/phpunit": "^9.6"
},
"autoload": {
@@ -47,7 +48,9 @@
"initphp/event-emitter": "*"
},
"scripts": {
- "test": "phpunit"
+ "test": "phpunit",
+ "cs:check": "php-cs-fixer fix --dry-run --diff",
+ "cs:fix": "php-cs-fixer fix"
},
"config": {
"sort-packages": true
diff --git a/src/Event.php b/src/Event.php
index e5884fb..c76a3e2 100644
--- a/src/Event.php
+++ b/src/Event.php
@@ -1,4 +1,5 @@
$this->simulate,
+ 'simulate' => $this->simulate,
'debugMode' => $this->debugMode,
'debugData' => $this->debug,
];
@@ -146,7 +147,7 @@ public function clearDebug()
* trigger() always returns true.
*
* @param string $name
- * @param mixed ...$arguments
+ * @param mixed ...$arguments
* @return bool false if a listener short-circuited the chain, true otherwise.
* @throws InvalidArgumentException
*/
@@ -168,7 +169,7 @@ public function trigger($name, ...$arguments)
if ($this->debugMode) {
$this->debug[] = [
'start' => $start,
- 'end' => microtime(true),
+ 'end' => microtime(true),
'event' => $name,
];
}
@@ -189,11 +190,11 @@ public function trigger($name, ...$arguments)
/**
* Registers a listener for the given event.
*
- * @param string $name
+ * @param string $name
* @param callable $callback
- * @param int $priority Lower numeric value runs first.
- * Use the PRIORITY_HIGH / PRIORITY_NORMAL /
- * PRIORITY_LOW constants for readability.
+ * @param int $priority Lower numeric value runs first.
+ * Use the PRIORITY_HIGH / PRIORITY_NORMAL /
+ * PRIORITY_LOW constants for readability.
* @return $this
* @throws InvalidArgumentException
*/
@@ -207,9 +208,9 @@ public function on($name, $callback, $priority = self::PRIORITY_NORMAL)
* Registers a one-shot listener that is automatically removed after
* the next trigger() of the event.
*
- * @param string $name
+ * @param string $name
* @param callable $callback
- * @param int $priority
+ * @param int $priority
* @return $this
* @throws InvalidArgumentException
*/
@@ -223,7 +224,7 @@ public function once($name, $callback, $priority = self::PRIORITY_NORMAL)
* Removes a previously registered listener (regular or one-shot) for
* the given event.
*
- * @param string $name
+ * @param string $name
* @param callable $callback
* @return $this
* @throws InvalidArgumentException
diff --git a/src/EventEmitter.php b/src/EventEmitter.php
index 7bb99a3..7233f54 100644
--- a/src/EventEmitter.php
+++ b/src/EventEmitter.php
@@ -1,4 +1,5 @@
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,19 +97,19 @@ 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]);
}
}
@@ -119,10 +120,10 @@ public function removeAllListeners($event = null)
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];
@@ -166,21 +167,21 @@ public function listeners($event = null)
*/
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);
}
@@ -207,24 +208,23 @@ public function clearOnceListeners($event = null)
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 3c8fdd5..a772249 100644
--- a/src/EventEmitterInterface.php
+++ b/src/EventEmitterInterface.php
@@ -1,4 +1,5 @@
If $event is not string or null.
*/
public function clearOnceListeners($event = null);
-
}
diff --git a/src/Events.php b/src/Events.php
index 9a667b3..1a2e297 100644
--- a/src/Events.php
+++ b/src/Events.php
@@ -1,4 +1,5 @@
emitter->on('e', $kept, 10);
diff --git a/tests/EventTest.php b/tests/EventTest.php
index cd526b8..f8f08d5 100644
--- a/tests/EventTest.php
+++ b/tests/EventTest.php
@@ -1,4 +1,5 @@
Date: Mon, 25 May 2026 09:02:10 +0300
Subject: [PATCH 6/8] Update unit test coverage to 100% and add new CI workflow
---
CHANGELOG.md | 5 ++-
tests/EventEmitterTest.php | 88 ++++++++++++++++++++++++++++++++++++++
tests/EventsFacadeTest.php | 21 +++++++++
3 files changed, 113 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8e34078..013481a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -83,10 +83,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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.
-- 59 unit tests, 99 assertions covering the priority contract,
+- 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).
- New CI workflow (`.github/workflows/ci.yml`):
- PHP 7.3 / 7.4 / 8.0 / 8.1 / 8.2 / 8.3 / 8.4 — `composer install`
+ `phpunit`.
diff --git a/tests/EventEmitterTest.php b/tests/EventEmitterTest.php
index f99438b..b2cb601 100644
--- a/tests/EventEmitterTest.php
+++ b/tests/EventEmitterTest.php
@@ -229,4 +229,92 @@ 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/EventsFacadeTest.php b/tests/EventsFacadeTest.php
index 99fd6aa..f9545fa 100644
--- a/tests/EventsFacadeTest.php
+++ b/tests/EventsFacadeTest.php
@@ -168,4 +168,25 @@ public function test_remove_all_listeners_through_the_facade(): void
$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);
+ }
}
From 7f6211d0bc22af5639631451045e09a4d181ddbb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Muhammet=20=C5=9Eafak?=
Date: Mon, 25 May 2026 09:15:44 +0300
Subject: [PATCH 7/8] Add PHPStan static analysis at level 8 and new CI
workflow with PHP 8.3 job
---
.github/workflows/ci.yml | 32 ++++++++++++++++++++++++++++++++
CHANGELOG.md | 15 ++++++++++-----
composer.json | 2 ++
phpstan.neon.dist | 23 +++++++++++++++++++++++
src/Event.php | 2 +-
src/EventEmitter.php | 21 +++++++++++++++++++--
src/EventEmitterInterface.php | 4 ++--
src/Events.php | 10 ++++++++++
tests/EventEmitterTest.php | 2 +-
9 files changed, 100 insertions(+), 11 deletions(-)
create mode 100644 phpstan.neon.dist
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c2a3c53..4b12cee 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -48,6 +48,38 @@ jobs:
- 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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 013481a..b40d9df 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -90,11 +90,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
**Coverage: 100% lines / 100% methods / 100% classes** across `src/`
(excluding `aliases.php`, which is verified by a dedicated BC alias
test instead).
-- New CI workflow (`.github/workflows/ci.yml`):
- - PHP 7.3 / 7.4 / 8.0 / 8.1 / 8.2 / 8.3 / 8.4 — `composer install`
- + `phpunit`.
- - 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
+- **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
diff --git a/composer.json b/composer.json
index 0437e35..c78586e 100644
--- a/composer.json
+++ b/composer.json
@@ -29,6 +29,7 @@
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.0",
+ "phpstan/phpstan": "^1.12",
"phpunit/phpunit": "^9.6"
},
"autoload": {
@@ -49,6 +50,7 @@
},
"scripts": {
"test": "phpunit",
+ "analyse": "phpstan analyse --no-progress",
"cs:check": "php-cs-fixer fix --dry-run --diff",
"cs:fix": "php-cs-fixer fix"
},
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/src/Event.php b/src/Event.php
index c76a3e2..5e62041 100644
--- a/src/Event.php
+++ b/src/Event.php
@@ -46,7 +46,7 @@ final class Event
/** @var EventEmitter */
protected $emitter;
- /** @var array */
+ /** @var list */
protected $debug = [];
/** @var bool */
diff --git a/src/EventEmitter.php b/src/EventEmitter.php
index 7233f54..bc52146 100644
--- a/src/EventEmitter.php
+++ b/src/EventEmitter.php
@@ -29,10 +29,15 @@
class EventEmitter implements EventEmitterInterface
{
- /** @var array */
+ /**
+ * Map of >>. Event names are
+ * always stored lower-cased so lookups are case-insensitive.
+ *
+ * @var array>>
+ */
protected $listeners = [];
- /** @var array */
+ /** @var array>> */
protected $onceListeners = [];
/**
@@ -116,6 +121,8 @@ public function removeAllListeners($event = null)
/**
* @inheritDoc
+ *
+ * @return list
*/
public function listeners($event = null)
{
@@ -164,6 +171,8 @@ public function listeners($event = null)
/**
* @inheritDoc
+ *
+ * @param array $arguments
*/
public function emit($event, $arguments = [])
{
@@ -206,6 +215,14 @@ public function clearOnceListeners($event = null)
}
}
+ /**
+ * @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)) {
diff --git a/src/EventEmitterInterface.php b/src/EventEmitterInterface.php
index a772249..4561722 100644
--- a/src/EventEmitterInterface.php
+++ b/src/EventEmitterInterface.php
@@ -49,14 +49,14 @@ public function removeAllListeners($event = null);
/**
* @param null|string $event
- * @return array
+ * @return list
* @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
*/
diff --git a/src/Events.php b/src/Events.php
index 1a2e297..3fcadae 100644
--- a/src/Events.php
+++ b/src/Events.php
@@ -46,11 +46,21 @@ class Events
/** @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);
diff --git a/tests/EventEmitterTest.php b/tests/EventEmitterTest.php
index b2cb601..de0b20e 100644
--- a/tests/EventEmitterTest.php
+++ b/tests/EventEmitterTest.php
@@ -245,7 +245,7 @@ public function test_remove_listener_rejects_non_callable_listener(): void
public function test_remove_listener_is_a_silent_noop_when_the_listener_is_not_registered(): void
{
$registered = function (): void {};
- $stranger = function (): void {};
+ $stranger = function (): void {};
// Hit both branches: removeListener walks BOTH the regular and
// once registries and, in each one, skips priority buckets
From 142df223817fa7ae6a6718d42951773721fad67c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Muhammet=20=C5=9Eafak?=
Date: Mon, 25 May 2026 09:54:24 +0300
Subject: [PATCH 8/8] Update autoload smoke test to standalone script in ci.yml
---
.github/workflows/ci.yml | 35 +++------------
tests/compat/autoload-smoke.php | 75 +++++++++++++++++++++++++++++++++
2 files changed, 81 insertions(+), 29 deletions(-)
create mode 100644 tests/compat/autoload-smoke.php
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4b12cee..caf529a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -138,32 +138,9 @@ jobs:
run: find src -type f -name '*.php' -print0 | xargs -0 -n1 php -l
- name: Autoload smoke test
- run: |
- php -r '
- spl_autoload_register(function ($class) {
- $prefix = "InitPHP\\\\Events\\\\";
- if (strpos($class, $prefix) !== 0) {
- return;
- }
- $relative = substr($class, strlen($prefix));
- $file = __DIR__ . "/src/" . str_replace("\\\\", "/", $relative) . ".php";
- if (is_file($file)) {
- require $file;
- }
- });
- require __DIR__ . "/src/aliases.php";
- $emitter = new InitPHP\Events\EventEmitter();
- $hit = false;
- $emitter->on("ping", function () use (&$hit) { $hit = true; });
- $emitter->emit("ping");
- if (!$hit) {
- fwrite(STDERR, "smoke test failed: listener not invoked\n");
- exit(1);
- }
- $legacy = "InitPHP\\\\EventEmitter\\\\EventEmitter";
- if (!class_exists($legacy)) {
- fwrite(STDERR, "smoke test failed: BC alias missing\n");
- exit(1);
- }
- echo "smoke ok\n";
- '
+ # 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/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";