Skip to content

DevPanel Guide

A server-rendered dashboard for a running installation, mounted at /devpanel. It is deliberately independent of the application's theme and document system: it emits its own self-contained HTML page, so it looks and behaves the same in every project and keeps working when the thing you are debugging is the theme.

It answers questions about this installation, right now — what the database holds, what is in the cache, who is signed in, what is queued, what has not migrated. For per-request timing and queries, use the debug toolbar instead; the two are complementary and neither replaces the other.

Enabling it

// app.php
'features' => ['devpanel', 'cache', 'queue'],

Access requires all of:

  • the devpanel feature enabled;
  • a development environmentAPP_DEBUG=1 in .env, or the DEVELOPMENT constant;
  • a signed-in user with usertype >= 90, or a policy callback that says yes.

Two optional keys, in app.php beside the feature list:

'devpanel' => [
    'mount'        => 'devpanel',  // path to mount on
    'min_usertype' => 90,          // raise the floor above 90
],

There is no setting that opens this panel

Not debug, not development, not a checkbox on /admin/Settings. The environment is the only lock, and that is deliberate: this panel browses the database, reads the cache and dumps the container. A row in the settings table makes that reachable by anybody who can open the administration area, on a live server, with no deploy and nothing in the repository to say it happened.

devpanel.mount and devpanel.min_usertype used to be settings too, with fields on that screen. They read from app.php now, for the same reason — where a developer tool is mounted and who may open it are properties of the deployment. (The fields never worked, as it happens: PHP replaces the . in a form field name with _, so both inputs posted under a name the controller never asked for, and every save wrote the default back. An installation that thought it had moved the mount point had not.)

If you need the panel on a server that is not a development one, the answer is a deploy — APP_DEBUG=1 and a restart — not a switch. For reading a live production request instead, use the debug toolbar with a signed one-browser token from debug:token, which expires on its own.

Adminer, at /adminer

Adminer is the database tool most people already use, and the usual way to have it on a server is a PHP file dropped in the web root: a URL anybody can guess, protected by whatever the database password happens to be, and forgotten after the afternoon it was needed.

composer require vrana/adminer

That is all. The framework serves it at /adminer, and the Database tab links to it when the package is present.

Who may open it, either:

  • usertype ≥ 99Root in UserTypes::DEFAULTS, on any deployment including production, and with or without the devpanel feature. That half is deliberate: fixing data on a live server is a real thing an owner does, and a tool that only works in development means they do it in psql with no undo, or leave adminer.php in the web root for ever.
  • a development environment, subject to this panel's own usertype floor.

Anything else gets a 404, not a 403. A 403 confirms the route exists, and this is the one URL on the site where that is worth withholding.

Adminer keeps its own database login, and this deliberately does not fill it in. Auto-login would make "may this browser reach a URL" the only thing between somebody and every row — and the accounts that can reach it are exactly the ones whose sessions are worth stealing. Two locks, and the second is not the framework's to remove.

A suggest, not a require. A framework that shipped a database browser into every application's vendor/ would enlarge the attack surface of applications that never asked for one, including the ones that do not read what a release added. With the package absent the route answers 404 like any other unknown address.

The two clauses of the gate

Both must hold for a non-root account, and only the first for root:

if ($usertype >= static::rootFloor()) {          // 99, or the installation's own
    return true;                                  // anywhere, devpanel or not
}

if (!Application::isDeveloperEnvironment()) {
    return false;
}

if (!FeatureRegistry::isEnabled('devpanel')) {    // the floor below belongs to that panel
    return false;
}

return $usertype >= DevPanelController::config('min_usertype', 90);

The third clause reads as belt-and-braces and is not: the floor it is about to apply belongs to the DevPanel, and a floor configured for a panel the installation does not have is a number with nothing behind it. Letting a usertype-90 account through on the strength of it would be a gate configured by accident.

And signed in means both: User::getCurrentUser() and Session::staticIsLogged() are independent questions, and this route needs yes to each. So an API-authenticated request — a sealed identity with no session — is refused, deliberately: this is a browser tool, and a bearer token is not the credential it asks for.

What this route hands Adminer, and what it takes away first

Three of these are load-bearing and none of them is visible in the page that comes back.

