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
MassPrunablekeeps storage bounded automatically. - This pattern composes: add a
CacheHitRatioWatcherorHttpOutboundWatcherwithout touching existing code.