DEV Community

Cover image for Laravel Request Logger Middleware: Full HTTP Observability
Shakil Alam
Shakil Alam

Posted on Originally published at blog.shakiltech.com

Laravel Request Logger Middleware: Full HTTP Observability

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');
});
Enter fullscreen mode Exit fullscreen mode

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];
    }
}
Enter fullscreen mode Exit fullscreen mode

The Power of Log::shareContext()

Notice this line in handle():

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

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}
Enter fullscreen mode Exit fullscreen mode

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);
})
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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 →

Top comments (0)