$_POST['auth'] and $_POST['logout'] are removed before the include. Adminer's auth.inc.php acts on $_POST['auth'] — driver, server, username, password, database — before anything else. Removing its login form took away the page that submits that, not the ability to submit it: a hand-made POST, or a form on another site aimed at this URL, would have logged this Adminer into any host reachable from the server with any credentials the sender knew. The gate on this route is permission to read this database, not a general-purpose database client. permanent goes with it — the field that asks Adminer to write an encrypted copy of the password into a cookie.

The session is closed and emptied. session_write_close() writes the data and releases the handle; $_SESSION keeps its contents in memory. Adminer starts a session of its own only when none is active, and when it decides not to it reads and writes our keys. One of them is token: Adminer's CSRF token is rand() ^ $_SESSION["token"], this framework's value is a hex string, and the result was «A non-numeric value encountered» twice per page and a CSRF check that could not work.

The URL rewrite is an output-buffer callback, not code after the include. Adminer is a script and several of its paths end with exit, the login page among them — so post-include code never ran, PHP flushed the buffer at shutdown, and the page went out with Adminer's own ./static/default.css links. Served from /adminer those resolve to /static/…, so the tool arrived with no stylesheet and looked broken rather than un-rewritten. A callback is invoked by the buffer's own flush, exit included.

If you override serveAdminer(), all three come with it.

The bar above Adminer's page

Adminer is a whole application and every link it draws stays inside itself — it has no idea this route exists. So the chrome is injected: the panel's tab strip with Adminer marked active, the site's name, and a Back link, because a visitor who opened it directly otherwise has the browser button and nothing else.

Two details that are decisions rather than styling:

  • position: fixed, not sticky. Adminer's pages are wider than the viewport whenever a table has many columns, so the page scrolls sideways — and sticky pins only the vertical axis, taking Back off the screen exactly when somebody is lost in a wide table.
  • Anything without a <body> is returned untouched. Adminer answers things that are not pages: a redirect, a download, a fragment for its own JavaScript. Injecting a div and a stylesheet into any of those corrupts the response, so the injection is guarded on finding a body tag rather than guessing.

The site name comes from a setting an operator typed and is escaped on the way in.

What locks it, on a public server

The gate on the URL is the authorisation — the connection is supplied, so reaching the page is reaching the database. That makes the list of what holds it worth reading in full:

Who signed in, and usertype ≥ 99 (Root) — or a development environment plus the DevPanel's floor. Everybody else gets a 404, not a 403
Credentials come from the configuration only. Adminer's login form is removed, and $_POST['auth'] is discarded before Adminer sees the request
Which connections the primary and the declared replicas (database.read / database.write). A request naming any other host, user or database gets the default rather than what it asked for
Audit every open and every refusal is logged to auth with the account, the address and the URL
Headers own CSP with frame-ancestors 'none', Referrer-Policy: no-referrer, X-Robots-Tag: noindex
Session ours is closed and emptied before Adminer runs; Adminer gets its own adminer_sid namespace
Which files it will send only static/… or externals/… ending in .css, .js, .png, .gif, .svg, .woff, .woff2 — matched against the whitelist before any filesystem call, then resolved with realpath() and required to be inside the package. A .php under static/ is refused, which is the case worth naming: nothing here would execute it, but it would be read out verbatim
What its links carry the connection is stripped from every href and action the page contains — server, pgsql, sqlite, mssql, oracle, mongo, elastic, username, db. Left in, every link on the page would name this installation's database host and account, in the address bar, in the browser history, and in a Referer on the way to adminer.org. Absolute URLs are untouched: those are Adminer's own outbound links
No package 404, like any other unknown address

Two of those were holes and are worth naming rather than listing. Removing the login form did not remove the ability to log in: auth.inc.php acts on $_POST['auth'] before anything else, so a hand-made POST — or a form on another site aimed at this URL — could have pointed this Adminer at any host reachable from the server, with any credentials the sender knew. And a request naming another server was obeyed, because that is Adminer's own design: the query string says who to connect as. Behind a single-purpose gate it must not.

What this does not protect against is somebody who already has a root account's session. If that is a concern, keep adminer_autologin off, and the operator types the database password — two locks instead of one.

The session repair

