Laravel Telescope Alternatives: Building a Lightweight Debug Bar with Custom Watchers
#laravel #debugging #livewire #observability

Laravel Telescope Alternatives: Building a Lightweight Debug Bar with Custom Watchers

3 min read Mohamed Said Mohamed Said

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_ENV alone.
  • The pattern composes cleanly with Filament — render the panel as a custom Filament widget on a restricted admin page.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Is it safe to enable this in a staging environment with real traffic?
Yes, provided you set a sensible entry limit per watcher (50–100 entries) and gate the panel behind authentication. The in-memory buffer never writes to disk or a database, so the overhead is a few microseconds per event listener invocation.
Q02 How do I add a watcher for failed jobs without polling the database?
Listen to Illuminate\Queue\Events\JobFailed inside a JobWatcher::register() method. The event carries the job payload, exception, and connection name. Store a trimmed snapshot (message + trace top 5 frames) in the bounded buffer — no queue table queries needed.
Q03 Can this coexist with Telescope in local development?
Absolutely. Keep Telescope in your local dev dependencies and enable the custom panel only in staging via an environment variable. They listen to the same framework events independently and do not conflict.

Continue reading

More Articles

View all