Repository navigation
Debugging
The Machinjiri Debugging System provides two complementary classes:
| Class | Purpose |
|---|---|
Debugger |
Framework-aware facade for logging, timing, memory tracking, backtraces, and events. |
Dumper |
Standalone, beautifully formatted variable inspection for CLI and browser. |
- ✅ Environment-aware — auto-disables in production
- ✅ Structured logging — integrates with
LoggerandEventListener - ✅ Timing utilities — start/stop timers, measure callables
- ✅ Memory profiling — snapshots, diffs, and formatting
- ✅ Rich variable dumps — HTML with dark mode + CLI formatting
- ✅ Security-first — automatic redaction of passwords, tokens, API keys
- ✅ Circular reference protection — safe to dump complex object graphs
- ✅ Zero-exit testing —
dd()can be intercepted in tests - ✅ Event hooks — listen to every debug action
use Mlangeni\Machinjiri\Core\Debug\Debugger;
use Mlangeni\Machinjiri\Core\Debug\Dumper;
// Simple logging
Debugger::getInstance()->info('Application booted');
// Dump a variable
Dumper::dump($user);
// Time an operation
$debug = Debugger::getInstance();
$debug->startTimer('db_query');
$results = $db->query('SELECT * FROM users');
$elapsed = $debug->stopTimer('db_query');
// Dump and die
Dumper::dd($results);A singleton-style facade that wraps framework services and provides high-level debugging utilities.
- Environment detection (auto-enable in dev, disable in prod)
- Message logging with caller context
- Timer and memory profiling
- Query log collection
- Event broadcasting (
debug.log,debug.dump,debug.timer.start, etc.) - Convenience forwarding to logger levels (
->info(),->error(), …)
$debug = new Debugger($container); // explicit
$debug = Debugger::getInstance(); // singleton (uses Container if present)On construction, Debugger:
- Resolves the current
Container(or accepts one). - Auto-detects development mode via
$app->isDevelopment(). - Tries to resolve a
LoggerandEventListenerfrom the container. - Falls back to
LoggerFactory::system("debugger", "debug", false).
A stateless helper (all methods are static) that renders variables in human-readable form.
- CLI output — aligned, indented, colored (via ANSI where supported)
- HTML output — modern, accessible "cozy dump" card with dark mode
- Reflection-based — shows private/protected properties from ancestors
- Redaction — filters sensitive keys automatically
- Cycle-safe — tracks seen object IDs
-
Truncation — bounded by
maxDepthandmaxItems -
Special-cases —
DateTimeInterface,Closure, enums
$debug = Debugger::getInstance();
$debug->info('User logged in', ['user_id' => 42]);Available convenience levels (via __call):
$debug->debug(...);
$debug->info(...);
$debug->notice(...);
$debug->warning(...);
$debug->error(...);
$debug->critical(...);
$debug->alert(...);
$debug->emergency(...);Dumper::dump($user);In CLI, you'll see:
App\Models\User {
public id => 42
public name => "Alice"
private password => "***REDACTED***"
}
In browser, you'll see a styled card with syntax coloring.
Dumper::dd($response);In production mode this renders a generic error page instead of leaking data.
$debug->startTimer('report');
generateReport();
$elapsed = $debug->stopTimer('report');
echo "Took {$elapsed}s";$rows = $debug->measure(fn() => $db->fetchAll('users'), 'fetch_users');Debugger::log() automatically attaches file, line, and function from the caller:
// In App/Services/UserService.php line 58
$debug->info('Registered user');
// Logs: { message: "Registered user", context: { file: "UserService.php", line: 58, ... } }$debug->memorySnapshot('before_import');
$importer->run($file);
$debug->memorySnapshot('after_import');
$diff = $debug->memoryDiff('before_import', 'after_import');
echo "Import used " . round($diff / 1024, 2) . " KB";// Log a backtrace
$trace = $debug->backtrace(10);
// Or print it beautifully
$debug->printBacktrace(5);Every action fires an event you can listen to:
$debug->on('debug.log', function ($payload) {
// $payload = ['message' => ..., 'level' => ..., 'context' => ...]
file_put_contents('/tmp/audit.log', $payload['message'] . PHP_EOL, FILE_APPEND);
});
$debug->on('debug.timer.stop', function ($e) {
if ($e['elapsed'] > 1.0) {
error_log("Slow operation: {$e['name']} took {$e['elapsed']}s");
}
});Enable it and feed SQL through logQuery():
$debug->enableQueryLog();
// In your DB layer:
$start = microtime(true);
$pdo->query($sql);
$debug->logQuery($sql, $bindings, microtime(true) - $start);
// Later:
foreach ($debug->getQueryLog() as $q) {
printf("[%sms] %s\n", round($q['time'] * 1000, 2), $q['query']);
}Dumper::configure([
'maxDepth' => 8,
'maxItems' => 200,
'sensitiveKeys' => ['password', 'token', 'ssn', 'credit_card'],
'redact' => '[HIDDEN]',
]);Useful for logging or test assertions:
$string = Dumper::format($user); // CLI-style text
$html = Dumper::format($user, true); // HTML fragment
$logger->debug("State: " . $string);use Mlangeni\Machinjiri\Core\Artisans\Logging\Logger;
$debug->setLogger($myCustomLogger);$debug->setEventListener($myCustomDispatcher);Dumper::$exitHandler lets you turn a fatal dump into a catchable exception:
use PHPUnit\Framework\Assert;
protected function setUp(): void
{
Dumper::$exitHandler = function (int $code) {
throw new \RuntimeException("dd() called with code {$code}");
};
}
public function testHandlesBadInput(): void
{
$this->expectException(\RuntimeException::class);
processBadInput(); // Contains a dd()
}
⚠️ Important: Always reset$exitHandler = nullintearDown().
You can capture the dumped output by combining format() with the exit handler:
$captured = null;
Dumper::$exitHandler = function (int $code) use (&$captured) {
throw new \RuntimeException('dd() intercepted');
};
try {
Dumper::dd($payload);
} catch (\RuntimeException $e) {
// Assert output separately via format()
$captured = Dumper::format($payload, true);
}
$this->assertStringContainsString('machinjiri-dump', $captured);Because every operation emits an event, you can aggregate metrics:
$stats = ['timers' => [], 'logs' => 0];
$debug->on('debug.timer.stop', function ($e) use (&$stats) {
$stats['timers'][] = $e;
});
$debug->on('debug.log', function () use (&$stats) {
$stats['logs']++;
});
// ... run app ...
render_dashboard($stats);Objects often hold credentials. Override the sensitive key list globally or locally:
Dumper::configure([
'sensitiveKeys' => array_merge(
['password', 'secret', 'token', 'api_key'],
['db_dsn', 'stripe_live_key', 'webhook_secret']
),
]);Keys are matched case-insensitively via str_contains, so "UserPassword", "ACCESS_TOKEN", and "db_dsn" are all caught.
Dumper tracks spl_object_id per dump session. This is safe:
$a = new stdClass();
$b = new stdClass();
$a->peer = $b;
$b->peer = $a;
Dumper::dump($a); // Second visit to $a shows { circular ref }enum Status: string { case Active = 'active'; }
Dumper::dump(Status::Active); // App\Enums\Status::Active
$fn = fn($x) => $x * 2;
Dumper::dump($fn); // Closure(/path/to/file.php:12)Debugger is fully functional without a framework:
$debug = new Debugger(); // No app, no logger → falls back to LoggerFactory
$debug->info('Standalone use');if ($request->header('X-Debug') === '1') {
$debug->enable();
}
// Later, to force-disable before sending a response:
$debug->disable();Note: disable() blocks all subsequent operations including dd() (which will render a generic error page instead of dumping).
Because all state is private, subclassing requires care. Prefer composition:
final class MyDumper
{
public static function dumpJson(mixed $v): void
{
echo json_encode($v, JSON_PRETTY_PRINT | JSON_PARTIAL_OUTPUT_ON_ERROR);
}
}A common pattern:
if (Debugger::getInstance()->isEnabled()) {
Dumper::dd($state);
}
abort(500);| Method | Returns | Description |
|---|---|---|
__construct(?Container $app = null) |
void |
Resolves container, logger, events. |
getInstance() |
self |
Singleton accessor. |
enable() |
self |
Enable and log the change. |
disable() |
self |
Disable and log the change. |
isEnabled() |
bool |
Current state. |
setLogger(Logger $logger) |
self |
Inject logger. |
setEventListener(EventListener $events) |
self |
Inject event bus. |
log($message, string $level = 'debug', array $context = []) |
self |
Core logging method. |
dump(...$args) |
self |
Delegate to Dumper::dump(). |
dd(...$args) |
never |
Delegate to Dumper::dd(). |
startTimer(string $name = 'default') |
self |
Begin a named timer. |
stopTimer(string $name = 'default', bool $log = true) |
?float |
End timer, return elapsed seconds. |
measure(callable $fn, string $label = 'anonymous', bool $log = true) |
mixed |
Time a callable and return its result. |
memorySnapshot(string $label = 'default') |
self |
Capture memory. |
memoryDiff(string $start, string $end) |
?int |
Bytes between snapshots. |
logMemory(string $label = 'Current memory') |
self |
Log current + peak usage. |
backtrace(int $limit = 0, int $options = DEBUG_BACKTRACE_PROVIDE_OBJECT) |
array |
Get and log a backtrace. |
printBacktrace(int $limit = 0) |
self |
Dump a backtrace. |
logQuery(string $query, array $bindings = [], ?float $time = null) |
self |
Append to query log. |
enableQueryLog() / disableQueryLog()
|
self |
Toggle query collection. |
getQueryLog() / clearQueryLog()
|
array / self
|
Access/clear query log. |
on(string $event, callable $listener, int $priority = 0) |
self |
Subscribe to an event. |
__call(string $method, array $args) |
mixed |
Forward to log levels. |
| Event | Payload |
|---|---|
debug.log |
['message' => string, 'level' => string, 'context' => array] |
debug.dump |
['args' => array] |
debug.timer.start |
['name' => string] |
debug.timer.stop |
['name' => string, 'elapsed' => float] |
debug.memory.snapshot |
['label' => string, 'bytes' => int] |
debug.backtrace |
['trace' => array] |
debug.query |
['query' => string, 'bindings' => array, 'time' => ?float, 'timestamp' => float] |
| Method | Returns | Description |
|---|---|---|
configure(array $config) |
void |
Merge config over defaults. |
dump(mixed ...$args) |
void |
Print each argument. |
dd(mixed ...$args) |
never |
Dump then exit (or invoke $exitHandler). |
format(mixed $var, bool $html = false) |
string |
Render without emitting. |
static $exitHandler |
?callable |
Override for tests. |
| Key | Default | Description |
|---|---|---|
maxDepth |
16 |
Recursion depth cap. |
maxItems |
1000 |
Item cap per container. |
sensitiveKeys |
['password', 'passwd', …] |
Keys to redact (case-insensitive substring match). |
redact |
'***REDACTED***' |
Replacement value. |
| Environment | Debugger::enabled |
|---|---|
Development (isDevelopment() === true) |
true |
Production (isDevelopment() === false) |
false |
| No container |
true (default) |
Override at runtime:
$debug->enable(); // force on
$debug->disable(); // force off// In your bootstrap:
$debug = Debugger::getInstance();
if (!$app->isDevelopment()) {
$debug->disable();
Dumper::configure([
'maxDepth' => 4,
'maxItems' => 50,
]);
}Dumper redacts any key whose lowercase form contains a sensitive substring:
password, passwd, pwd, secret, token, api_key, apikey,
access_token, refresh_token, private_key, authorization, auth
This catches "user_password", "API_KEY", "Authorization", etc.
⚠️ Redaction matches keys, not values. A password stored under"value"will not be hidden. Always review what you dump.
Dumper::dd() checks the container's environment:
if (method_exists($app, 'isDevelopment') && !$app->isDevelopment()) {
ErrorHandler::renderGenericErrorPage();
self::terminate(1);
}In production, dd() never leaks data — it renders a generic page.
Once disabled, no logs, dumps, timers, or events fire. Use it as a kill switch before responding to untrusted requests.
Dumper::$exitHandler is a powerful testing tool, but in production it is null by default. Ensure you do not register a handler in a bootstrap that could reach production.
use PHPUnit\Framework\TestCase;
use Mlangeni\Machinjiri\Core\Debug\Dumper;
use Mlangeni\Machinjiri\Core\Debug\Debugger;
final class DumperTest extends TestCase
{
protected function setUp(): void
{
Dumper::$exitHandler = null;
}
protected function tearDown(): void
{
Dumper::$exitHandler = null;
}
public function testRedactsPassword(): void
{
$html = Dumper::format(['password' => 'hunter2'], true);
$this->assertStringContainsString('***REDACTED***', $html);
$this->assertStringNotContainsString('hunter2', $html);
}
public function testDdIsInterceptable(): void
{
Dumper::$exitHandler = function (int $code): never {
throw new \RuntimeException("exit({$code})");
};
$this->expectException(\RuntimeException::class);
Dumper::dd(['a' => 1]);
}
public function testDebuggerTiming(): void
{
$debug = Debugger::getInstance();
$debug->startTimer('t');
usleep(10_000);
$elapsed = $debug->stopTimer('t', false);
$this->assertNotNull($elapsed);
$this->assertGreaterThanOrEqual(0.01, $elapsed);
}
}$received = null;
$debug->setEventListener($fakeEventBus);
$debug->on('debug.log', function ($e) use (&$received) { $received = $e; });
$debug->info('hello');
$this->assertSame('hello', $received['message']);- ✅ Use
Debugger::getInstance()for app-wide access - ✅ Prefer
measure()over manualstartTimer/stopTimerfor one-off timing - ✅ Always use
memorySnapshot/memoryDiffwhen debugging memory leaks - ✅ Subscribe to
debug.timer.stopto catch slow operations - ✅ Reset
Dumper::$exitHandlerin testtearDown()methods - ✅ Extend
sensitiveKeyswith domain-specific secrets (e.g.,db_dsn,stripe_live_key)
- ❌ Leave
$debug->enable()running in production - ❌ Dump objects containing raw secrets under non-sensitive key names
- ❌ Mix
echo/var_dumpwithDumper— it fragments output streams - ❌ Call
dd()in library code — throw exceptions instead - ❌ Store container references in
Dumper(it's intentionally stateless)
| Symptom | Likely Cause | Fix |
|---|---|---|
| Nothing logs | Debugger::enabled = false |
Call $debug->enable()
|
| No logger output | Logger not bound in container | Check $app->bound(Logger::class); fallback logs to system logger |
dd() renders a blank page in prod |
ErrorHandler::renderGenericErrorPage() executed |
Expected behavior — check isDevelopment()
|
| Dump truncated at depth 16 |
maxDepth default |
Dumper::configure(['maxDepth' => 32]) |
| Values still visible despite redaction | Key name not in sensitiveKeys
|
Add to config |
on() does nothing |
No EventListener resolved |
Call $debug->setEventListener($events)
|
| Circular refs not detected | Object ID collision across sessions | Ensure self::$seenObjects = [] between dumps (handled internally) |
Q: Does Dumper require the container?
A: No. It works standalone. Only dd()'s production check consults the container when present.
Q: Can I use Debugger outside the framework?
A: Yes. Construct with new Debugger() — a fallback logger is created automatically.
Q: Is Dumper PSR-3 compatible?
A: No — it's a pretty-printer, not a logger. Use Debugger::log() or Dumper::format() for logging pipelines.
Q: Why is Debugger a singleton?
A: For ergonomic global access (Debugger::getInstance()->info(...)). You can bypass the singleton by constructing fresh instances.
Q: Can I add custom dump formats?
A: Not natively. Compose a wrapper around Dumper::format() or use json_encode for JSON dumps.
Q: What happens if EventListener fails?
A: triggerEvent() silently guards with if ($this->events && $this->enabled) — listener failures won't break the debugger, but you should still check for exceptions in your own listeners.
Q: Does Dumper cache styles between dumps?
A: Yes — $stylesEmitted ensures CSS is injected only once per process.
- Native query logging via PDO decorator
-
Dumper::stream()for tailing dumps to a file - Structured payload support for
dd()(JSON-encoded for browser fetch) - Sampler support for timer capture in high-throughput scenarios
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