Adminer starts a session only when none is active, so the first version of this route handed it ours. One of the keys it uses is token: ours is a hex string and its is rand() ^ $_SESSION["token"], which gives «A non-numeric value encountered» twice a page and a CSRF check that cannot work.

Closing our session fixed it for a new visitor and did nothing for anybody who had already loaded the broken page — the bad value was already in their adminer_sid session, waiting for every later request. So AdminerBridge::repairSession() removes it, rather than only preventing it: the alternative is telling people to clear their cookies, which is what software says when it cannot fix itself.

It is deliberately narrow. Only token, and only when it is not numeric — everything else in there is Adminer's. Only when no session is already open, because starting a second one over a live session would take the visitor's own with it. Only for a cookie shaped like a session id, because that value is under the visitor's control and session_id() rejects anything else noisily.

And it restores what it changed: the session name, and session.use_cookies. session_start($options) applies its options as ini settings for the rest of the request, so without the restore Adminer's own session_set_cookie_params() warned «Session cookies cannot be used when session.use_cookies is disabled» at the top of every page.

Two details worth knowing:

  • Its assets live in vendor/, which no web root serves, and it links them as ./static/default.css. The output is rewritten to ?file=… and those requests are served from the package — the same trick Adminer's own single-file build uses. Paths are whitelisted and resolved, because a whitelist that allows dots is one somebody gets through.
  • The route sends its own CSP. The site's policy is nonce-based and Adminer is full of inline onclick handlers that a nonce policy blocks whatever the nonces say; the relaxation applies to this URL and nothing else.

The tabs

Overview

Runtime facts: PHP and framework version, memory, host uptime, load and RAM, database driver and version, git HEAD, migrations, background work, and the queue.

Migrations counts every migration file the loader resolves — the same directories migrate:status uses, which is app/Migrations plus each framework feature directory — against the schemaversion history. A migration counts as applied only when its history row has result = 1, so a failed attempt shows as pending, which is what it is. Auto-migration fingerprint rows (__fw_auto_*) are bookkeeping and are skipped.

Background work is the one to read when another panel looks empty:

Row Meaning
Write spool Rows buffered out of the request path by WriteSpool, waiting for a drain — and which backend holds them.
Scheduled tasks How many tasks Scheduler knows about, framework and application together.

A non-zero spool with nothing draining it is the normal cause of an empty Performance tab. The framework schedules spool:drain every minute in FrameworkSchedule, but a schedule only runs when something runs schedule:run — a cron entry, or a supervised daemon. An installation whose daemons are all application workers has no scheduler, and the rows stay in the spool for ever.

Database

More than /admin/dashboard/database, not a smaller copy of it. That screen answers an operator's questions — how big, how busy, how far behind. This one answers the questions somebody changing the schema has.

The administration screen's sections, in its order — overview, processes, replication, tables, views, TimescaleDB — and then the three this tab adds.

Section Answers
This database version, size, connections, transactions, cache-hit ratio
Active Processes what it is doing now, with Copy on the query and Kill on the row
Replication Status connected standbys and their lag
Table Sizes what is in here and how big, with each name linking into Adminer
Public Schema Views the definitions, moved here from the administration screen
TimescaleDB hypertables, chunks, compression, jobs, aggregates, and the job error history
Indexes nothing uses which indexes cost a write on every insert and buy nothing
Read the hard way which tables are scanned sequentially, and how many rows that costs
Slowest statements what the database actually spends its time on

All of it comes from the shared DatabaseInspector, which the administration screen already used. This tab had its own copy of the table-size query — the third in the framework, with its own bugs — and it is gone.

Kill ends a backendpg_terminate_backend, not pg_cancel_backend: cancel asks a query to stop and a backend stuck in a lock wait ignores it, which is exactly the backend somebody is trying to end. It asks first, because the connection dies with the query. It is here rather than on the administration screen deliberately: ending somebody's query is a developer's action against a development database, and this panel is already behind a development-mode lock and a usertype floor.

A wait is only shown when it is a problem. Every idle PostgreSQL backend sits on Client/ClientRead — waiting for the application to send the next statement, which is what an idle pooled connection is for. Rendered as a warning on every row it says the database is in trouble when nothing is wrong. Only Lock, LWLock, BufferPin and IO are shown, and only for a backend that is running something.

