+1 (415) 599-8902

Structured Logging in Laravel with Context: Correlation IDs Across Requests and Queued Jobs

When a customer reports "it failed around 2pm", the difference between a five-minute fix and a lost afternoon is whether you can follow one request across your web process, queued jobs and outgoing HTTP calls. Laravel's Context facade (introduced in Laravel 11 and available in 13) is built for exactly this. This tutorial sets up structured logging with a correlation ID that follows a request into queued jobs.

Prerequisites: a Laravel 11+ app on PHP 8.3+ and a queue driver other than sync.

What Context does

Context stores key/value data for the current request or command. Two things make it useful:

  • Anything added is automatically appended to every log entry written afterwards, as extra data on the record.
  • When you dispatch a queued job, the context is serialized into the job payload and restored (hydrated) when the worker runs it.

So you add data once and it shows up in logs everywhere downstream, with no need to pass it through method arguments.

Step 1: Add a correlation ID middleware

php artisan make:middleware AddCorrelationId
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Context;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;

class AddCorrelationId
{
    public function handle(Request $request, Closure $next): Response
    {
        $id = $request->header('X-Correlation-ID') ?: (string) Str::uuid();

        Context::add('correlation_id', $id);
        Context::add('url', $request->path());

        $response = $next($request);
        $response->headers->set('X-Correlation-ID', $id);

        return $response;
    }
}

Only trust an incoming header if the request comes from a source you control. If your app is public, consider always generating a fresh ID and logging the incoming one under a different key, so callers cannot inject arbitrary values into your logs.

Register it globally in bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) {
    $middleware->append(\App\Http\Middleware\AddCorrelationId::class);
})

Step 2: Add user context after authentication

Authentication happens inside the middleware stack, so add the user once it is known, for example in a listener or a middleware placed after auth:

Context::add('user_id', $request->user()?->id);

Log IDs rather than emails or names. Context ends up in your log storage, and you rarely want personal data there.

Step 3: Log as usual

use Illuminate\Support\Facades\Log;

Log::info('Order submitted', ['order_id' => $order->id]);

The log record now includes the order_id you passed, plus correlation_id, url and user_id under the record's extra data. With the default text formatter, context is printed as part of the line. For searchable logs, switch to JSON output.

Step 4: Emit JSON logs

In config/logging.php, add a channel using Monolog's JSON formatter that writes to stderr (a good fit for containers):

'json_stderr' => [
    'driver' => 'monolog',
    'handler' => Monolog\Handler\StreamHandler::class,
    'formatter' => Monolog\Formatter\JsonFormatter::class,
    'with' => ['stream' => 'php://stderr'],
],

Set LOG_CHANNEL=json_stderr in production. Any log platform can now filter on extra.correlation_id.

Step 5: Follow the ID into queued jobs

Nothing extra is required. Dispatch a job from a request:

ProcessInvoice::dispatch($invoice);

and inside the job:

public function handle(): void
{
    Log::info('Processing invoice', ['invoice_id' => $this->invoice->id]);
}

The worker's log line carries the same correlation_id as the request that dispatched the job. Searching one ID now returns the HTTP request, the job and any jobs it dispatches in turn.

Step 6: Hidden context for sensitive values

Sometimes you need data available in code but never written to logs. Use hidden context:

Context::addHidden('tenant_api_token', $token);

$token = Context::getHidden('tenant_api_token');

Hidden values are not included in log output, though they are still carried to queued jobs. Treat the queue payload as storage and do not put real secrets there. Prefer an ID you can look up.

Step 7: Pass the ID to outgoing HTTP calls

Propagate the ID to services you call so their logs correlate too:

use Illuminate\Support\Facades\Http;

Http::withHeaders([
    'X-Correlation-ID' => Context::get('correlation_id'),
])->post($url, $payload);

To apply this everywhere, register a global request middleware on the HTTP client in a service provider's boot method with Http::globalRequestMiddleware(...).

Step 8: Test it

use Illuminate\Support\Facades\Context;

it('adds a correlation id to the response', function () {
    $response = $this->get('/');

    expect($response->headers->get('X-Correlation-ID'))->not->toBeEmpty();
});

it('reuses the id sent by the client', function () {
    $this->withHeader('X-Correlation-ID', 'abc-123')->get('/')
        ->assertHeader('X-Correlation-ID', 'abc-123');
});

Context persists across tests in the same process, so flush it in your TestCase setup with Context::flush() if you assert on its contents.

Pitfalls

  • Keep context small. It is serialized into every job payload. Store IDs, not models or large arrays.
  • Octane and long-lived workers. Make sure context is cleared between requests. Check the behaviour of your Laravel version and flush explicitly if you see values leaking.
  • Queue workers started before a deploy run old code. Restart them with php artisan queue:restart after changes to context handling.
  • Log volume and cost. Structured logs are larger. Set sensible levels, such as warning in production for noisy channels.

Where to go next

Once logs are correlated, pair them with metrics and tracing. If you are inheriting a Laravel app where failures are hard to diagnose, or you want a second pair of eyes on your production setup, get in touch with the Polish & Pixel team.