-
Notifications
You must be signed in to change notification settings - Fork 2
Simulate and Debug
The Events facade (and the underlying
Event class it wraps) ships
two opt-in modes for tests, dry-runs, and observability:
-
Simulate mode —
trigger()walks the listener list as usual but does not actually call the listeners. -
Debug mode —
trigger()records a timing breadcrumb per listener to an in-memory log you can retrieve.
Both modes are per-process; toggle them with the static setters and inspect with the matching getters.
The low-level
EventEmitterclass does not expose simulate or debug; both features live one level up inEvent.
When simulate is true, Events::trigger():
- Still resolves the listener list for the event name.
-
Skips every
call_user_func_array()call — listeners are never invoked. - Treats every listener as if it returned
true, so the chain never short-circuits. - Returns
truefromtrigger().
That last point is important: simulate mode cannot observe veto
returns (return false), because no listener actually runs.
use InitPHP\Events\Events;
$emails = 0;
Events::on('mailout', function () use (&$emails) {
$emails++;
mail(/* … real send … */);
});
Events::setSimulate(true);
Events::trigger('mailout'); // returns true; $emails stays 0
Events::trigger('mailout'); // same
Events::setSimulate(false);
Events::trigger('mailout'); // now actually sends — $emails == 1- Tests that should exercise the registration logic without triggering real side effects.
-
Dry-run commands (
--simulatestyle CLIs) where users want to see which hooks would fire. - Debug surfaces ("preview what saving this would trigger") in admin panels.
Simulate is global state on the singleton facade. Forgetting to
setSimulate(false) after a test will silently disable every following
trigger() in the same process. In test suites, the cleanest fix is
Events::reset() in setUp() / tearDown() — that drops the
singleton entirely, including the simulate/debug flags and every
registered listener:
protected function setUp(): void
{
\InitPHP\Events\Events::reset();
}
protected function tearDown(): void
{
\InitPHP\Events\Events::reset();
}If you prefer to keep the listeners around between tests but reset just the flags, the explicit setters still work:
protected function tearDown(): void
{
\InitPHP\Events\Events::setSimulate(false);
\InitPHP\Events\Events::setDebugMode(false);
\InitPHP\Events\Events::clearDebug();
}When debug is true, every iteration of Events::trigger() appends one
record to an internal array:
[
'start' => float, // microtime(true) before the listener
'end' => float, // microtime(true) after the listener
'event' => string, // the (case-preserving) name passed to trigger()
]You retrieve the records with Events::getDebug(). The array
accumulates across every trigger() call until the process ends or
you call Events::clearDebug().
use InitPHP\Events\Events;
Events::setDebugMode(true);
Events::on('boot', function () { usleep(1_500); });
Events::on('boot', function () { usleep( 400); });
Events::on('warm', function () { usleep( 200); });
Events::trigger('boot');
Events::trigger('warm');
print_r(Events::getDebug());
/*
Array
(
[0] => Array ( [start] => 1716567121.4521 [end] => 1716567121.4538 [event] => boot )
[1] => Array ( [start] => 1716567121.4538 [end] => 1716567121.4542 [event] => boot )
[2] => Array ( [start] => 1716567121.4542 [end] => 1716567121.4544 [event] => warm )
)
*/The number of debug records equals the number of listener invocations.
A single trigger('boot') with two listeners produces two records,
both with event => 'boot'. The duration of an individual listener is
end - start; the duration of the entire trigger() call is the sum of
its rows (or, more precisely, the span from the first listener's start
to the last listener's end).
When both modes are on, you still get one debug record per listener,
with start and end essentially equal (the call itself was skipped):
Events::setSimulate(true);
Events::setDebugMode(true);
Events::trigger('boot');
// debug rows are emitted, but end - start is near-zeroThis is useful when you want to inspect which listeners would run without actually executing them.
getDebug() returns a copy of the live array. To reset it between
phases, call Events::clearDebug():
Events::setDebugMode(true);
// … run phase 1 …
$phase1 = Events::getDebug();
Events::clearDebug();
// … run phase 2 …
$phase2 = Events::getDebug();clearDebug() returns the shared Event instance, so it is chainable.
In a long-running worker you can also call Events::reset() at the
boundary of each job to drop the singleton entirely — that wipes the
debug log along with every registered listener.
If you need finer-grained control, build your own profiling listener on
top of the EventEmitter — microtime(true) plus an
array is the entire mechanism.
public function test_trigger_records_a_debug_row(): void
{
\InitPHP\Events\Events::setDebugMode(true);
\InitPHP\Events\Events::on('audit', fn() => null);
\InitPHP\Events\Events::trigger('audit');
$debug = \InitPHP\Events\Events::getDebug();
self::assertNotEmpty($debug);
self::assertSame('audit', end($debug)['event']);
}Remember to disable both modes in tearDown.
A common pattern in CLI dry-run mode:
$dry = in_array('--dry-run', $argv, true);
\InitPHP\Events\Events::setSimulate($dry);
\InitPHP\Events\Events::setDebugMode($dry);
// … register listeners, run the pipeline …
\InitPHP\Events\Events::trigger('export', $payload);
if ($dry) {
echo "Would have run these listeners:\n";
foreach (\InitPHP\Events\Events::getDebug() as $i => $row) {
printf(" #%d %s\n", $i + 1, $row['event']);
}
}-
Eventsfacade — the static API that exposes both modes. - API Reference — exact signatures and return types.
- Recipes — practical patterns built on simulate/debug.
initphp/events · MIT License · part of the InitPHP family
Source · Issues · Discussions · Packagist · Contributing · Security Policy
Getting Started
Core APIs
Practical
Reference