The job error history is rendered even when nothing has failed. Hidden until there is something to show, it is a section nobody can find — and "no job has failed" is an answer somebody came here for as often as the list is. Same for replication on a standalone instance.

Active processes distinguishes running from idle. active_sec is the running query's own age and is null unless the backend is running one; idle_sec is how long a pooled connection has been sitting there. Reported as one number, an idle connection showed as running for 194 minutes, in red, and two of those is all it takes for nobody to read the column again.

Unused indexes exclude primary keys and unique constraints. They are not there to be scanned — they are there to make a duplicate impossible — so listing them as dead weight is telling somebody to drop the thing holding their data together.

The slowest are ordered by total time, not by mean. A query taking two milliseconds four million times is the one to fix, and it never appears in a list ordered by mean.

pg_stat_statements is an extension and is usually absent, so the panel says not installed rather than showing an empty table: that is a different fact from "no slow queries", and one screen for both tells somebody their database is fine when it has never been asked. Installed but unreadable — the usual state for an application role without pg_read_all_stats — says that instead, because it is fixable.

TimescaleDB chunks are not listed as tables. A hypertable's storage lives in _timescaledb_internal as one table per chunk — _hyper_7_15_chunk and forty like it — and they are not tables anybody put anything in. Listed, they crowd out the tables somebody was looking for and double-count storage the hypertable already reports. They are counted in the TimescaleDB section instead. (Fixed in the shared inspector, so the administration screen stops listing them too.)

The jobs are on the same screen as the hypertables. A hypertable whose compression policy has been failing for a week looks perfectly healthy from the hypertable list alone.

The index and statement sections are PostgreSQL only. MySQL can say an index exists; it cannot say whether anything has ever used it.

Cache

Adapter, item count and namespaces, a paginated item browser (first 100), a namespace filter, per-item Inspect, and a flush button.

The adapter named here is the store the instance actually ended up with — see the cache guide for what happens when the configured backend cannot be reached. If this says file on an installation configured for Redis, that is a fallback and the log will have a warning saying so.

The browser lists everything in the store, including keys written by other components — sessions, queue payloads, anything else sharing the same Redis instance. Those are shown as type raw: they are not this cache's envelopes and are not decoded.

Users

Sessions and login security.

Sessions are limited to a recency window — last used within 1h / 6h / 24h (default) / 7d / 30d, or All. The window is not cosmetic. A web_session token is created per login, so without one the panel lists every login the installation has ever had. If the All count is large and the 1h count is one, that is not a busy server, it is an accumulating table.

A web session is bounded by the PHP session it belongs to, whatever the window says. It is accepted through $_SESSION['usertoken'], so once PHP has expired the session — session.gc_maxlifetime, 24 minutes out of the box — the row cannot be used by the browser that owns it. A login from this morning is not a session, it is a row. API tokens (auth, access_token) have no such bound and use the window you selected. All lifts both.

Two tables: Sessions by User, which is the summary to read first (who, which token type, how many, last seen), and the 50 most recently used sessions in detail. The count line says how many there are in total, so a capped list is never mistaken for a complete one.

A session is listed when its token has status = 1 and an expiry that has not passed — the same test User::loadByToken() applies when deciding whether a token still works — and its type is one of web_session, auth or access_token.

Click a token for its request history; click a user for their audit log. Both are paginated.

Login Lockouts lists identifiers currently locked out, with attempt counts and the lockout expiry.

Performance

Slowest endpoints and slowest users/applications over a selectable window, read from tokenactions. An endpoint is shown by URL, resolved through the urls table.

Only calls that were timed appear here. A row with no duration has nothing to say about speed, and it did worse than say nothing: ORDER BY avg_ms DESC puts NULLs first on PostgreSQL, so a table holding unmeasured rows showed twenty of them at the top, each rendered as 0.0 ms, with the real measurements pushed off the list. Web requests carried no duration before 2026-08-20, so on an installation with history that was the whole report. When rows exist but none are timed, the panel says that instead.

tokenactions rows are written through the write spool, so this panel shows nothing until something drains it. When the table is empty the panel says so — and says how many rows are waiting in the spool — rather than reporting "no data for this period", which is a different problem with a different fix.

MCP

Every registered MCP tool, each one's schema rendered as a form, and the answer on the page. It is here because mcp:serve cannot be watched: it speaks JSON-RPC on stdio and, under a real client, does not own its own pipes.

