Documentation menu
On this page
Packages · v1.x
WebProfiler
The WebProfiler package collects information about every HTTP request in development and shows it in a web debug toolbar injected at the bottom of HTML pages and in a profiler interface (/_profiler).
It is a structure: the package only renders, stores and routes. Each feature (component, package, process or application code) adds its own element by creating one class that returns plain data and value objects; the package does all the HTML.
No external library is used: the pages and the toolbar are self-contained (inline CSS and JS, no CDN, light and dark themes).
Quick start
The package is enabled when the kernel runs in debug mode (APP_DEBUG=1, default in dev). Nothing else is required:
- open any HTML page of the application: the toolbar appears at the bottom;
- click an item (or the list icon) to open the full profile;
- every response carries
X-Debug-TokenandX-Debug-Token-Linkheaders, also for JSON APIs.
In production (kernel.debug false) the package never collects, never registers its routes and never injects anything.
How it works
| Step | Event | What happens |
|---|---|---|
| 1 | RequestEvent (priority 2048) |
the routes are registered, the stopwatch starts (bootstrap, kernel.request) |
| 2 | ControllerEvent (priority -2048) |
kernel.request stops, controller starts |
| 3 | ExceptionEvent (priority 2048) |
the exception is kept for the elements |
| 4 | ResponseEvent (priority -2048) |
every element collect()s, the profile is stored, the headers are added, the toolbar loader is injected before </body> |
| 5 | browser | the loader fetches /_wdt/{token} (toolbar fragment) and tracks the fetch / XMLHttpRequest calls of the page |
Elements are discovered and cached in var/cache/web_profiler/profilers.{env}.php (refreshed automatically in debug when a file changes):
- framework: every class in
src/{components|packages|process}/<Feature>/Helper/Profiler/*.phpimplementingProfilerElementInterface(path convention, no attribute needed); - application: every class of
src/carrying#[AsProfiler]and implementingProfilerElementInterface; - configuration: the classes listed in
panels:ofconfig/packages/web_profiler.yaml.
Elements are resolved through the container (constructor autowiring) and sorted by priority (highest first); the priority is #[AsProfiler(priority: …)] when set, else getPriority().
Adding an element to a feature
Create ./src/{components|packages|process}/<Feature>/Helper/Profiler/<Feature>Profiler.php. An element implements ToolbarInterface, ProfilerInterface or both; both extend ProfilerElementInterface, so the data is collected once:
interface ProfilerElementInterface
{
public function getName(): string;
public function getPriority(): int;
public function collect(Request $request, Response $response, ?Throwable $exception = null): array;
}
interface ToolbarInterface extends ProfilerElementInterface
{
public function getToolbarItem(Profile $profile, array $data): ?ToolbarItem;
}
interface ProfilerInterface extends ProfilerElementInterface
{
public function getPanel(Profile $profile, array $data): ?Panel;
}
Rules:
collect()runs once per request and returns JSON-safe data (scalars and arrays). It must not keep objects: values are passed throughValueExporter::export()anyway (objects become strings or arrays), and the profile is read later, in another request;getToolbarItem()andgetPanel()run when the toolbar or the profiler page is displayed, only with the stored$data: never access services there;- return
nullto hide the item or the panel for this profile; - never write HTML: return value objects, the package escapes and renders them.
AbstractProfiler gives getName() (short class name without Profiler, snake case: CacheProfiler → cache), getPriority() (constant PRIORITY) and export().
Full example for the Cache component (assuming the cache manager records its calls in a getCalls() method), ./src/components/Cache/Helper/Profiler/CacheProfiler.php:
<?php
declare(strict_types=1);
namespace NeoPHP\Component\Cache\Helper\Profiler;
use NeoPHP\Component\Cache\Contract\CacheManagerInterface;
use NeoPHP\Component\Container\Contract\ContainerInterface;
use NeoPHP\Component\Http\Request\Request;
use NeoPHP\Component\Http\Response\Response;
use NeoPHP\Package\WebProfiler\Block\KeyValueBlock;
use NeoPHP\Package\WebProfiler\Block\MetricBlock;
use NeoPHP\Package\WebProfiler\Block\TableBlock;
use NeoPHP\Package\WebProfiler\Contract\AbstractProfiler;
use NeoPHP\Package\WebProfiler\Contract\ProfilerInterface;
use NeoPHP\Package\WebProfiler\Contract\ToolbarInterface;
use NeoPHP\Package\WebProfiler\Model\Metric;
use NeoPHP\Package\WebProfiler\Model\Panel;
use NeoPHP\Package\WebProfiler\Model\Profile;
use NeoPHP\Package\WebProfiler\Model\Status;
use NeoPHP\Package\WebProfiler\Model\ToolbarItem;
use Throwable;
class CacheProfiler extends AbstractProfiler implements ToolbarInterface, ProfilerInterface
{
public const PRIORITY = 50;
public function __construct(protected ContainerInterface $container)
{
}
public function collect(Request $request, Response $response, ?Throwable $exception = null): array
{
if (!$this->container->bound(CacheManagerInterface::class)) {
return [];
}
$calls = $this->container->get(CacheManagerInterface::class)->getCalls();
return [
'hits' => count(array_filter($calls, static fn (array $call): bool => $call['hit'])),
'calls' => array_map(static fn (array $call): array => [$call['pool'], $call['method'], $call['key'], $call['hit'], $call['time']], $calls),
];
}
public function getToolbarItem(Profile $profile, array $data): ?ToolbarItem
{
if (($data['calls'] ?? []) === []) {
return null;
}
$calls = count($data['calls']);
$ratio = (int) round($data['hits'] / $calls * 100);
return new ToolbarItem('Cache', (string) $calls, 'cache', $ratio < 50 ? Status::WARNING : Status::DEFAULT, [
'Calls' => $calls,
'Hits' => $data['hits'],
'Hit ratio' => $ratio . ' %',
]);
}
public function getPanel(Profile $profile, array $data): ?Panel
{
$calls = (array) ($data['calls'] ?? []);
return new Panel('Cache', 'cache', [
new MetricBlock([
new Metric('Calls', count($calls)),
new Metric('Hits', (int) ($data['hits'] ?? 0), null, Status::SUCCESS),
]),
new TableBlock(['Pool', 'Method', 'Key', 'Hit', 'Time (ms)'], $calls, 'Calls', 'No cache call.'),
new KeyValueBlock(['Profile' => $profile->getToken()], 'Context'),
], count($calls));
}
}
Nothing to register: the class is found on the next request (the discovery cache is refreshed in debug). If the feature does not depend on the WebProfiler package at runtime, keep the element in Helper/Profiler: it is only loaded when the profiler is enabled.
To measure durations in the timeline, inject the Stopwatch (see Stopwatch).
Custom elements in the application
Any class of src/ with #[AsProfiler]:
<?php
declare(strict_types=1);
namespace App\Profiler;
use App\Service\Cart;
use NeoPHP\Component\Http\Request\Request;
use NeoPHP\Component\Http\Response\Response;
use NeoPHP\Package\WebProfiler\Attribute\AsProfiler;
use NeoPHP\Package\WebProfiler\Block\KeyValueBlock;
use NeoPHP\Package\WebProfiler\Contract\AbstractProfiler;
use NeoPHP\Package\WebProfiler\Contract\ProfilerInterface;
use NeoPHP\Package\WebProfiler\Contract\ToolbarInterface;
use NeoPHP\Package\WebProfiler\Model\Panel;
use NeoPHP\Package\WebProfiler\Model\Profile;
use NeoPHP\Package\WebProfiler\Model\ToolbarItem;
use Throwable;
#[AsProfiler(priority: 10)]
class CartProfiler extends AbstractProfiler implements ToolbarInterface, ProfilerInterface
{
public function __construct(protected Cart $cart)
{
}
public function collect(Request $request, Response $response, ?Throwable $exception = null): array
{
return ['items' => $this->cart->count(), 'total' => $this->cart->total()];
}
public function getToolbarItem(Profile $profile, array $data): ?ToolbarItem
{
return new ToolbarItem('Cart', (string) $data['items'], 'info');
}
public function getPanel(Profile $profile, array $data): ?Panel
{
return new Panel('Cart', 'info', [new KeyValueBlock($data)]);
}
}
Classes outside src/ (a vendor library for instance) can be listed in the configuration:
panels:
- Acme\Profiler\QueueProfiler
Two elements cannot share the same getName() (InvalidElementException). An element throwing during collect() or getPanel() does not break the request: its panel shows the error.
Toolbar items
NeoPHP\Package\WebProfiler\Model\ToolbarItem:
| Argument | Type | Description |
|---|---|---|
label |
string |
text after the value (hidden on small screens) |
value |
string |
short value in bold (200, 12 ms) |
icon |
string |
a built-in icon key or a trusted inline <svg> |
status |
string |
Status::DEFAULT, SUCCESS, INFO, WARNING, DANGER (red item) |
details |
array |
label => value rows of the popover (escaped) |
panel |
?string |
panel opened on click; default: the element name when it implements ProfilerInterface |
linked |
bool |
false for an item without link |
Built-in icon keys: info, request, response, time, memory, exception, config, database, route, user, mail, cache, event, log, view, form, security, ajax, php, logo, close, list (Renderer\Icons).
The toolbar also shows an Ajax item listing the fetch() and XMLHttpRequest calls made by the page (method, status, URL, duration and a link to their profile read from the X-Debug-Token response header). The toolbar can be hidden (state remembered in localStorage).
Toolbar assets
The toolbar is loaded with Ajax and inserted with innerHTML, so an element cannot put a <script> in its item. An element that needs a behaviour on the page (a widget opened by its toolbar item, keyboard shortcuts...) implements NeoPHP\Package\WebProfiler\Contract\ToolbarAssetInterface:
public function getToolbarAssets(Profile $profile, array $data): array
{
return [
'css' => '.my-widget{position:fixed;right:16px;bottom:48px}',
'js' => '(function (config) { document.querySelector(\'.neo-wdt-item[data-name="my_element"] .neo-wdt-main\').addEventListener(\'click\', function () { alert(config.token); }); })(window.__neoWdt.config);',
];
}
- The assets are rendered once per toolbar, after the items: the CSS in a
<style>tag, the JS executed bytoolbar.jsonce the toolbar is in the page (the item of the element is available with.neo-wdt-item[data-name="<element name>"]). window.__neoWdt.configgives the toolbar configuration (tokenof the current profile,profilerPath,toolbarUrl).- Use a
ToolbarItemwithlinked: falseto handle the click yourself. - The assets are trusted code of the element: never put request data in them without
json_encode()(JSON_HEX_TAG). - They are skipped when
collect()failed for this profile.
Panels and blocks
Model\Panel(title, icon = 'info', blocks = [], badge = null, badgeStatus = Status::DEFAULT) is displayed in the left menu of the profile page with its badge. add(BlockInterface) appends a block.
Built-in blocks (NeoPHP\Package\WebProfiler\Block), all values are escaped:
| Block | Type | Constructor |
|---|---|---|
TableBlock |
table |
(headers, rows = [], title = null, emptyMessage = 'No data.'), a cell can be a scalar, an array (dumped) or a block |
KeyValueBlock |
key_value |
(items, title = null, emptyMessage) |
MetricBlock |
metric |
(Metric[] metrics, title = null), Metric(label, value, unit = null, status, help = null) |
TimelineBlock |
timeline |
(events, total = null, title = null), events are TimelineEvent(name, start ms, duration ms, category, memory) or arrays (Stopwatch::toArray()) |
CodeBlock |
code |
(content, title = null, language = null, firstLine = 0, highlightLine = null) |
AlertBlock |
alert |
(message, status = Status::INFO, title = null) |
TextBlock |
text |
(text, title = null) |
SectionBlock |
section |
(title, blocks, collapsed = false), collapsible |
TabsBlock |
tabs |
(['Tab label' => blocks[]], title = null) |
HtmlBlock |
html |
(html, title = null): raw HTML, not escaped. Only for trusted HTML built by the developer, never for collected data |
Custom block types
A block implements BlockInterface (getType()) or extends AbstractBlock (constant TYPE, optional title). Its renderer implements BlockRendererInterface:
class ChartBlockRenderer implements BlockRendererInterface
{
public function getTypes(): array
{
return [ChartBlock::TYPE];
}
public function render(BlockInterface $block, BlockRenderer $renderer): string
{
return $renderer->title($block->getTitle()) . '<div class="chart">' . $renderer->escape($block->getLabel()) . '</div>';
}
}
Register it in block_renderers: of the configuration, or at runtime with $container->get(BlockRenderer::class)->register(new ChartBlockRenderer()). BlockRenderer gives escape(), value(), title(), render(), renderBlocks() and uniqueId().
Stopwatch
NeoPHP\Package\WebProfiler\Stopwatch\Stopwatch (service Stopwatch::class, alias stopwatch) measures named events displayed in the Performance timeline:
$stopwatch->start('orm.query', 'database');
$stopwatch->stop('orm.query');
$stopwatch->lap('import');
$result = $stopwatch->measure('render', fn () => $view->render(), 'view');
Times are in milliseconds from the start of the request (REQUEST_TIME_FLOAT). Categories kernel, controller, database and view have their own color.
Storage
Profiles are JSON files in var/profiler/{token}.json with an index var/profiler/index.jsonl (one summary per line). FileProfileStorage implements ProfileStorageInterface:
| Method | Description |
|---|---|
write(Profile) |
stores the profile, then purges |
read(token) |
?Profile |
find(limit, filters) |
summaries, newest first; filters ip, url (contains), method, status (404 or 4xx), token (prefix), route |
purge() |
deletes the profiles over max_profiles or older than lifetime |
clear() |
deletes everything |
Replace the storage by binding another ProfileStorageInterface implementation in the container.
Model\Profile holds token, ip, method, url, status, time, duration (ms), memory (peak bytes), route, content_type and the data of each element (getData('request')).
Configuration
config/packages/web_profiler.yaml:
enabled: '%kernel.debug%'
toolbar: true
path: /_profiler
toolbar_path: /_wdt
storage: var/profiler
max_profiles: 200
lifetime: 86400
ajax_limit: 50
excluded_paths:
- '^/(favicon\.ico|robots\.txt|build|builds|assets)(/|$)'
panels: []
block_renderers: []
| Option | Default | Description |
|---|---|---|
enabled |
%kernel.debug% |
never enabled when kernel.debug is false |
toolbar |
true |
injects the toolbar in HTML responses |
path |
/_profiler |
prefix of the profiler pages |
toolbar_path |
/_wdt |
prefix of the toolbar fragment |
storage |
var/profiler |
relative to the project root |
max_profiles |
200 |
profiles kept |
lifetime |
86400 |
seconds, 0 = unlimited |
ajax_limit |
50 |
requests listed in the Ajax item |
excluded_paths |
assets, favicon, robots | regular expressions of paths never profiled |
panels |
[] |
extra element classes |
block_renderers |
[] |
extra BlockRendererInterface classes |
The profiler and toolbar paths are always excluded. The toolbar is only injected in responses with a text/html content type containing </body>, not for redirections, attachments, HEAD or XMLHttpRequest requests.
Routes
Registered at request time (only when enabled):
| Name | Path | Description |
|---|---|---|
_profiler_index |
/_profiler |
last profiles with search filters (ip, url, method, status, token, limit) |
_profiler_latest |
/_profiler/latest |
redirects to the last profile |
_profiler_json |
/_profiler/{token}.json |
raw profile |
_profiler_show |
/_profiler/{token}?panel=name |
summary, menu of panels and selected panel |
_profiler_toolbar |
/_wdt/{token} |
toolbar HTML fragment |
Console
| Command | Description |
|---|---|
profiler:list [--limit=20] [--url=] [--method=] [--status=5xx] [--ip=] |
lists the last profiles |
profiler:clear [--expired] |
deletes all the profiles (or only the expired ones) |
Built-in elements
In src/packages/WebProfiler/Helper/Profiler/:
| Element | Name | Toolbar | Panel |
|---|---|---|---|
RequestProfiler |
request |
status code and route | request / response headers, query, body, attributes, cookies (values hidden), session, server |
ExceptionProfiler |
exception |
red item with the class (only on exception) | message, source excerpt, stack trace, previous exceptions |
PerformanceProfiler |
performance |
duration, memory in the popover | metrics, timeline, stopwatch events |
ConfigProfiler |
config |
NeoPHP version, env, debug, PHP | framework, PHP, php.ini, extensions, registered elements |
Sensitive keys (password, token, secret, authorization, cookie, api_key, csrf) are masked.
Exceptions
NeoPHP\Package\WebProfiler\Exception\*, all extend WebProfilerException, a FrameworkException:
| Exception | Thrown when |
|---|---|
WebProfilerException |
missing template, invalid block renderer class |
InvalidElementException |
an element not implementing ProfilerElementInterface, duplicated element name, a panel block not implementing BlockInterface |
StorageException |
profiler directory or files not writable, invalid token |
Limitations
- The toolbar loader is an inline
<script>: a strict Content-Security-Policy without'unsafe-inline'blocks it. - Streamed or binary responses are profiled but never receive the toolbar.
- The file storage is meant for development (no concurrency guarantee beyond
LOCK_EX).
Changelog
- v1.26.0 —
ToolbarAssetInterface: an element can add CSS and JavaScript to the toolbar (executed after the Ajax load),Profiler::getToolbarAssets(),window.__neoWdt.config. - v1.25.0 — WebProfiler package: profiler and web debug toolbar structure,
ProfilerElementInterface/ToolbarInterface/ProfilerInterfaceelements discovered inHelper/Profilerof every feature and with#[AsProfiler]in the application (cached),ToolbarItemandPanelvalue objects, blocks (Table,KeyValue,Metric,Timeline,Code,Alert,Text,Section,Tabs,Html) with an extensibleBlockRenderer,FileProfileStorage,Stopwatch,X-Debug-Tokenheaders, Ajax requests tracking,/_profilerand/_wdtroutes,profiler:listandprofiler:clearcommands, built-in Request, Exception, Performance and Config elements.