Series: Every Laravel Project Should Have These Building Blocks

Part: 27 of 35 Level: Intermediate Prerequisites: Structured Logging

What You’ll Learn

  • Why every production app needs a request log
  • Assigning a unique X-Request-ID to every request
  • Logging method, URL, user, duration, and response status
  • Persisting to a request_logs table for DB cross-referencing
  • Writing the log after the response is sent, so logging never slows down or breaks a request
  • Excluding health check and static routes

Why You Need a Request Log

When a user reports “something went wrong around 2pm yesterday”, you need to answer: which request, with which data, made by which user, that took how long, and got what response?

Without a request log: you dig through exception logs, guess at timing, and can’t confirm which user triggered what.

With a request log: one query on the request_logs table gives you the complete picture — and the request_id connects it to every model change and exception that happened during that request.

The request_logs Table

Schema::create('request_logs', function (Blueprint $table): void { $table->id(); $table->string('request_id', 36)->unique(); // UUID (unique() is also the index) $table->string('method', 10); $table->string('url', 2048); $table->string('ip_address', 45)->nullable(); $table->unsignedBigInteger('user_id')->nullable(); $table->string('user_type', 50)->nullable(); // which guard $table->unsignedSmallInteger('status_code'); $table->unsignedInteger('duration_ms'); $table->timestamp('created_at'); $table->index(['user_id', 'user_type']); $table->index('created_at'); }); 

Note: no updated_at — request logs are append-only.

The Full Middleware

// app/Http/Middleware/RequestLogger.php <?php declare(strict_types=1); namespace App\Http\Middleware; use App\Models\RequestLog; use Closure; use Illuminate\Http\Request; use Illuminate\Support\Facades\Context; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use Symfony\Component\HttpFoundation\Response; use Throwable; class RequestLogger { public const X_REQUEST_ID = 'X-Request-ID'; /** * Paths to skip entirely (health checks, tooling, CSP reports). * CSP reports can arrive in floods; logging each one can exhaust PHP-FPM. */ private const SKIP_PATHS = [ 'up', 'health', '_ignition', 'telescope', 'horizon', 'favicon.ico', 'api/csp-violation-report', ]; private const GUARDS = ['admin', 'merchandiser', 'employee', 'web']; public function handle(Request $request, Closure $next): Response { if ($request->is(...self::SKIP_PATHS)) { return $next($request); } // Always generate the ID on the server. Never trust an incoming // X-Request-ID: clients could forge it or collide with the unique index. $requestId = (string) Str::uuid(); $request->headers->set(self::X_REQUEST_ID, $requestId); $request->attributes->set('request_started_at', microtime(true)); // Every log line (and every job dispatched in this request) gets the ID. Context::add('request_id', $requestId); $response = $next($request); // Add the ID to the response so API clients can reference it $response->headers->set(self::X_REQUEST_ID, $requestId); return $response; } /** * Runs after the response has been sent, so logging never slows the user down. */ public function terminate(Request $request, Response $response): void { $startedAt = $request->attributes->get('request_started_at'); if ($startedAt === null) { return; // skipped path } // Logging is bookkeeping: a failure here is reported, never thrown. try { $durationMs = (int) round((microtime(true) - $startedAt) * 1000); [$userId, $guard] = $this->resolveActor(); $this->log($request, $response, $durationMs, $userId, $guard); RequestLog::create([ 'request_id' => $request->headers->get(self::X_REQUEST_ID), 'method' => $request->method(), 'url' => substr($request->fullUrl(), 0, 2048), 'ip_address' => $request->ip(), 'user_id' => $userId, 'user_type' => $guard, 'status_code' => $response->getStatusCode(), 'duration_ms' => $durationMs, 'created_at' => now(), ]); } catch (Throwable $e) { report($e); } } private function log(Request $request, Response $response, int $durationMs, ?int $userId, ?string $guard): void { $level = match (true) { $response->getStatusCode() >= 500 => 'error', $response->getStatusCode() >= 400 => 'warning', $durationMs > 3000 => 'warning', // slow request default => 'info', }; // request_id is added automatically from Context Log::channel('request')->{$level}('Request', [ 'method' => $request->method(), 'path' => $request->path(), 'status' => $response->getStatusCode(), 'duration_ms' => $durationMs, 'user_id' => $userId, 'guard' => $guard, 'ip' => $request->ip(), ]); } /** * The user id and the guard it came from, read from the same guard. * * @return array{0: int|null, 1: string|null} */ private function resolveActor(): array { foreach (self::GUARDS as $guard) { if (config("auth.guards.{$guard}") !== null && auth($guard)->check()) { return [(int) auth($guard)->id(), $guard]; } } return [null, null]; } } 

The Power of Log::shareContext()

Notice this line in handle():

Context::add('request_id', $requestId); // Laravel 10 and earlier: Log::shareContext(['request_id' => $requestId]); 

This injects request_id into every single log entry made during this request – from any service, any action, any listener. You don’t have to pass the request ID around manually.

Result in laravel.log:

{"message":"Processing payment","request_id":"550e8400-e29b-41d4-a716-446655440000","order_id":1234} {"message":"Payment confirmed","request_id":"550e8400-e29b-41d4-a716-446655440000","stripe_intent":"pi_abc"} {"message":"Inventory reserved","request_id":"550e8400-e29b-41d4-a716-446655440000","items":3} 

All three log entries carry the same request_id. Grep for the ID, get the full story.

Registering the Middleware

In Laravel 11 (bootstrap/app.php):

->withMiddleware(function (Middleware $middleware): void { $middleware->append(\App\Http\Middleware\RequestLogger::class); }) 

append() puts it at the end of the global stack – after authentication, so auth()->id() is populated.

Querying Request Logs

// What happened during a specific request? $logs = RequestLog::where('request_id', $id)->first(); // All slow requests in the last 24 hours RequestLog::where('duration_ms', '>', 2000) ->where('created_at', '>', now()->subHours(24)) ->orderByDesc('duration_ms') ->get(); // All 500 errors for a specific user RequestLog::where('user_id', $userId) ->where('status_code', '>=', 500) ->latest() ->get(); // Cross-reference with ModelChangeLog $modelChanges = ModelChangeLog::where('request_id', $requestId)->get(); 

FAQs

How do I log all HTTP requests in Laravel?

A terminable middleware that records method, path, user, payload, status, and duration after the response is sent – zero user-facing latency. Code below.

Won’t logging every request slow my app down?

Use terminate() so writing happens after the response, and prune the table on a schedule.

Key Takeaways

  • Every request gets a UUID (X-Request-ID) that flows through logs, model changes, and exception reports.
  • Log::shareContext(['request_id' => $id]) automatically injects the ID into every log entry for the duration of the request. The middleware uses Laravel 11’s Context::add(), which does the same and also carries the ID into queued jobs.
  • Log level is derived from response status and duration – 5xx = error, 4xx = warning, slow = warning.
  • Persist to request_logs for DB-level querying and cross-referencing with model_change_logs.
  • Skip health check and static paths to avoid noise.
  • Wrap the persist() call in try/catch – a logging failure should never break the application.

Tips and Gotchas

⚠️ Warning: Wrap the RequestLog::create() call in a try/catch. If the request_logs table is unavailable (migration not run, DB connection issue), a bare exception here will throw a 500 for every request. Log the failure to a file instead and let the request proceed.

💡 Tip: The X-Request-ID header is your single most valuable debugging tool in production. When a user reports a bug, ask them to open DevTools and copy the X-Request-ID from any request header. Grep that ID across all log files to reconstruct exactly what happened.

🔥 Expert Note: The log level should reflect the response, not just the fact of logging. A 200 response at info, a 400 at warning, a 500 at error – this way your monitoring alerts on error-level logs to catch real problems, and you can filter out the info noise during debugging.

Further Reading

← AI in Laravel Development | Next: ErrorReporter →