Tools
  ▸ log-analytics
      Summarise this installation's logs: entry trend, counts per level, …
      timespan [1h ▾]   files [comma separated]
      [ Call ]  ☐ show the JSON-RPC envelope              41 ms
      // sent {"timespan":"1h"}
      { "trends": { … }, "levels": { … } }

Four things it is careful about, each of them a wrong answer avoided:

  • Every field can be left out. An omitted argument and an empty string are different things — a tool with a default gets to keep it — so each control carries an explicit — omit —, and a boolean is a tri-state select rather than a checkbox, because an unchecked box cannot express "leave it out".
  • The arguments actually sent are printed above the answer. {"limit": "5"} and {"limit": 5} are different calls, and a schema that rejected the first is otherwise a mystery.
  • A tool that threw is shown as a failure. The protocol reports it as a successful response whose content happens to be the exception message, so without that it reads as the result.
  • The call goes through McpServer::dispatch(), the same method the stdio loop calls. A tool that works when invoked directly and fails through the protocol is a real bug, and only the envelope shows it — the checkbox prints it.

The tab builds its own server when the container has none, so it works with the mcp feature off, and says so: what the feature adds is the container binding that an application's own tools are registered into. The POST that runs a tool carries a CSRF token, because the panel's other endpoints read and this one executes whatever a project registered.

The traffic log row says whether mcp:serve --log has ever run, and links mcp.log into the log viewer. The panel cannot switch it on: that log belongs to the server process the client started, which is not this one. See the MCP guide for mcp:call and --log.

Logs

/devpanel/logsthe administration area's log viewer, served here.

It used to be a second, smaller one: a table with three filters, written beside a LogController that already had pagination, reverse order, follow, per-level filtering, statistics, cross-file search, export, rotate and archive. Reported as «γιατί τα logs στο devpanel δεν είναι τα ίδια με τον κανονικό controller;» — and the honest answer was that they had no reason not to be.

The reason they were not was one hard-coded URL. LogViewer built the address of its own raw endpoint from adminUrl('logs'), so the component could be embedded in exactly one place, and rather than make that a parameter a smaller viewer was written. renderViewer() takes a base URL now:

(new LogViewer($files, $controller))->renderViewer($file, $base);

so the panel serves the same component from its own address, behind its own guard — a signed debug grant rather than an admin session, which is the whole reason to read a log from here. /devpanel/raw is this panel's copy of the endpoint the frame loads from, guarded the same way.

What the panel keeps of its own is the part the administration screen has nothing like: the requests that failed, grouped by request id. That section is short on purpose and empty on a server nobody is debugging — lines carry a request id only while the debug toolbar is active for that visitor, because on a live server everybody else is logging into the same seconds and their lines are not a developer's to read.

What it does not reproduce is linked instead: statistics, cross-file search, filter and export stay in /admin/Logs, and the panel says plainly that they need an admin session. Writing a second copy of each is what produced the viewer this replaced.

A file name from the URL is a choice, never a path. It is compared against the names actually on disk rather than joined to the log directory, so there is nothing to get subtly wrong about how many .. a path can contain.

/devpanel/logs?request=<id> still answers JSON, which is what the debug toolbar asks for. The same address serves both because the toolbar always passes an id and a person never does — and before that, a person who opened /devpanel/logs got a 400 about a parameter they had no way to know existed.

Git, PHP Info

HEAD commit, branches and remotes; and phpinfo() for admins.

Adding your own panel

\Pramnos\DevPanel\DevPanelController::registerPanel(
    'billing',
    'Billing',
    fn(): string => '<p>Anything you can render as HTML.</p>'
);

Register it from a service provider. The slug becomes a route (/devpanel/billing) and a tab, and inherits the same access guard.

When a panel looks empty

Empty is an answer, and it used to be the wrong one often enough to be worth a section.

  • A section that could not load says so. Failures are rendered as a warning above the panel, and logged to the devpanel log. A panel that shows an empty table and no warning really did find nothing.
  • Check the Background Work card before concluding a table is empty: rows may be spooled.
  • Check the window on Users and Performance — both default to a bounded period.
  • Check the cache adapter on the Cache tab: an unexpected file means a fallback, and a fallback means the data you are looking for is in another store.