Debug toolbar
Lift ships an in-browser debugging toolbar — a docked panel that overlays your HTML pages with timings, request/response data, database queries, log messages, and uncaught-exception pages. Off by default; gated by $app->debug(true) so it can't accidentally ship to production.
Mental model: a debug session is just three pieces — a DebugCollector that records info during the request, a DebugToolbarMiddleware that injects the rendered HTML into responses, and an ErrorHandler that converts exceptions into rich HTML pages instead of plain 500s.
Enabling it
use Lift\Config\Env;
$app->debug([
'enabled' => Env::bool('APP_DEBUG', false), // master switch
'toolbar' => true, // render the HTML toolbar
'position' => 'bottom-right', // or 'bottom-left'
'only_html' => true, // skip JSON/text/binary responses
'track_php_errors' => true, // capture warnings/notices
'exception_pages' => true, // pretty HTML 500 pages
'hide' => [
'headers' => ['Authorization', 'Cookie', 'Set-Cookie'],
'params' => ['password', 'token', 'secret'],
],
]);
Or the short form:
$app->debug(true); // enable with defaults
$app->debug(Env::bool('APP_DEBUG', false));
Always derive enabled from an environment variable. Hard-coding true will leak source code paths, env vars, and stack traces the next time you deploy.
What it shows
A small badge appears in the bottom corner of every HTML response. Click it to expand:
- Request — method, path, route name, controller, route parameters, headers (with redactions).
- Response — status, content type, headers, size, render time.
- Session — current keys/values (when a session is active).
- Queries — every SQL statement executed during the request, with bindings, duration, and the call site.
- Logs — every PSR-3 log line emitted during the request.
- PHP errors — captured warnings/notices that didn't escalate to exceptions.
- Timing — boot, dispatch, render time breakdown.
- Memory — peak / current.
The "hide" list redacts sensitive headers/params so screenshots are safe to share.
How injection works
DebugToolbarMiddleware runs at the very end of the pipeline. After your handler returns, it:
- Checks
DebugConfig::shouldInject()(skipping HEAD, 204/304, non-HTML,X-Debug-Toolbar: off, …). - Renders the toolbar HTML from the collected data.
- Injects it before the closing
</body>tag of the response.
To skip the toolbar for one specific request, send the header:
X-Debug-Toolbar: off
— useful for full-page-screenshot tooling, performance benchmarks, etc.
Exception pages
When exception_pages: true and an uncaught Throwable reaches ErrorHandler::handle(), Lift renders a detailed HTML page with:
- Exception class + message + status code.
- File / line of the throw, with source-code preview (~10 lines).
- Full stack trace, each frame expandable to its own source preview.
- Request inspection panel (same data as the toolbar).
- "Open in editor" links (
vscode://,phpstorm://) on every frame.
Disable per-environment:
'exception_pages' => $app->environment() === 'local',
For JSON APIs you usually want both toolbar: false and exception_pages: false — error responses should stay JSON. The middleware honours these flags independently.
SQL query log
$db->onQuery(...) is what powers the query panel. Lift wires it automatically when debug is enabled and a Connection is registered. To attach manually for advanced setups:
$collector = $app->container()->get(\Lift\Debug\DebugCollector::class);
$db->onQuery(fn($sql, $bindings, $ms) => $collector->addQuery($sql, $bindings, $ms));
Each row shows:
- SQL with
?placeholders. - The actual
$bindingsarray. - Execution time in ms.
Log capture
DebugLogHandler is a tiny PSR-3 handler that forwards every log line into the collector. To enable:
$logger = new \Lift\Log\Logger([
new \Lift\Log\Handler\FileHandler('storage/logs/app.log'),
new \Lift\Debug\DebugLogHandler($collector), // only when debug is on
]);
In a typical bootstrap:
$app->singleton(\Psr\Log\LoggerInterface::class, function () use ($app) {
$handlers = [new FileHandler('storage/logs/app.log', 'info')];
if ($app->container()->has(\Lift\Debug\DebugCollector::class)) {
$handlers[] = new \Lift\Debug\DebugLogHandler(
$app->container()->get(\Lift\Debug\DebugCollector::class),
);
}
return new Logger($handlers);
});
Custom collector entries
Anything in your code can record into the collector:
$collector = $app->container()->get(\Lift\Debug\DebugCollector::class);
$collector->addTiming('ai.completion', 1234.5);
$collector->addContext('feature_flags', $flags);
These show up in the toolbar under a generic "App" panel.
Production safety
A deployment-day checklist:
- ✅
APP_DEBUG=falsein your prod.env. - ✅
$app->debug(Env::bool('APP_DEBUG', false))— never$app->debug(true). - ✅ Sensitive headers/params are in the
hidelist (Authorization,Cookie,password,token,secret— Lift's defaults already cover these). - ✅
OPcache.save_comments = 1is fine; the toolbar doesn't need it specifically (route attributes do). - ❌ Don't
$_GET['debug'] = trueyour way to enabling it — it leaks to logged-in users.
When debug is disabled:
DebugCollectorisn't built.- The middleware short-circuits (no injection).
- Error pages fall through to your
$app->onError(...)handler (or Lift's default 500 JSON).
Result: zero overhead in production.
Performance
Enabled, the toolbar adds ~1–3 ms of overhead per request (collection + HTML render). Disabled, it adds zero — the middleware isn't even registered, the collector isn't constructed. Don't ship-gate on perf concerns; ship-gate on security.
Configuration reference
$app->debug([
'enabled' => false, // master switch
'toolbar' => true, // inject the HTML toolbar
'position' => 'bottom-right', // or 'bottom-left'
'only_html' => true, // skip non-HTML responses
'track_php_errors' => true, // capture warnings / notices
'exception_pages' => true, // render rich HTML for uncaught exceptions
'hide' => [
'headers' => ['Authorization', 'Cookie', 'Set-Cookie'],
'params' => ['password', 'password_confirmation', 'token', 'secret'],
],
]);
| Key | Default | Effect |
|---|---|---|
enabled |
false |
Master switch. Nothing else runs without it. |
toolbar |
true |
Inject the HTML panel. |
position |
'bottom-right' |
'bottom-right' or 'bottom-left'. |
only_html |
true |
Skip JSON/binary responses. |
track_php_errors |
true |
Capture E_NOTICE / E_WARNING. |
exception_pages |
true |
Pretty 500 page on uncaught exceptions. |
hide.headers |
['Authorization', 'Cookie', 'Set-Cookie'] |
Mask these request/response headers. |
hide.params |
['password', 'token', 'secret', …] |
Mask these query / body / route parameters. |
Tips
- Behind a CDN? Bypass the cache on debug requests (
Cache-Control: private, no-store) — otherwise the cached page won't include your toolbar. - JSON API in dev? Set
only_html: falseand the toolbar tries to inject anyway. Most teams keeponly_html: trueand just use the exception-pages feature for API debugging. - Slow page render? Open the toolbar's Timing panel — it'll usually narrow it down to a specific middleware / handler / query.
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| Toolbar doesn't appear on any page | enabled: false (often via env) |
Set APP_DEBUG=true in .env. |
| Toolbar doesn't appear on JSON endpoints | only_html: true (correct behaviour) |
Either flip to false (rarely useful) or use exception pages instead. |
X-Debug-Toolbar: off from a screenshot tool blocks debugging mid-test |
Header sent unintentionally | Drop the header in test setup. |
| Sensitive header / param still visible | Not in hide.headers/hide.params list |
Add it explicitly. |
| Toolbar shows zero SQL queries | DB connection wasn't built via the container (or onQuery wiring skipped) |
Make sure the same Connection instance both serves your handlers and is registered with the collector. |
Exception page leaks .env values |
exception_pages: true in production |
Tie it to environment, never hardcode true. |
Cheat sheet
// Enable (always env-gated!)
$app->debug([
'enabled' => Env::bool('APP_DEBUG', false),
'toolbar' => true,
'exception_pages' => true,
]);
// Off per-request
// curl -H 'X-Debug-Toolbar: off' …
// Custom timing
$collector->addTiming('llm.call', $ms);
// Custom context panel
$collector->addContext('features', $flagsArray);
// Logger that feeds the toolbar
new Logger([
new FileHandler('storage/logs/app.log'),
new DebugLogHandler($collector),
]);