Skip to content

Using the debug toolbar

This page is organised by what has gone wrong, not by what each tab contains. For how the toolbar works, how it is delivered and how to switch it on, see the Debugging Guide.

Open it with the ⚙ Pramnos bar along the bottom of the page. Click a tab to open its panel, click the same tab again to close it, drag the panel's top edge to resize it — the height is remembered.

Finding the tab you want

Fourteen tabs do not fit across a laptop, so related ones collapse into a dropdown:

Group Tabs
App Route, Views, Domain, Migrations
User Auth, Gate, Session
Logs Logs, Exceptions, Errors

Everything else — SQL, Time, Client, API — stays inline, because those are the ones opened most often and a click to reach them would be a click too many.

Three behaviours are worth knowing, because each is the answer to "why did that tab move":

  • The tab you have open is always pulled out of its group and shown on the bar. It does not disappear behind a dropdown the moment you select it.
  • A group whose contents include something alarming is marked, so an exception you cannot see is still visible as a warning on the group that holds it. A collapsed problem is the one failure a grouping like this could introduce, and it is the one it is built not to have.
  • A group with only one tab in it is not a dropdown. It renders as a plain tab, because a menu holding a single item is a worse way to click that item.

Every tab carries a tooltip saying what it holds — Gate is authorization policy and permission checks, Domain is domain model entities loaded — so the short labels do not have to be guessed at.


The request came back wrong

Start in requests. Every request this page has made is listed there, newest first, with its status, server time and query count — the page's own request included. Click one and every other tab switches to it, so you read the same tab for one request after another rather than hunting for the right panel.

Then, in order of how often it is the answer:

Tab What it settles
SQL What was actually asked of the database. A query returning nothing usually looks obviously wrong once you read it.
Route Which controller and action ran. If this is not what you expected, nothing after it will be.
Domain Which models and services the request touched. An empty Domain tab on a request that should have saved something is the finding.
Exceptions What the server raised. It turns red with a ⚠ as soon as anything did, so you do not have to open it to check.

What the page sent is shown above whichever tab is open, collapsed, for any request that had a body — "what did I send" is usually the first question, and a form-encoded body is decoded into the structure it encodes.

A request whose response carried nothing still gets a row, with where its numbers would be, and its row is red across the full width. That is often the finding by itself: the call happened, and the server never answered it.


It is slow

Time. Two subtractions nobody does by hand:

  • client versus serverclient 210ms = server 42ms + 168ms elsewhere. If most of it is "elsewhere", the server is not your problem: it is the network, the queue, or the browser.
  • SQL as a share of server time. "24ms of 40ms was the database" is an indexing problem; "2ms of 400ms" is not.

Then the timeline column in requests. Every request is drawn on one shared axis, so what a single-request view cannot show becomes obvious: three calls that each take 200 ms are a 200 ms page if they overlap and a 600 ms page if they do not. A polling loop looks like a comb.


It worked, and then it stopped

What turns the toolbar on

Three things, and nothing else:

debug:token a signed, single-use, expiring grant for one browser — the only way it appears on a live server
APP_DEBUG in the environment this deployment is a development one
the DEVELOPMENT constant the same statement, made in code

The debug and development settings do not. They used to, and that was a hole: they are rows editable from /admin/Settings, and flipping one turned the toolbar on for every visitor of the site rather than for the person who flipped it — with every query, the session's keys and the request's authentication state in it. They still govern error display, the DevPanel and the debug log; debug:status prints all of this, including which signal is actually open.

The bar remembers which tab you had open

Across page changes, for the length of the browser session. Following one bug through three screens used to mean reopening the same tab three times — and the tab most often reopened is the one that explains why the page you landed on is not the page you asked for. Whether the bar is hidden, and how tall the panel is, are remembered for longer: those are preferences, the open tab is "where I was a moment ago".

Second factors, and the codes

The Auth tab carries the second-factor state whenever somebody is signed in or a step-up is half-finished: what the account holds, whether the site requires a factor of it, whether it is behind the enrolment wall, and — during a step-up — which methods were demanded, whether a mailed code is live and how long the resend has.

That block is the answer to two questions that are otherwise unanswerable from the page: why am I being asked for a code, and why does every page redirect me to the setup screen. The second one especially: from outside, the enrolment wall looks like a redirect loop, and the first guess is always a routing bug.

The codes themselves are off by default. With 'debug' => ['reveal_factor_codes' => true] in app.php, the tab also shows the authenticator secret, a TOTP code valid right now, and the last six digits mailed — which saves the loop of opening a mail catcher and copying digits twenty times a day.

Leave it out of a deployed configuration. The argument for showing them is sound — the panel renders only where debugging is on, and the codes belong to the viewer's own session — but the payload rides on responses, sits in the browser's network log, and gets pasted into bug reports. A live code in a paste is a live code, and a debug flag left on by accident is a normal kind of accident.

