Laravel Structured Logging: Named Channels & Request Tracing
Series: Every Laravel Project Should Have These Building Blocks
Part: 19 of 35 Level: Intermediate Prerequisites: App Service Provider
Table of Contents
What You’ll Learn
- Why structured logging matters (and what it means)
- Named log channels – one concern per channel
- The
RequestLoggermiddleware – full request lifecycle logging - The
ErrorReporter– rate-limited exception emails - Slow query detection and logging
- How the
X-Request-IDties everything together
Why Logging Is an Architectural Decision
Most Laravel projects use one log file: storage/logs/laravel.log. Everything goes in there — exceptions, emails, slow queries, requests, scheduled commands. After a few months of production, debugging means searching through a wall of text where every line looks equally important.
Good logging is:
- Structured – machine-parseable JSON, not free-form strings
- Channeled – separate concerns in separate files
- Contextual – every log entry includes enough data to understand what happened
- Linked – related events can be traced together
Named Log Channels
The first change to make: create dedicated channels per concern.
// config/logging.php
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => ['single'],
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
],
// One channel per concern:
'request' => [
'driver' => 'daily',
'path' => storage_path('logs/request.log'),
'level' => 'debug',
'days' => 30,
],
'error' => [
'driver' => 'daily',
'path' => storage_path('logs/error.log'),
'level' => 'error',
'days' => 90,
],
'query' => [
'driver' => 'daily',
'path' => storage_path('logs/query.log'),
'level' => 'debug',
'days' => 7,
],
'mail' => [
'driver' => 'daily',
'path' => storage_path('logs/mail.log'),
'level' => 'info',
'days' => 30,
],
'heartbeat' => [
'driver' => 'daily',
'path' => storage_path('logs/heartbeat.log'),
'level' => 'info',
'days' => 14,
],
],
Now instead of one 500MB log file, you have targeted files. When investigating a slow query, you open query.log. When checking queue health, heartbeat.log. When investigating a user-reported error, request.log + error.log.
The RequestLogger Middleware
This is the most powerful observability tool in the stack. Every HTTP request gets:
- A unique
X-Request-IDheader - A full log entry (method, URL, user, duration, response status)
- A database record (
request_logstable) for cross-referencing with model changes
// 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\Http\Response;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
class RequestLogger
{
public const X_REQUEST_ID = 'X-Request-ID';
public function handle(Request $request, Closure $next): SymfonyResponse
{
// Generate or propagate a request ID
$requestId = $request->header(self::X_REQUEST_ID) ?? (string) Str::uuid();
$request->headers->set(self::X_REQUEST_ID, $requestId);
$startTime = microtime(true);
$response = $next($request);
$duration = round((microtime(true) - $startTime) * 1000, 2); // ms
// Add the ID to the response so clients can reference it
$response->headers->set(self::X_REQUEST_ID, $requestId);
$this->logRequest($request, $response, $requestId, $duration);
return $response;
}
private function logRequest(
Request $request,
SymfonyResponse $response,
string $requestId,
float $durationMs
): void {
$context = [
'request_id' => $requestId,
'method' => $request->method(),
'url' => $request->fullUrl(),
'status' => $response->getStatusCode(),
'duration_ms' => $durationMs,
'ip' => $request->ip(),
'user_id' => auth()->id(),
'user_type' => $this->resolveGuard(),
];
Log::channel('request')->info('Request', $context);
// Persist to database for cross-referencing with ModelChangeLog
RequestLog::create([
'request_id' => $requestId,
'method' => $request->method(),
'url' => $request->fullUrl(),
'status_code' => $response->getStatusCode(),
'duration_ms' => $durationMs,
'ip_address' => $request->ip(),
'user_id' => auth()->id(),
'user_type' => $this->resolveGuard(),
]);
}
private function resolveGuard(): ?string
{
foreach (['admin', 'merchandiser', 'web'] as $guard) {
if (auth($guard)->check()) {
return $guard;
}
}
return null;
}
}
Register it globally in bootstrap/app.php (Laravel 11):
->withMiddleware(function (Middleware $middleware): void {
$middleware->append(RequestLogger::class);
})
What the X-Request-ID Unlocks
Every log entry in every channel can include the X-Request-ID:
// In ErrorReporter
Log::channel('error')->error('Exception', [
'request_id' => request()->header(RequestLogger::X_REQUEST_ID),
'exception' => $e::class,
'message' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
// In ModelChangeLogger
ModelChangeLog::create([
...
'request_id' => request()->header(RequestLogger::X_REQUEST_ID),
]);
// In any service or action:
logger()->info('Processing payment', [
'request_id' => request()->header(RequestLogger::X_REQUEST_ID),
'order_id' => $order->id,
'gateway' => $gateway::class,
]);
Now when a user reports “something went wrong at 14:32”, you:
- Find the
RequestLogrow for that URL at that time - Get the
request_id - Search all log files for that
request_id - Find all
ModelChangeLogrecords with thatrequest_id
You have the complete picture of what happened during that request.
The ErrorReporter
The ErrorReporter is in Services/Core/ and handles exception notification with rate limiting:
// app/Services/Core/ErrorReporter.php
final class ErrorReporter
{
/**
* @return array<string>
*/
private static function validRecipients(): array
{
$recipients = config('app.error_report_emails', []);
return array_filter($recipients, fn ($email) => filter_var($email, FILTER_VALIDATE_EMAIL));
}
public static function report(Throwable $e): void
{
$recipients = self::validRecipients();
if ($recipients === []) {
return;
}
// Rate limit: don't send the same exception more than once per 5 minutes
$key = 'exception:' . hash('sha256', $e::class . $e->getMessage() . $e->getFile());
if (! Cache::add($key, true, now()->addMinutes(5))) {
return; // already reported this exception recently
}
Log::channel('error')->error('Exception reported', [
'class' => $e::class,
'message' => $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine(),
'request_id' => request()->header(RequestLogger::X_REQUEST_ID),
'url' => request()->fullUrl(),
'user_id' => auth()->id(),
]);
Mail::to($recipients)->send(new ExceptionOccurred($e));
}
}
Wire it up in the exception handler:
// bootstrap/app.php (Laravel 11)
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->report(function (Throwable $e): void {
if (app()->isProduction()) {
ErrorReporter::report($e);
}
});
})
Cache::add() is the key: it only sets the key if it doesn’t exist yet. If the same exception fires 100 times in 30 seconds, only the first email goes out.
Slow Query Detection
Register a DB::listen() callback in AppServiceProvider::boot():
// In AppServiceProvider::boot():
if (! app()->isProduction()) {
return; // Only log slow queries in production
}
DB::listen(function (QueryExecuted $query): void {
$thresholdMs = config('database.slow_query_threshold_ms', 1000);
if ($query->time < $thresholdMs) {
return;
}
Log::channel('query')->warning('Slow query detected', [
'sql' => $query->sql,
'bindings' => $query->bindings,
'duration_ms' => $query->time,
'connection' => $query->connectionName,
'request_id' => request()->header(RequestLogger::X_REQUEST_ID),
'url' => request()->fullUrl(),
]);
});
Set the threshold in .env:
DB_SLOW_QUERY_THRESHOLD_MS=500
And in config/database.php:
'slow_query_threshold_ms' => env('DB_SLOW_QUERY_THRESHOLD_MS', 1000),
Every query over 500ms gets logged to query.log with the full SQL, bindings, and the URL that triggered it.
FAQs
What is structured logging in Laravel?
Logging with consistent context arrays (request ID, user ID, domain data) instead of prose strings – so logs are queryable, not just readable.
Which Laravel log channel should I use?
Daily driver as baseline; dedicated channels (payments, requests) for high-volume domains; stack them so nothing is lost.
Key Takeaway
- Named log channels (request, error, query, mail, heartbeat) replace one monolithic log file with targeted, searchable files.
- The
RequestLoggermiddleware gives every request a unique ID, logs timing and status, and persists a DB record. X-Request-IDflows through every log entry – it’s the thread that connects a user report to a specific request, model changes, and exceptions.ErrorReporterusesCache::add()to rate-limit duplicate exception emails: one email per exception type per 5 minutes.- Slow query detection via
DB::listen()catches N+1 and expensive queries automatically.
Tips and Gotchas
⚠️ Warning: Never log passwords, tokens, card numbers, or PII. Once something is in a log file, it persists – log rotation doesn’t retroactively remove sensitive data from backups, log aggregators, or SIEM systems. Sanitize before you log.
💡 Tip: Use
Log::withContext(['order_id' => $order->id])to temporarily enrich all log entries within a specific scope (a job’shandle()method, a service method). UnlikeLog::shareContext(),withContext()doesn’t persist across the full request.
🔥 Expert Note: JSON-formatted log output (
'formatter' => Monolog\Formatter\JsonFormatter::class) makes logs machine-parseable. Log aggregators (Datadog, Papertrail, Logtail) ingest JSON natively – every key becomes a searchable field. Plain text logs require fragile regex parsing.
Further Reading
- Laravel Docs: Logging
- Monolog – the underlying logging library; Laravel wraps it
- Laravel Docs: Log Channels
- Logtail / Datadog – production log aggregation platforms
4 Comments