Repository navigation
Events and Logging
A dual-purpose event dispatch and structured logging system for the Machinjiri Core framework — with a clean separation between user-facing events (dispatched only) and system events (dispatched and logged to module-scoped files).
This subsystem answers two questions every application eventually asks:
- "Something happened — who cares?" → The Event dispatcher.
- "Something happened — how do I record it?" → The Logger.
The framework deliberately splits concerns:
| Concern | Handled by | Dispatches listeners? | Writes to disk? |
|---|---|---|---|
User events (user.login, user.registered, …) |
Event::user*() |
✅ Yes | ❌ No |
System events (app.booted, cache.hit, …) |
Event::app*(), Event::cache*(), etc. |
✅ Yes | ✅ Yes (via Factory) |
| Pure logging | Factory::*() |
❌ No | ✅ Yes |
| Pure dispatching | EventListener::trigger() |
✅ Yes | ✅ Only dispatch metadata |
The result is a system where your application logic (e.g., "send a welcome email") responds to user events, while your observability (e.g., "the cache was hit for key X") lands in structured JSON logs.
┌──────────────────────────────────────┐
│ Application │
└──────────────────┬───────────────────┘
│
┌──────────────────────────┼──────────────────────────┐
│ │ │
Event::userLogin() Event::appBooted() Event::cacheHit()
│ │ │
▼ ▼ ▼
┌─────────┐ ┌──────────────┐ ┌──────────────┐
│ Event │─────────────▶│ Factory │ │ Factory │
│ facade │ (system │ (logging) │ │ (logging) │
└────┬────┘ events) └──────┬───────┘ └──────┬───────┘
│ │ │
│ LoggerFactory::system() │
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ EventListener │ │ system/core/… │ │ system/cache/… │
│ (singleton) │ │ .log files │ │ .log files │
└────────┬────────┘ └──────────────────┘ └──────────────────┘
│
▼
Your listeners
Key invariants:
-
EventListeneris a true singleton.Event::listener()always returns the same instance unlessEvent::reset()is called. -
Eventis a static façade. Never instantiate it. -
Factoryonly logs. It never dispatches. -
Event::emit()generates an event ID viaFactory::generateEventId()and passes it to the trigger — so dispatch logs and system logs can be correlated byevent_id.
| File | Role |
|---|---|
Event.php |
Static façade. Entry point for everything. Holds singleton reference. |
EventListener.php |
The dispatcher. Stores listeners, sorts by priority, handles wildcards, recursion. |
DispatchedEvent.php |
Immutable value object wrapping (name, id, payload). |
Factory.php |
System-event logger. Writes structured JSON via LoggerFactory::system(). |
QueueableListener.php |
Marker interface → listener is deferred via queueDispatcher. |
SubscriberInterface.php |
Contract for auto-subscribing event classes. |
Logger.php |
Custom PSR-3-ish JSON-line logger. Handles levels, backtraces, file rotation. |
LoggerFactory.php |
Builds Logger instances from config; provides system() shortcut. |
use Mlangeni\Machinjiri\Core\Artisans\Events\Event;
// Register a listener
Event::on('user.login', function ($payload, $eventName) {
$user = $payload['user'];
error_log("Welcome back, {$user['name']}!");
});
// Trigger it
Event::userLogin(['id' => 1, 'name' => 'Ada']);The listener receives two arguments:
-
$payload— whatever the dispatcher passed (often an array; system events pass rich context). -
$eventName— the normalized event name (e.g.,user.login).
There are three ways to trigger:
// 1. Raw trigger — no event ID generated
Event::trigger('my.event', ['foo' => 'bar']);
// 2. Emit — generates an ID via Factory, logs to EventListener's logger
Event::emit('my.event', ['foo' => 'bar']);
// 3. Object-based (PSR-14-ish)
Event::dispatch(new DispatchedEvent('my.event', null, ['foo' => 'bar']));
Event::dispatch($anyObjectWithNameProperty);Rule of thumb: use
emit()from application code. Usetrigger()when you need full control. Usedispatch()when integrating with an object-oriented event system.
User events — dispatched, not written to system logs:
Event::userLogin($user);
Event::userRegistered($user, ['referrer' => 'google']);
Event::userPasswordChanged($user);
Event::userAuthenticated($user);
Event::userAuthenticationFailed('ada@example.com');System events — dispatched and written to a module-specific log file:
Event::appBooted(); // → system/core/…/core.log
Event::cacheHit('users.42'); // → system/cache/…/cache.log
Event::jobFailed($job, $exception); // → system/queue/…/queue.log
Event::securityBreachDetected('sql-inj'); // → system/security/…/security.log (critical)The system event helpers delegate to Factory::<module>(), which writes an entry before dispatching. That means the log entry exists even if all listeners fail.
use Mlangeni\Machinjiri\Core\Artisans\Logging\LoggerFactory;
$logger = LoggerFactory::system('core', 'core', true);
$entries = $logger->get(); // today's entries
$entries = $logger->get(20250115); // specific dateEach entry is a decoded JSON object:
{
"timestamp": "2025-01-15T10:23:44.123456+00:00",
"level": "INFO",
"message": "app.booted",
"context": {
"event_id": 10012345,
"module": "core",
"module_id": 100,
"env": "production"
}
}Higher priority runs first. Default is 0. Values can be negative.
Event::on('user.login', $auditLogger, 100); // runs first
Event::on('user.login', $notifier, 0); // runs second
Event::on('user.login', $analytics, -50); // runs lastInternally: krsort($matched) on the priority map — so numeric keys are sorted descending.
Fire exactly once, then auto-remove:
Event::once('app.booted', function () {
// bootstrap connection pools
});The once flag is checked after the listener runs successfully. If it throws and $halt === false, it is not removed — it will retry on the next trigger. This is deliberate: once-listeners that fail are usually worth retrying.
Use * in the registered pattern. Matching is done with fnmatch() against the normalized event name.
Event::on('user.*', $auditLogger); // matches user.login, user.logout, …
Event::on('*.failed', $failureHandler); // matches job.failed, task.failed, …
Event::on('cache.*', $cacheMetrics); // matches cache.hit, cache.missed, …
⚠️ Wildcards are only expanded at dispatch time, not at registration.Event::listeners('user.*')returns only listeners registered literally asuser.*— not those matching a concrete event. UsehasListeners($event)(which checks patterns) to test matching.
Normal trigger() returns an array of non-null responses:
$responses = Event::trigger('order.calculate', $order);
// ['total' => 99.99, 'discount' => 5.00]until() returns the first non-null response and stops:
$result = Event::until('order.calculate', $order);
// 'total' => 99.99 (first listener wins)Use until() for "first handler that knows the answer" patterns — e.g., resolving a route, finding a cache hit, or picking an authentication provider.
If a listener throws during until(), the exception propagates. During trigger(), exceptions are logged and isolated by default.
A subscriber groups multiple listeners on one class:
use Mlangeni\Machinjiri\Core\Artisans\Events\SubscriberInterface;
class AuditSubscriber implements SubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
'user.login' => ['onLogin', 10],
'user.logout' => ['onLogout', 0],
'security.*' => [['onSecurity', 100], ['onSecurityDebug', 0]],
'task.*' => ['onTask' => 5], // method => priority form
];
}
public function onLogin($payload) { /* … */ }
public function onLogout($payload) { /* … */ }
public function onSecurity($payload) { /* … */ }
public function onSecurityDebug($payload) { /* … */ }
public function onTask($payload) { /* … */ }
}
Event::subscribe(new AuditSubscriber());All four return-shape variations documented on SubscriberInterface are supported.
When a listener is expensive (email, HTTP call, DB write), don't block the request. Implement QueueableListener:
use Mlangeni\Machinjiri\Core\Artisans\Events\QueueableListener;
class SendWelcomeEmail implements QueueableListener
{
public function __invoke($payload) { /* … */ }
}
// Register the dispatcher once (e.g., at boot)
Event::setQueueDispatcher(function (QueueableListener $listener, string $event, $payload) {
app('queue')->push($listener, compact('event', 'payload'));
});
Event::on('user.registered', new SendWelcomeEmail());If no queue dispatcher is set, queueable listeners run inline — a safe fallback for development or single-threaded scripts.
DispatchedEvent is a value object — all properties are protected, and there are no setters. The only way to "modify" one is withId(), which returns a new instance:
$original = new DispatchedEvent('user.login', null, ['user' => $u]);
$withId = $original->withId(12345);
assert($original->id() === null);
assert($withId->id() === 12345);You can pass a DispatchedEvent to Event::dispatch(); the listener will receive the inner payload and the id will be forwarded to the trigger's correlation logging.
EventListener protects against:
-
Circular dispatch: if
event Ais on the dispatch stack and someone dispatchesAagain, aMachinjiriExceptionis thrown. This catches accidentalEvent::appBooted()inside anapp.bootedlistener. -
Depth explosion: max depth is
32. Bump it if you have legitimately deep chains ($listener->setMaxDepth(64)— not exposed publicly; subclass if needed).
Event::on('a', fn() => Event::trigger('b')); // ✅ b dispatched from a
Event::on('a', fn() => Event::trigger('a')); // ❌ CircularEventDispatchExceptionNote: the guard is on the normalized name, so
'user.login'and'user-login'are considered the same event. Wildcards are not covered —'user.*'won't match the stack check against'user.login'. If you rely on wildcard chaining, watch for this.
When trigger() starts, it freezes the listener list for that dispatch:
Event::on('boot', function () {
Event::on('boot', function () { echo "B"; }); // registered DURING dispatch
});
Event::on('boot', fn() => print("A"));
Event::trigger('boot');
// Output: "A" — the new listener is NOT called this round.This prevents infinite loops from listeners that register more listeners. The newly-registered listener will fire on the next trigger('boot').
Consequence: once removal and on/off calls during dispatch don't affect the current iteration — they affect the next one.
Event is stateless except for self::$listener. This makes it safe to reset() between test cases:
protected function setUp(): void { Event::reset(); }Event::boot($logger) lets you inject a custom Logger. By default, booting creates a system logger:
LoggerFactory::system('events', 'core', true);
// → storage/logs/system/events/YYYY-MM-DD/core.events.logYou can override at any time:
Event::setListener(new EventListener($customLogger));When you call, say, Event::cacheHit('users.42'):
1. Factory::cache('cache.hit', ['key' => 'users.42', ...])
└─ Factory::log('cache', 'cache.hit', 2100, $context, 'debug')
├─ generateEventId(2100) → e.g. 210156789
├─ LoggerFactory::system('cache', 'cache', true)
│ └─ new Logger('cache', DEBUG, true, 'cache', 'system')
│ └─ path: storage/logs/system/events/2025-01-15/system.cache.log
└─ $logger->debug('cache.hit', $context)
2. Event::emit('cache.hit', ['key' => 'users.42', ...])
├─ Factory::generateEventId() → a DIFFERENT random ID
└─ EventListener::trigger(…)
└─ EventListener's logger logs: "Event triggered: cache.hit"
⚠️ Note: the system-logevent_idand theemit()-generatedevent_idare different numbers — the correlation isn't currently wired. If you need correlation, pass the ID manually:$id = Factory::cache('cache.hit', ['key' => $k]); // logged Event::trigger('cache.hit', ['key' => $k], false, $id); // dispatched with same ID
Logger supports:
-
Custom minimum level (
LOG_LEVELenv or constructor arg). -
Context interpolation:
$logger->info("Hello {name}", ['name' => 'Ada'])→"Hello Ada". -
Backtrace capture for
DEBUGentries (5 frames, whenincludeBacktrace). -
File rotation: one file per day, under
<root>/<referrer>/<events|reports>/YYYY-MM-DD/. -
get($date)to read historical logs. Pass20250115(numeric) — the implementation normalizes the filename.
Configure via LoggerFactory constructor:
new LoggerFactory([
'default' => 'daily',
'channels' => [
'daily' => [
'disk' => 'local',
'log_file' => 'app',
'min_level' => Logger::INFO,
'is_event' => false, // false → reports/, true → events/
'referrer' => 'myapp',
],
],
'disks' => [
'local' => ['driver' => 'local', 'root' => '/var/log/myapp'],
'ftp' => ['driver' => 'ftp', 'host' => '…'],
],
]);Factory reserves numeric ID spaces per module (see constants). The generated event ID is:
$random = random_digit_5_to_5();
$eventId = $moduleId + $random;So CORE_ID (100) produces IDs in 100–100999, DATABASE_ID (1000) in 1000–100999, etc. Overlap between modules is possible if the random 5-digit number pushes into the next block (e.g., 100 + 99999 = 100099 collides with DATABASE's low end). Treat the ID as a correlation hint, not a globally unique identifier.
| Method | Description |
|---|---|
boot(?Logger $logger) |
Initialize singleton. Returns EventListener. |
setListener(EventListener) |
Swap the underlying listener. |
listener() |
Lazy-boot and return the singleton. |
reset() |
Tear down (useful in tests). |
on($event, $cb, $prio) |
Register listener. |
once($event, $cb, $prio) |
Register one-shot listener. |
subscribe($subscriber) |
Register via SubscriberInterface. |
trigger($e, $p, $halt, $id) |
Raw dispatch. |
emit($e, $p) |
Dispatch with generated ID. |
dispatch($object) |
Object-based dispatch. |
until($e, $p) |
First non-null response. |
off($event, $cb) |
Remove one listener. |
flush($event) |
Remove all listeners for event. |
flushAll() |
Remove everything. |
has($event) |
Check for listeners (includes wildcards). |
events() |
List registered event names. |
listeners($event) |
Return listener list. |
display() |
Print events (debug helper). |
setQueueDispatcher(callable) |
Defer queueable listeners. |
Event helper methods — see Event Catalog.
Public methods: getInstance, resetInstance, setQueueDispatcher, on, once, subscribe, trigger, dispatch, until, removeListener, removeAllListeners, flushAll, getEvents, getListeners, hasListeners, plus static describeListener.
Protected: resolveListeners, invalidateCache, guardRecursion, normalizeEvent, normalizeSubscriberHandler.
| Method | Module | Default Level |
|---|---|---|
generateEventId(?int) |
— | — |
log($module, $event, $moduleId, $ctx, $lvl) |
given | info |
core, http, ldap, network, notifications, task, uuid, container, webhook, database, auth, artisan, filesystem, routing, transport, view, queue, integration, ui
|
named | info |
exception |
exceptions |
error |
security |
security |
warning |
debugger |
debugger |
debug |
cache |
cache |
debug |
All return the generated int event ID.
| Method | Description |
|---|---|
emergency, alert, critical, error, warning, notice, info, debug, log
|
PSR-3 levels. |
pushContext(array) |
Merge into default context. |
resetContext() |
Clear default context. |
get(?int $date) |
Read entries (current file or YYYYMMDD). |
setIncludeBacktrace(bool) |
Toggle debug backtraces. |
setFilesystem(Filesystem) |
Inject filesystem (used by LoggerFactory). |
| Method | Description |
|---|---|
new LoggerFactory($config) |
Configure. |
logger(?string $channel) |
Get cached logger for channel. |
system($log, $process, $event) |
Shortcut — bypasses config; directly returns a Logger writing under system/. |
LoggerFactory::system()is whatFactoryuses — it ignores config channels, so it works even before config is loaded.
| Method | Description |
|---|---|
new DispatchedEvent($name, $id = null, $payload = null) |
Construct. |
name() |
Event name. |
id() |
Optional ID. |
payload() |
Arbitrary payload. |
withId(int) |
Immutable clone. |
| Method | Event name | Payload |
|---|---|---|
userLogin |
user.login |
user, context
|
userLogout |
user.logout |
user, context
|
userRegistered |
user.registered |
user, context
|
userUpdated |
user.updated |
user, context
|
userDeleted |
user.deleted |
user, context
|
userPasswordChanged |
user.password.changed |
user, context
|
userPasswordResetRequested |
user.password.reset.requested |
user, context
|
userPasswordReset |
user.password.reset |
user, context
|
userEmailVerified |
user.email.verified |
user, context
|
userProfileUpdated |
user.profile.updated |
user, context
|
userRoleAssigned |
user.role.assigned |
user, role, context
|
userRoleRevoked |
user.role.revoked |
user, role, context
|
userAuthenticated |
auth.authenticated |
user, context
|
userAuthenticationFailed |
auth.failed |
identifier, context
|
userLockedOut |
auth.locked |
user, context
|
userTwoFactorEnabled |
auth.2fa.enabled |
user, context
|
userTwoFactorDisabled |
auth.2fa.disabled |
user, context
|
| Method | Event name | Log module | Level |
|---|---|---|---|
appBooted |
app.booted |
core | info |
appShutdown |
app.shutdown |
core | info |
exceptionThrown($e) |
app.exception |
exceptions | error |
requestHandled |
http.request.handled |
http | info |
requestRejected |
http.request.rejected |
http | warning |
routeMatched |
routing.route.matched |
routing | info |
jobDispatched |
queue.job.dispatched |
queue | info |
jobFailed |
queue.job.failed |
queue | error |
jobCompleted |
queue.job.completed |
queue | info |
cacheHit |
cache.hit |
cache | debug |
cacheMissed |
cache.missed |
cache | debug |
cacheFlushed |
cache.flushed |
cache | debug |
databaseQueryExecuted |
database.query.executed |
database | debug / warning (>1000ms) |
migrationCompleted |
database.migration.completed |
database | info |
securityBreachDetected |
security.breach.detected |
security | critical |
accessDenied |
security.access.denied |
security | warning |
fileUploaded |
filesystem.file.uploaded |
filesystem | info |
fileDeleted |
filesystem.file.deleted |
filesystem | info |
notificationSent |
notification.sent |
notifications | info |
webhookReceived |
webhook.received |
webhooks | info |
webhookDelivered |
webhook.delivered |
webhooks | info |
integrationCallSucceeded |
integration.call.succeeded |
integrations | info |
integrationCallFailed |
integration.call.failed |
integrations | error |
viewRendered |
view.rendered |
view | info |
componentRendered |
ui.component.rendered |
ui | info |
taskStarted |
task.started |
task | info |
taskCompleted |
task.completed |
task | info |
taskFailed |
task.failed |
task | error |
storage/
└── logs/
├── system/
│ ├── events/
│ │ └── 2025-01-15/
│ │ ├── system.core.log
│ │ ├── system.cache.log
│ │ ├── system.queue.log
│ │ ├── system.security.log
│ │ └── …
│ └── reports/
│ └── 2025-01-15/
│ └── app.app.log
└── myapp/
└── events/
└── 2025-01-15/
└── myapp.events.log
Filename pattern (from Logger::createLogFile):
<path>/<referrer>.<logFilename>.log
With referrer = 'system', logFilename = 'core':
system/core/events/2025-01-15/system.core.log
⚠️ Directory segments are driven byLogger::resolveLogPath— note the order isreferrer / events|reports / date. Inspect the class if you customizereferrerorsubdirectory.
{"timestamp":"2025-01-15T10:23:44.123456+00:00","level":"INFO","message":"app.booted","context":{"event_id":10012345,"module":"core","module_id":100,"env":"production"}}Notes:
-
One JSON object per line (JSONL). This makes
grep,jq, and streaming tail trivial. -
Objects in context are reduced to
[object ClassName](unless they implement__toString). -
Placeholders in messages are interpolated:
{name}→$ctx['name']. -
DEBUGentries may include abacktracekey when enabled.
-
Use helper methods (
Event::cacheHit()) instead ofEvent::emit('cache.hit', ...)— you get logging for free. - Listen for user events to trigger side effects (emails, notifications, audit) rather than coupling them into controllers.
-
Use
QueueableListenerfor anything that touches the network or blocks the request. -
Wildcards + high priority for cross-cutting concerns:
Event::on('user.*', $audit, 100). -
Event::reset()in tests to avoid cross-test leakage from the singleton. -
Return values from listeners when you want
until()to halt the chain. -
Throw when a listener is critical and you dispatch with
$halt = true— otherwise exceptions are swallowed.
- Don't emit user events from system-event listeners in a loop — you'll hit the recursion guard.
- Don't assume event IDs are unique across modules; treat them as correlation hints.
- Don't rely on listener registration order — always use priorities explicitly when it matters.
- Don't mutate the payload and expect other listeners to see it — payloads are passed by value (for arrays). If you need shared state, pass an object.
-
Don't
flushAll()in a long-running worker unless you intend to drop listeners globally. -
Don't skip
QueueableListenerfor anything > 50ms.
| Priority | Use for |
|---|---|
100+ |
Audit, security, logging |
10 |
Business-critical (e.g., auth gates) |
0 (default) |
Normal side effects |
-50 |
Analytics, non-critical metrics |
A listener for foo triggered foo again. Find the listener with:
foreach (Event::listeners('foo') as $l) {
echo EventListener::describeListener($l) . PHP_EOL;
}Fix by dispatching a different event, or by guarding with a flag:
$fired = false;
Event::on('app.booted', function () use (&$fired) {
if ($fired) return;
$fired = true;
// …safe to dispatch again
});You have a chain deeper than 32 events. Either flatten the chain, or dispatch in phases. There is no public setter for maxDepth — subclass EventListener if you truly need more:
class DeepEventListener extends EventListener {
protected int $maxDepth = 128;
}
Event::setListener(new DeepEventListener($logger));Check three things:
-
Name normalization:
'User Login','user-login','user/login','user_login'all normalize touser.login. Verify withEventListener::describeListenerandEvent::events(). -
Wildcard mismatch:
'users.*'won't match'user.login'(plural vs singular). - Snapshot semantics: if the listener was registered inside another listener for the same event, it won't fire until the next trigger.
This is thrown only from LoggerFactory::build() (config channels). system() bypasses config — if you hit this via system(), ensure LoggerFactory::$config is initialized (it isn't strictly required by system(), but subclass overrides might read it).
- Verify
Container::getInstance()->storageorstorage_path()resolves correctly. - Check permissions on
storage/logs/. - Check
LOG_LEVELenv — if set toERROR,debugentries (e.g.,cache.hit) are silently dropped. - Verify the referrer — each
Factorycall usessystemas referrer, but custom calls may differ.
Logger::get($date) expects a numeric date (20250115), not 2025-01-15. The normalization strips -, , _ but not /.
use Mlangeni\Machinjiri\Core\Artisans\Events\Event;
use Mlangeni\Machinjiri\Core\Artisans\Events\QueueableListener;
use Mlangeni\Machinjiri\Core\Artisans\Events\SubscriberInterface;
// Register
Event::on('user.login', $fn, 10);
Event::once('app.booted', $fn);
Event::subscribe(new MySubscriber());
Event::on('user.*', $audit, 100);
// Dispatch
Event::userLogin($user);
Event::trigger('custom', $payload);
Event::emit('custom', $payload);
Event::until('custom', $payload); // first non-null
Event::dispatch($eventObject);
// Queue
Event::setQueueDispatcher(fn($l, $e, $p) => queue()->push($l, $e, $p));
Event::on('user.registered', new SendEmail()); // SendEmail implements QueueableListener
// Introspect
Event::has('user.login');
Event::events();
Event::listeners('user.login');
Event::off('user.login', $fn);
Event::flush('user.login');
Event::flushAll();
// System logging (dispatches + logs)
Event::cacheHit('users.42');
Event::securityBreachDetected('sql-injection', ['ip' => $ip]);
Event::exceptionThrown($e, ['route' => '/checkout']);
// Read logs
$entries = LoggerFactory::system('core', 'core', true)->get();
$entries = LoggerFactory::system('cache', 'cache', true)->get(20250115);Machinjiri Docs
- Home
- Learn
- Build
-
Tutorials
- Routing
- Controllers and Middleware
- Views and Templates
- Database ORM
- Eloquent Database ORM
- Errors and Exceptions
- Forms and Validation
- Http
- Events and Logging
- Services and DI
- Security
- Authentication
- Caching
- File System
- Date and Time
- Debugging
- Queue and Jobs
- Task Scheduling
- Transport (Mail, SMS)
- LDAP
- Notifications
- Webhooks
- UUID Generations
- Terminal
- Helper Function
- Reference