Laravel Telescope Alternatives: Building a Lightweight Debug Bar with Custom Watchers
#laravel #debugging #filament #performance

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

3 min read Mohamed Said Mohamed Said

Why Not Just Use Telescope?

Laravel Telescope is excellent for local development, but enabling it in staging or production introduces real costs: every request writes multiple database rows, storage grows unbounded without aggressive pruning, and the overhead of recording every query, mail, and notification adds latency you cannot always afford.

The alternative is not "log everything to a file." The alternative is a targeted debug layer — one you design around the signals you actually care about.


The Core Idea: Watchers as First-Class Citizens

Telescope's own architecture is instructive. Each concern (queries, requests, exceptions) is a discrete Watcher class that subscribes to framework events. We can steal that pattern without the storage overhead.

// app/Debug/Contracts/Watcher.php
interface Watcher
{
    public function register(Dispatcher $events): void;
}
// app/Debug/Watchers/SlowQueryWatcher.php
final class SlowQueryWatcher implements Watcher
{
    public function __construct(
        private readonly int $thresholdMs = 200
    ) {}

    public function register(Dispatcher $events): void
    {
        $events->listen(QueryExecuted::class, function (QueryExecuted $event): void {
            if ($event->time < $this->thresholdMs) {
                return;
            }

            DebugEntry::create([
                'type'    => 'slow_query',
                'payload' => [
                    'sql'  => $event->sql,
                    'time' => $event->time,
                    'connection' => $event->connectionName,
                ],
                'context' => request()->path(),
            ]);
        });
    }
}

The DebugEntry model writes to a separate debug_entries table with a short TTL enforced by a scheduled prune command — not a background queue worker.


Registering Watchers Conditionally

Bind watchers through a dedicated service provider and gate them behind an environment check or a config flag:

// app/Providers/DebugServiceProvider.php
public function boot(): void
{
    if (! config('debug_layer.enabled')) {
        return;
    }

    $watchers = [
        app(SlowQueryWatcher::class),
        app(UnhandledExceptionWatcher::class),
        app(ScheduledJobWatcher::class),
    ];

    foreach ($watchers as $watcher) {
        $watcher->register($this->app['events']);
    }
}

Set debug_layer.enabled to true in staging via an environment variable. In production, flip it on temporarily during an incident and off again — zero deployment required.


A Minimal Filament Panel for Visibility

Rather than a custom blade view, a dedicated Filament panel gives you sortable tables, filters, and bulk-delete for free.

// app/Filament/Debug/Resources/DebugEntryResource.php
public static function table(Table $table): Table
{
    return $table
        ->columns([
            TextColumn::make('type')->badge(),
            TextColumn::make('context')->limit(40),
            TextColumn::make('payload.time')
                ->label('Duration (ms)')
                ->sortable(),
            TextColumn::make('created_at')->since(),
        ])
        ->filters([
            SelectFilter::make('type')
                ->options(DebugEntry::distinct('type')->pluck('type', 'type')),
        ])
        ->defaultSort('created_at', 'desc')
        ->poll('10s');
}

Mount this panel on a path guarded by an auth middleware that checks for a debug role. The 10-second poll gives you near-real-time visibility without WebSockets.


Pruning Without Bloat

// routes/console.php
Schedule::command('model:prune', ['--model' => DebugEntry::class])
    ->hourly();

Add MassPrunable to DebugEntry and define a prunable scope that deletes entries older than 24 hours. The table stays small; the signal stays fresh.


Takeaways

  • Watcher interface keeps each concern isolated and independently testable.
  • Config-gated registration means zero overhead when the layer is off.
  • Threshold filtering (e.g., only queries > 200 ms) prevents noise and write amplification.
  • Filament panel provides a production-grade UI without custom frontend work.
  • Scheduled pruning with MassPrunable keeps storage bounded automatically.
  • This pattern composes: add a CacheHitRatioWatcher or HttpOutboundWatcher without touching existing code.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Is this approach safe to enable in production?
Yes, with two guards in place: a config flag so you can toggle it without a deployment, and threshold filtering so only anomalous events (slow queries, exceptions) are recorded. Write volume stays low and the prune schedule keeps the table bounded.
Q02 How does this differ from simply logging to a file?
Structured database rows give you filterable, sortable, queryable entries. A log file gives you append-only text. The Filament panel turns those rows into an interactive UI with zero custom frontend code.
Q03 Can I add a watcher for outbound HTTP calls?
Yes. Laravel fires no built-in event for HTTP client calls, but you can register a global middleware on the HTTP client via Http::globalMiddleware() using a Guzzle handler that records slow or failed requests to DebugEntry.

Continue reading

More Articles

View all