Request Logger Middleware — Full HTTP Lifecycle 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-IDto every request - Logging method, URL, user, duration, and response status
- Persisting to a
request_logstable 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_logsfor DB-level querying and cross-referencing withmodel_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 therequest_logstable 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-IDheader is your single most valuable debugging tool in production. When a user reports a bug, ask them to open DevTools and copy theX-Request-IDfrom 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 atwarning, a 500 aterror— this way your monitoring alerts on error-level logs to catch real problems, and you can filter out theinfonoise during debugging.
Further Reading
- Laravel Docs: Middleware
- Laravel Docs:
Log::shareContext() - RFC 7240: HTTP Request ID — the convention behind the
X-Request-IDheader - Laravel Docs: HTTP Logging