Laravel Telescope Alternatives: Building a Lightweight Request Inspector with Middleware
#laravel #middleware #filament #debugging #redis

Laravel Telescope Alternatives: Building a Lightweight Request Inspector with Middleware

1 min read Mohamed Said Mohamed Said

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.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Is tagged cache available with all Laravel cache drivers?
No. Cache tags require a driver that supports them — Redis and Memcached both do, but the file and database drivers do not. If you use the file driver locally, switch to Redis or stub the tag calls behind a feature flag.
Q02 How do I prevent the inspector from capturing sensitive payloads in production?
Use `$request->except(['password', 'token', 'secret'])` for input and strip authorization headers in your `safeHeaders()` method. Additionally, consider encrypting the snapshot value before writing to cache if your Redis instance is shared.
Q03 Can this approach handle high-traffic routes without becoming a bottleneck?
Yes, because `terminate()` runs after the response is flushed to the client. The Redis write adds a few milliseconds of worker time but does not affect perceived latency. For very high-traffic routes, add a sampling check — e.g., only capture 10% of requests — inside `shouldCapture()`.

Continue reading

More Articles

View all