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 - 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’sContext::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_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
Top comments (0)