The switch is compared with === true, so '1', 1 and 'true' do not turn it on. That is deliberate: those are what a flag looks like when it arrives from an environment variable or a hand-edited config, they are all truthy, and a loose comparison would mean an installation that never asked for live credentials in its network log gets them because somebody typed a string. Write the boolean.

What it shows, and where each comes from:

totp_secret the live enrolment, or the half-finished one from authserver.twofactor_setup if there is no live one. Decrypted; a row that will not open after a key rotation is reported as no pending enrolment rather than as a string of ciphertext
totp_now a code generated from that secret, valid right now
mailed_code the six digits from the newest mail to this account that contains any, out of the last five. Not from twofactor_email_codes, which holds an HMAC and nothing else — the right design, and the reason the code is unrecoverable from it

Newest-first matters: a code from last week is worse than no code, because somebody will type it and then look for the bug somewhere else. A newer mail with no code in it — a password-change confirmation arriving after the step-up mail — does not hide an older one that has.

Almost always Auth. It answers four things about the request in view: who the server identified, which credential did it (apiKey, accessToken, cookie), where that credential came from, and how long it has left — counted from the token's own expiry rather than from when the response was made, because the answer may have been sitting in the browser for a while. The tab turns red once the credential has expired, which explains every 401 above it in the list at once.

A logout reports that nobody is signed in any more, even though the request itself was authenticated — it has to be, to revoke anything.

If Auth says the credential is fine, look in Client → what the browser has stored: a stale token survives a deploy, the server then signs with a new key, and every call fails in a way that looks like a server problem.


Client → where the application thinks it is. The router base is printed next to the current path, because that pair is the failure: an application served under /app whose router base is empty resolves every deep link to its home screen and says nothing about it. When the path does not start with the base, the panel says so rather than leaving two values side by side.

On a server-rendered page the same section tells you the server decided which page this URL is, and points at the Route tab — there is no client-side route to report, and that is not a fault.


It said I am not allowed

Gate. Every authorization check the request made, and — the part nothing else can tell you — which step decided:

Step Means
before A global hook decided immediately: "an administrator may do anything"
ability A named Gate::define() rule answered
policy A policy method answered, and the row names it
store The permission store answered
default Nothing claimed this ability, so it was refused
after A rule answered and an after hook overrode it

default is the row to look for first. An ability nobody defined is refused, so a mistyped name and a deliberate deny both come back false — the step is the only thing that separates them, and the tab counts them so the badge says so before you open it.

A rule is a closure in a bootstrap file: it is in no stack trace, a before hook that allows everything leaves no mark, and a decision may touch no database, so SQL has nothing to show either. This tab is the only place that answer exists.

The Auth tab above answers the other half — who the server thinks you are. A refusal usually turns out to be one or the other, and they are consecutive tabs for that reason.

Something broke in the browser

Errors, which appears — red, with a ⚠ — only once something has been thrown. It holds what window.onerror and unhandledrejection saw, plus anything the application handed over deliberately: an ApiError a screen turned into a message, a component failure caught by <svelte:boundary>. Those never reach a global handler, and they are exactly what you are looking for when the screen says something went wrong and the network tab looks fine.

Each row names the request it happened after, and identical failures collapse into one row with a ×4 — a render loop throws the same error thousands of times.

The server's own exceptions are in Exceptions. Two tabs, because they have different fields and different lifetimes, and mixing them would make "where did this come from" ambiguous in the one table you read while something is broken.


I want to try an endpoint

API. It lists the endpoints in the project's own OpenAPI document, gives you a field per documented parameter, and sends a real request — same server, same middleware, same authentication. The call is recorded in the requests list like any other, so Time, SQL and Logs answer for it too.

The response appears directly under the Send button, with the status in words as well as numbers. Its own _debug payload is left out of that view: it is what every other tab is already showing for the same call.

Credentials: the application's apiKey, cookies (so a signed-in browser session authenticates the call), and a stored bearer token if there is one — named by its key, never printed, and refusable in one click, which is how "is this endpoint actually public?" gets answered.


The response could not carry its own detail

An error page is not a JSON object, so a request that died brings back a summary in headers rather than a payload. The toolbar knows: Logs and Exceptions show a row per such request with a button that asks the server for the detail by request id.

Every request has an id. Hover its row to copy it — it is what to paste into a bug report, and what GET /devpanel/logs?request=<id> takes.


Housekeeping

  • clear the list in the requests tab empties it. In a SPA that polls, the call you care about scrolls away otherwise.
  • hides the whole bar, leaving a small in the corner to bring it back. The choice is remembered, and shared between the server-rendered toolbar and the SPA panel.
  • The bar is not there at all in production, because nothing attaches debug data — no data, no DOM, no panel.
  • The DevPanel link appears only when the devpanel feature is enabled and the signed-in user meets devpanel.min_usertype (default 90). Its address is resolved server-side from sURL, or SITE_URL when that is what the application defines, so an installation served from a subdirectory gets a link that works.

The link is hidden rather than disabled, and hiding it is not what protects the panel: DevPanelController performs the same feature and usertype check on every action it serves. Removing the link stops advertising a door that is already locked.