Why Telescope Falls Short in Production
Laravel Telescope is a fantastic development companion, but enabling it in production — even with sampling — introduces database writes on every request, eager-loaded watchers, and a non-trivial memory footprint per worker lifecycle. For teams that need targeted, low-overhead visibility into specific routes or tenants in production, a purpose-built inspector is a better trade-off.
This article walks through building one: a scoped request inspector that captures only what you care about, stores snapshots in tagged Redis cache with a short TTL, and surfaces them in a read-only Filament panel.
The Core: A Terminable Middleware
PHP's terminate() hook runs after the response is sent to the client, making it ideal for non-blocking capture.
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\HttpFoundation\Response;
class CaptureRequestSnapshot
{
public function handle(Request $request, \Closure $next): Response
{
return $next($request);
}
public function terminate(Request $request, Response $response): void
{
if (! $this->shouldCapture($request)) {
return;
}
$snapshot = [
'id' => (string) str()->ulid(),
'method' => $request->method(),
'path' => $request->path(),
'status' => $response->getStatusCode(),
'duration_ms'=> $this->duration(),
'memory_kb' => round(memory_get_peak_usage() / 1024),
'input' => $request->except(['password', 'token']),
'headers' => $this->safeHeaders($request),
'captured_at'=> now()->toIso8601String(),
];
Cache::tags(['request-snapshots'])
->put('snapshot:' . $snapshot['id'], $snapshot, now()->addMinutes(30));
Cache::tags(['request-snapshots'])
->put('snapshot-index', $this->appendIndex($snapshot['id']), now()->addMinutes(30));
}
private function shouldCapture(Request $request): bool
{
return config('inspector.enabled')
&& $request->routeIs(...config('inspector.routes', []));
}
private function duration(): float
{
return defined('LARAVEL_START')
? round((microtime(true) - LARAVEL_START) * 1000, 2)
: 0.0;
}
private function safeHeaders(Request $request): array
{
return collect($request->headers->all())
->except(['authorization', 'cookie', 'x-csrf-token'])
->toArray();
}
private function appendIndex(string $id): array
{
$index = Cache::tags(['request-snapshots'])->get('snapshot-index', []);
array_unshift($index, $id);
return array_slice($index, 0, 200); // cap at 200 entries
}
}
Register it in bootstrap/app.php (Laravel 11+) or Kernel.php against specific route groups only — never globally.
Configuration: Opt-In by Route Pattern
// config/inspector.php
return [
'enabled' => env('REQUEST_INSPECTOR_ENABLED', false),
'routes' => [
'api.orders.*',
'api.payments.*',
],
];
Keeping it opt-in via an environment flag means you can toggle capture per environment or even per deployment without a code change.
Surfacing Data in a Read-Only Filament Page
Rather than building a custom UI from scratch, a Filament custom page gives you a polished interface in minutes.
namespace App\Filament\Pages;
use Filament\Pages\Page;
use Illuminate\Support\Facades\Cache;
class RequestInspector extends Page
{
protected static string $view = 'filament.pages.request-inspector';
protected static ?string $navigationIcon = 'heroicon-o-magnifying-glass';
protected static ?string $navigationGroup = 'Diagnostics';
public array $snapshots = [];
public function mount(): void
{
$index = Cache::tags(['request-snapshots'])->get('snapshot-index', []);
$this->snapshots = collect($index)
->map(fn (string $id) => Cache::tags(['request-snapshots'])->get('snapshot:' . $id))
->filter()
->values()
->toArray();
}
}
The Blade view iterates $snapshots and renders a simple table — duration, status badge, path, and a collapsible JSON viewer for the input payload.
Keeping It Safe
- Sensitive data: always strip credentials in
safeHeaders()and use$request->except()for input. - TTL discipline: 30-minute TTL means stale data self-evicts without a cron.
- Index cap: capping at 200 entries prevents unbounded cache growth.
- Gate protection: wrap the Filament page with a policy or
canAccess()override so only engineers with the right role see it.
Takeaways
- Terminable middleware runs post-response, making it safe for non-blocking capture.
- Tagged Redis cache gives you logical grouping and bulk invalidation without a dedicated table.
- Route pattern matching in config keeps capture surgical — never capture everything.
- A Filament custom page surfaces the data without a bespoke frontend.
- TTL + index cap are your two levers against runaway storage growth.
- Environment flags let ops teams toggle capture without deploys.