Why Not Just Use Telescope?
Telescope is excellent for local development, but its storage writes on every request make it a liability in staging environments under load. More importantly, it captures everything — you often only care about a narrow slice: slow queries, specific job failures, or a single HTTP client call. Building a focused watcher gives you exactly what you need with negligible overhead.
This article walks through a production-safe, opt-in debug panel backed by Laravel's event system and a thin Livewire component.
The Core Abstraction: A Watcher Contract
Define a simple interface every watcher must implement:
namespace App\Debug\Contracts;
interface Watcher
{
public function register(): void;
public function entries(): array;
public function flush(): void;
}
Each watcher self-registers its listeners and stores entries in a bounded in-memory buffer — no database writes.
Building a Query Watcher
namespace App\Debug\Watchers;
use App\Debug\Contracts\Watcher;
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Support\Facades\Event;
class QueryWatcher implements Watcher
{
private array $entries = [];
private int $limit;
public function __construct(int $limit = 50)
{
$this->limit = $limit;
}
public function register(): void
{
Event::listen(QueryExecuted::class, function (QueryExecuted $event) {
if (count($this->entries) >= $this->limit) {
return;
}
$this->entries[] = [
'sql' => $event->sql,
'time' => $event->time,
'conn' => $event->connectionName,
'at' => now()->toTimeString(),
];
});
}
public function entries(): array { return $this->entries; }
public function flush(): void { $this->entries = []; }
}
The $limit guard is critical — without it a long-running Octane worker accumulates unbounded memory.
Wiring Watchers via the Service Container
Tag all watchers so the panel can resolve them without knowing each class:
// AppServiceProvider::register()
$this->app->singleton(QueryWatcher::class);
$this->app->tag([QueryWatcher::class], 'debug.watchers');
In a DebugServiceProvider boot method, conditionally activate them:
public function boot(): void
{
if (! config('debug-panel.enabled')) {
return;
}
foreach ($this->app->tagged('debug.watchers') as $watcher) {
$watcher->register();
}
}
Set debug-panel.enabled to true only via an environment variable — never hard-code it.
A Minimal Livewire Panel Component
namespace App\Livewire;
use Livewire\Component;
use Illuminate\Contracts\Container\Container;
class DebugPanel extends Component
{
public array $queries = [];
public function mount(Container $container): void
{
foreach ($container->tagged('debug.watchers') as $watcher) {
if ($watcher instanceof \App\Debug\Watchers\QueryWatcher) {
$this->queries = $watcher->entries();
}
}
}
public function render()
{
return view('livewire.debug-panel');
}
}
The Blade view renders a fixed-position overlay only when the X-Debug-Panel request header is present — checked in a middleware that sets a view composer variable.
Guarding Against Production Exposure
// routes/web.php
Route::get('/_debug', DebugPanelController::class)
->middleware(['auth', 'can:view-debug-panel']);
Couple this with a Gate::define('view-debug-panel', ...) that checks an internal role. Never rely solely on APP_DEBUG.
Extending: An HTTP Client Watcher
Laravel's Http facade fires RequestSending and ResponseReceived events since Laravel 10. A HttpClientWatcher listens to both and correlates them by request URL — giving you latency per external call without any instrumentation in your own code.
Takeaways
- Tag-based resolution lets you add watchers without touching the panel code.
- In-memory buffers with hard limits keep overhead negligible even on Octane.
- Event-driven registration means watchers are zero-cost when disabled.
- Gate-protected routes prevent accidental exposure; never trust
APP_ENValone. - The pattern composes cleanly with Filament — render the panel as a custom Filament widget on a restricted admin page.