maximebf/debugbar
PHP Debug Bar adds a developer toolbar to PHP apps, collecting messages, database queries, timelines, exceptions, and rendered views. Framework-agnostic with integrations for Laravel, Symfony, and more, it helps profile and debug requests in the browser.
What you actually install. maximebf/debugbar is the framework-agnostic core; the composer package has been renamed to php-debugbar/php-debugbar (the old name is abandoned but still resolves). In Laravel you almost never touch it directly — you install the wrapper, which pulls the core in:
composer require --dev barryvdh/laravel-debugbar
php artisan vendor:publish --provider="Barryvdh\Debugbar\ServiceProvider"
Non-Laravel projects require the core directly and drive it via StandardDebugBar + JavascriptRenderer (see below).
First three files to read.
config/debugbar.php — this is the whole control surface: enabled, per-collector booleans, options.db, storage, capture_ajax, inject, except.vendor/php-debugbar/php-debugbar/src/DataCollector/QueryCollector.php — reading collect() tells you exactly what data lands in the tab.Barryvdh\Debugbar\Middleware\DebugbarEnabled / InjectDebugbar — explains when injection happens, which is the source of most "why is nothing showing up" confusion.Gating. Out of the box enabled is null, meaning "follow APP_DEBUG". In any environment that isn't your laptop, set it explicitly:
DEBUGBAR_ENABLED=false
and in config/debugbar.php leave 'enabled' => env('DEBUGBAR_ENABLED', null).
First use case — the one that pays for the install immediately. Load a slow page, open the Queries tab, sort by duration. Then enable history so you can inspect AJAX and POST requests that have already finished:
// config/debugbar.php
'storage' => [
'enabled' => true,
'driver' => 'file',
'path' => storage_path('debugbar'),
],
Now /_debugbar lists every captured request with its full dataset. That endpoint is the single most useful thing in the package — it works for JSON/AJAX/redirected requests where the toolbar can't render.
Instrument something in 30 seconds:
debugbar()->info('Cache miss for pricing rules');
debugbar()->startMeasure('price:calculate', 'Pricing calculation');
// ... work ...
debugbar()->stopMeasure('price:calculate');
You get a message in the Messages tab and a bar in the Timeline tab.
The toolbar is passive; the value comes from marking your own domain boundaries. A try/finally around measures is non-negotiable, because an unclosed measure throws on shutdown and pollutes the timeline:
use DebugBar\DebugBar;
debugbar()->startMeasure('checkout', 'Checkout flow');
try {
$total = $this->calculator->total($cart);
debugbar()->addMessage(['items' => $cart->count(), 'total' => $total], 'checkout');
} finally {
debugbar()->stopMeasure('checkout');
}
Messages must be strings — pass arrays through json_encode or debugbar()->addMessage(json_encode($payload), 'checkout'). addMessage with a non-scalar will silently render Array.
When you need structured, per-request state (e.g. pricing rules evaluated, feature flags resolved), a custom collector is far better than spamming messages:
namespace App\Support;
use DebugBar\DataCollector\DataCollector;
use DebugBar\DataCollector\Renderable;
class PricingCollector extends DataCollector implements Renderable
{
private array $rules = [];
public function addRule(string $name, float $ms): void
{
$this->rules[] = ['rule' => $name, 'ms' => $ms];
}
public function collect(): array
{
return [
'count' => count($this->rules),
'total_ms' => round(array_sum(array_column($this->rules, 'ms')), 2),
'rules' => $this->rules,
];
}
public function getName(): string { return 'pricing'; }
public function getWidgets(): array
{
return [
'pricing' => [
'icon' => 'tags',
'widget' => 'PhpDebugBar.Widgets.VariableListWidget',
'map' => 'pricing',
'default' => '{}',
],
];
}
}
Register it from a middleware so the collector is created and attached before $next($request) returns:
public function handle($request, Closure $next)
{
if (app()->bound('debugbar') && config('debugbar.enabled')) {
debugbar()->addCollector(app(PricingCollector::class));
}
return $next($request);
}
Reusing a built-in widget (VariableListWidget, MessagesWidget, TimelineWidget) means zero extra JavaScript. Only write a custom DebugBar.extendComponent when the shape genuinely doesn't fit.
The query tab is the most expensive collector. Shape it rather than turn it off:
'options' => [
'db' => [
'with_params' => true,
'backtrace' => true,
'backtrace_exclude_paths' => ['/vendor/laravel/framework'],
'timeline' => false,
'duration_backtrace' => false,
'explain' => ['enabled' => false, 'types' => ['SELECT']],
],
],
timeline => true puts every query on the timeline — great for spotting N+1s visually, brutal on pages with 3,000 queries. Turn it on temporarily.
The toolbar is injected into HTML responses only. For JSON endpoints, set 'capture_ajax' => true and use the X-Debugbar-Id response header to correlate; every AJAX request shows up in /_debugbar regardless of content type. For Inertia apps the initial page load gets a toolbar but the XHR navigation doesn't — the history view is your workflow, so storage must be enabled.
// Middleware, before $next():
if ($request->is('admin/*')) {
Debugbar::disable();
}
// In tests:
Debugbar::enable();
disable() only works if it runs before the inject middleware, so register your gate early in the web group. Config-level except is more reliable for permanent exclusions:
'except' => ['telescope/*', 'horizon/*', '*.pdf', 'api/exports/*'],
Collectors are queryable, which makes them a cheap N+1 guard in feature tests:
Debugbar::enable();
$this->get('/orders')->assertOk();
$queries = Debugbar::getCollector('db')->getQueries();
$this->assertLessThan(15, count($queries['statements']));
Keep DEBUGBAR_ENABLED=false in phpunit.xml for the rest of the suite — the middleware adds measurable overhead to every request.
$debugbar = new DebugBar\StandardDebugBar();
$debugbar['messages']->addMessage('bootstrapped');
$debugbar['time']->startMeasure('render', 'Rendering');
$debugbar['time']->stopMeasure('render');
$renderer = $debugbar->getJavascriptRenderer();
$renderer->setBaseUrl('/assets/debugbar'); // assets are not auto-served here
echo $renderer->renderHead();
echo $renderer->render();
You must serve vendor/php-debugbar/php-debugbar/src/Resources yourself, or call dumpJsAssets()/dumpCssAssets() into a public directory at build time.
Never let this reach production. The collectors dump config values, session payloads, mail bodies, and full SQL bindings. APP_DEBUG=false covers the default, but any explicit DEBUGBAR_ENABLED=true in a shared .env overrides it. Add a boot guard that hard-fails outside local/staging, and remember php artisan config:cache bakes the flag into the cached config — flip it, then re-cache.
SQL bindings leak secrets. With with_params => true, the _debugbar storage files contain password hashes, API tokens, and personal data bound into queries. The storage directory is under storage/ so it isn't web-reachable, but it is on disk and in backups. Prune with php artisan debugbar:clear and consider disabling with_params on shared staging.
Injection corrupts responses. The toolbar appends HTML to the response body and mutates Content-Length. This breaks streamed downloads, Content-Disposition exports, gzipped responses, and ETag-based caching. except those routes. If you see a PDF with <!-- Debugbar --> in it, this is why.
Nothing renders. Work the checklist in this order: (1) is the response text/html? (2) does an earlier middleware short-circuit before injection? (3) did something call Debugbar::disable() after your gate? (4) is config:cache stale? (5) view source — the <script> tags are usually there even when the toolbar is invisible, which points at a JS/asset 404 rather than a backend problem.
Octane state leakage. Collectors are request-scoped objects that can survive between requests in long-lived workers if you attach them in a service provider's boot(). Attach per-request in middleware, and make sure Laravel Debugbar's Octane listeners are registered (config/octane.php warmup/reset). Symptoms are requests showing another user's queries or messages.
Unclosed and duplicate measures. startMeasure() with the same name overwrites rather than nests. There is no stack. Nested timings require distinct names (checkout, checkout.tax, checkout.shipping). An unclosed measure produces a warning and a zero-length bar at shutdown — always finally.
Package rename friction. maximebf/debugbar is abandoned in favour of php-debugbar/php-debugbar. If a transitive dependency pins the old name while you require the new one, composer will happily install both and you get duplicate class definitions or a JavascriptRenderer that behaves differently from the docs. Check composer why php-debugbar/php-debugbar when upgrading. The v2 line has a different asset pipeline and UI than the v1 docs you'll find in older blog posts — verify against the tag you installed.
/_debugbar is a real route. It's unauthenticated by default and exposes the full request history. Restrict it with a can gate or middleware on route_prefix, and delete or disable storage entirely on any host with a public URL.
Queue workers and console. Debugbar is HTTP-oriented; instrumenting jobs with debugbar() inside a worker either does nothing or writes to the wrong storage bucket. Use Log/Telescope for async work, and keep the queue driver out of the collectors to avoid serialisation blowups on jobs carrying large payloads.
Performance tax is real on big pages. Bindings + backtrace + timeline on a page with hundreds of queries can add 100ms+ and hundreds of MB to the response payload. When a page is slow only with the bar on, disable duration_backtrace first, then backtrace, then timeline — that ordering isolates the cost without losing the tabs.
How can I help you explore Laravel packages today?