Request Logger Middleware — Full HTTP Lifecycle Observability

Request Logger Middleware — Full HTTP Lifecycle Observability

Reading Time: 3 minutes

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
  • Sanitizing sensitive fields from logged bodies
  • 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
    $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('request_id');
    $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\Log;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;

class RequestLogger
{
    public const X_REQUEST_ID = 'X-Request-ID';

    /**
     * Paths to skip entirely (health checks, static assets, etc.)
     */
    private const SKIP_PATHS = [
        'up',
        'health',
        '_ignition',
        'telescope',
        'horizon',
        'favicon.ico',
    ];

    /**
     * Request body keys to redact in logs.
     */
    private const SENSITIVE_KEYS = [
        'password',
        'password_confirmation',
        'current_password',
        'token',
        'secret',
        'api_key',
        'card_number',
        'cvv',
        'ssn',
    ];

    public function handle(Request $request, Closure $next): Response
    {
        // Skip noisy paths
        foreach (self::SKIP_PATHS as $skip) {
            if (str_starts_with(ltrim($request->path(), '/'), $skip)) {
                return $next($request);
            }
        }

        // Generate or propagate the request ID
        $requestId = $request->header(self::X_REQUEST_ID) ?? (string) Str::uuid();
        $request->headers->set(self::X_REQUEST_ID, $requestId);

        // Share the request ID in all log messages for this request
        Log::shareContext(['request_id' => $requestId]);

        $startTime = microtime(true);

        $response = $next($request);

        $durationMs = (int) round((microtime(true) - $startTime) * 1000);

        // Add the ID to the response so API clients can reference it
        $response->headers->set(self::X_REQUEST_ID, $requestId);

        $this->persist($request, $response, $requestId, $durationMs);
        $this->log($request, $response, $requestId, $durationMs);

        return $response;
    }

    private function persist(
        Request $request,
        Response $response,
        string $requestId,
        int $durationMs
    ): void {
        try {
            RequestLog::create([
                'request_id'  => $requestId,
                'method'      => $request->method(),
                'url'         => substr($request->fullUrl(), 0, 2048),
                'ip_address'  => $request->ip(),
                'user_id'     => auth()->id(),
                'user_type'   => $this->resolveGuard(),
                'status_code' => $response->getStatusCode(),
                'duration_ms' => $durationMs,
            ]);
        } catch (\Throwable) {
            // Never let logging break the application
        }
    }

    private function log(
        Request $request,
        Response $response,
        string $requestId,
        int $durationMs
    ): void {
        $level = match(true) {
            $response->getStatusCode() >= 500 => 'error',
            $response->getStatusCode() >= 400 => 'warning',
            $durationMs > 3000               => 'warning',  // slow request
            default                          => 'info',
        };

        Log::channel('request')->{$level}('Request', [
            'request_id'  => $requestId,
            'method'      => $request->method(),
            'path'        => $request->path(),
            'status'      => $response->getStatusCode(),
            'duration_ms' => $durationMs,
            'user_id'     => auth()->id(),
            'guard'       => $this->resolveGuard(),
            'ip'          => $request->ip(),
        ]);
    }

    private function resolveGuard(): ?string
    {
        foreach (['admin', 'merchandiser', 'employee', 'web'] as $guard) {
            if (auth($guard)->check()) {
                return $guard;
            }
        }
        return null;
    }
}

The Power of Log::shareContext()

Notice this line in handle():

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();

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.
  • 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 →

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.