Why Not Just Use Telescope?
Laravel Telescope is excellent during local development, but its default DatabaseWatcher writes every request, query, and exception to your database synchronously. In production, that overhead compounds quickly — especially under load. Most teams either disable Telescope entirely or run it on a separate process with sampling, which adds operational complexity.
A focused, purpose-built inspector can give you 80% of the value at 10% of the cost. Here's how to build one.
Designing the Inspector
The goal: capture HTTP request metadata (method, URI, status, duration, authenticated user, peak memory) and persist it asynchronously to a Redis sorted set, with a Filament table to browse recent entries.
Three moving parts:
InspectRequestmiddleware — measures and serialises the request.RequestStore— a thin abstraction over Redis.- A Filament resource — reads from Redis and renders the table.
The Middleware
<?php
namespace App\Http\Middleware;
use App\Inspection\RequestStore;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class InspectRequest
{
public function __construct(private readonly RequestStore $store) {}
public function handle(Request $request, Closure $next): Response
{
$start = hrtime(true);
$response = $next($request);
$durationMs = (hrtime(true) - $start) / 1_000_000;
// Fire-and-forget: don't block the response
dispatch(static function () use ($request, $response, $durationMs): void {
app(RequestStore::class)->record([
'method' => $request->method(),
'uri' => $request->getRequestUri(),
'status' => $response->getStatusCode(),
'duration' => round($durationMs, 2),
'memory_kb' => (int) (memory_get_peak_usage(true) / 1024),
'user_id' => $request->user()?->id,
'ip' => $request->ip(),
'at' => now()->toIso8601String(),
]);
})->afterResponse();
return $response;
}
}
afterResponse() defers the closure until after the response is sent to the client, keeping p99 latency clean.
The RequestStore
<?php
namespace App\Inspection;
use Illuminate\Support\Facades\Redis;
final class RequestStore
{
private const KEY = 'inspector:requests';
private const LIMIT = 2000;
public function record(array $payload): void
{
$score = microtime(true);
$value = json_encode($payload, JSON_THROW_ON_ERROR);
Redis::pipeline(function ($pipe) use ($score, $value): void {
$pipe->zadd(self::KEY, $score, $value);
// Trim to the most recent LIMIT entries
$pipe->zremrangebyrank(self::KEY, 0, -(self::LIMIT + 1));
});
}
/** @return array<int, array<string, mixed>> */
public function recent(int $limit = 100): array
{
$raw = Redis::zrevrange(self::KEY, 0, $limit - 1);
return array_map(
static fn (string $item) => json_decode($item, true, 512, JSON_THROW_ON_ERROR),
$raw
);
}
}
Using a sorted set with a Unix timestamp score gives you free chronological ordering and O(log N) trimming.
Filament Resource (Read-Only)
Because the data lives in Redis rather than Eloquent, use a getTableQuery override that returns an in-memory collection wrapped in a query-compatible object — or, simpler, use Filament's table method on a plain page:
<?php
namespace App\Filament\Pages;
use App\Inspection\RequestStore;
use Filament\Pages\Page;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Support\Collection;
class RequestInspector extends Page implements HasTable
{
use InteractsWithTable;
protected static string $view = 'filament.pages.request-inspector';
protected static ?string $navigationIcon = 'heroicon-o-magnifying-glass';
public function table(Table $table): Table
{
$rows = collect(app(RequestStore::class)->recent(200))
->map(fn ($r, $i) => array_merge($r, ['id' => $i]));
return $table
->query(fn () => $rows) // Filament accepts a Closure returning a Collection
->columns([
TextColumn::make('method')->badge(),
TextColumn::make('uri')->limit(60)->tooltip(fn ($r) => $r['uri']),
TextColumn::make('status')->badge()
->color(fn ($state) => $state >= 500 ? 'danger' : ($state >= 400 ? 'warning' : 'success')),
TextColumn::make('duration')->suffix(' ms')->sortable(),
TextColumn::make('memory_kb')->suffix(' KB'),
TextColumn::make('user_id')->label('User'),
TextColumn::make('at')->label('Time')->since(),
]);
}
}
Note: Filament v4's
Tablecomponent accepts aCollection-returning Closure directly when no Eloquent model is involved — no fake model or custom paginator needed.
Registering the Middleware
// bootstrap/app.php (Laravel 11+)
->withMiddleware(function (Middleware $middleware): void {
$middleware->appendToGroup('web', \App\Http\Middleware\InspectRequest::class);
})
For API routes, append to the api group instead.
Key Takeaways
afterResponse()defers work until after the response is flushed — zero impact on perceived latency.- A Redis sorted set gives chronological ordering, O(log N) inserts, and automatic trimming without a migration.
- Keeping the store behind an interface lets you swap Redis for a database or S3 in tests without touching middleware.
- Filament's
Tablecomponent works with plainCollectiondata — no Eloquent dependency required. - This pattern is composable: add a
SamplingStoredecorator that records only 10% of requests in production with a singleif (random_int(1, 10) === 1)guard.