Skip to content

Events and Logging

Precious Lyson edited this page Nov 13, 2026 · 2 revisions

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).

Overview

This subsystem answers two questions every application eventually asks:

  1. "Something happened — who cares?" → The Event dispatcher.
  2. "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.


The Big Picture

                ┌──────────────────────────────────────┐
                │             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:

  • EventListener is a true singleton. Event::listener() always returns the same instance unless Event::reset() is called.
  • Event is a static façade. Never instantiate it.
  • Factory only logs. It never dispatches.
  • Event::emit() generates an event ID via Factory::generateEventId() and passes it to the trigger — so dispatch logs and system logs can be correlated by event_id.

File Map

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.

Beginner's Guide

Your First Listener

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:

  1. $payload — whatever the dispatcher passed (often an array; system events pass rich context).
  2. $eventName — the normalized event name (e.g., user.login).

Triggering an Event

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. Use trigger() when you need full control. Use dispatch() when integrating with an object-oriented event system.

Emitting User vs. System Events

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.

Reading Logs

use Mlangeni\Machinjiri\Core\Artisans\Logging\LoggerFactory;

$logger = LoggerFactory::system('core', 'core', true);
$entries = $logger->get();       // today's entries
$entries = $logger->get(20250115); // specific date

Each 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"
  }
}

Intermediate Guide

Priorities

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 last

Internally: krsort($matched) on the priority map — so numeric keys are sorted descending.

Once-Listeners

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.

Wildcard Listeners

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 as user.* — not those matching a concrete event. Use hasListeners($event) (which checks patterns) to test matching.

Halting & Returning Values (until)

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.

Subscribers

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.

Queueable Listeners

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.

Immutable DispatchedEvent

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.


Advanced Guide

Recursion Guards

EventListener protects against:

  1. Circular dispatch: if event A is on the dispatch stack and someone dispatches A again, a MachinjiriException is thrown. This catches accidental Event::appBooted() inside an app.booted listener.
  2. 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'));   // ❌ CircularEventDispatchException

Note: 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.

Snapshot Dispatch Semantics

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.

The Event Facade in Depth

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.log

You can override at any time:

Event::setListener(new EventListener($customLogger));

System Event Logging Pipeline

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-log event_id and the emit()-generated event_id are 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 Customization

Logger supports:

  • Custom minimum level (LOG_LEVEL env or constructor arg).
  • Context interpolation: $logger->info("Hello {name}", ['name' => 'Ada']) → "Hello Ada".
  • Backtrace capture for DEBUG entries (5 frames, when includeBacktrace).
  • File rotation: one file per day, under <root>/<referrer>/<events|reports>/YYYY-MM-DD/.
  • get($date) to read historical logs. Pass 20250115 (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' => '…'],
    ],
]);

Module ID Ranges

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.


API Reference

Event API

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.

EventListener API

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.

Factory API

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.

Logger API

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).

LoggerFactory API

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 what Factory uses — it ignores config channels, so it works even before config is loaded.

DispatchedEvent API

Method Description
new DispatchedEvent($name, $id = null, $payload = null) Construct.
name() Event name.
id() Optional ID.
payload() Arbitrary payload.
withId(int) Immutable clone.

Event Catalog

User Events

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

System Events

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

Log File Layout

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 by Logger::resolveLogPath — note the order is referrer / events|reports / date. Inspect the class if you customize referrer or subdirectory.

Log Entry Format

{"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'].
  • DEBUG entries may include a backtrace key when enabled.

Best Practices

✅ Do

  • Use helper methods (Event::cacheHit()) instead of Event::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 QueueableListener for 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

  • 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 QueueableListener for anything > 50ms.

Priority Convention

Priority Use for
100+ Audit, security, logging
10 Business-critical (e.g., auth gates)
0 (default) Normal side effects
-50 Analytics, non-critical metrics

Troubleshooting

"Circular event dispatch detected for [foo]"

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
});

"Maximum event dispatch depth of 32 exceeded"

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));

Listener registered but never called

Check three things:

  1. Name normalization: 'User Login', 'user-login', 'user/login', 'user_login' all normalize to user.login. Verify with EventListener::describeListener and Event::events().
  2. Wildcard mismatch: 'users.*' won't match 'user.login' (plural vs singular).
  3. Snapshot semantics: if the listener was registered inside another listener for the same event, it won't fire until the next trigger.

LoggerFactory::system() throws "Disk [x] not configured"

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).

Logs not appearing

  • Verify Container::getInstance()->storage or storage_path() resolves correctly.
  • Check permissions on storage/logs/.
  • Check LOG_LEVEL env — if set to ERROR, debug entries (e.g., cache.hit) are silently dropped.
  • Verify the referrer — each Factory call uses system as referrer, but custom calls may differ.

"Logger: log file … not found!"

Logger::get($date) expects a numeric date (20250115), not 2025-01-15. The normalization strips -, , _ but not /.


Quick Reference Card

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);

Clone this wiki locally