A customer reports that their order "went through but never arrived". Your logs contain the checkout request, a queued job that synced the order to a CRM, and a failed HTTP call to that CRM. They are three lines in three places, with nothing tying them together. Searching by timestamp works on a quiet afternoon and fails on a busy one. A correlation ID fixes this: one identifier, created at the edge of the request, attached to every log line, carried into every queued job, and forwarded to every service you call.
This guide shows a complete implementation for Laravel 8 and later that I ran and tested against Laravel 8.83 while writing it, including one bug the first version had that only shows up in long-running queue workers. If you are on Laravel 11 or later, there is a shorter built-in route, covered near the end.
What the ID has to survive
A correlation ID is only useful if it is present at every hop. In a typical Laravel application that means four boundaries:
- Inbound HTTP. Reuse the ID a load balancer, API gateway, or mobile client already sent, or create one.
- Logs. Every log line written during the request must carry it without each developer remembering to add it.
- Queues. A job runs later, in another process, possibly on another server. The ID has to travel inside the job payload and be restored when the worker picks it up.
- Outbound HTTP. Calls to your own services or partners should forward the ID so their logs can be joined with yours.
Returning the ID in the response is a fifth, small but valuable boundary: support can ask a customer for it, and frontend error reports can include it.
The implementation
One class holds the logic, and one middleware applies it to requests. Laravel 8 already has everything needed: Log::withContext() adds data to every subsequent log entry, Queue::createPayloadUsing() adds fields to every job payload, and the JobProcessing event fires in the worker before a job runs.
<?php
namespace App\Support;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Queue\Events\JobProcessing;
use Illuminate\Queue\Queue;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/** One ID per request, carried into logs, queued jobs, and outgoing HTTP calls. */
final class CorrelationId
{
public const HEADER = 'X-Request-Id';
public static function current(): ?string
{
return app()->bound('correlation_id') ? app('correlation_id') : null;
}
public static function set(string $id): void
{
app()->instance('correlation_id', $id);
Log::withContext(['correlation_id' => $id]);
}
/** Accept an upstream ID only if it looks safe; otherwise mint a new one. */
public static function fromRequest(Request $request): string
{
$incoming = (string) $request->header(self::HEADER, '');
return preg_match('/\A[A-Za-z0-9._-]{8,64}\z/', $incoming) ? $incoming : (string) Str::uuid();
}
/** Call once from a service provider's boot(). */
public static function propagateToQueues(): void
{
Queue::createPayloadUsing(function () {
return ($id = self::current()) ? ['correlation_id' => $id] : [];
});
// Workers are long-lived: clear the previous job's ID before restoring this one.
Event::listen(JobProcessing::class, function (JobProcessing $event) {
Log::withoutContext();
app()->forgetInstance('correlation_id');
if ($id = $event->job->payload()['correlation_id'] ?? null) {
self::set($id);
}
});
}
}
final class AssignCorrelationId
{
public function handle(Request $request, Closure $next)
{
CorrelationId::set($id = CorrelationId::fromRequest($request));
$response = $next($request);
$response->headers->set(CorrelationId::HEADER, $id);
return $response;
}
}
Register both pieces:
// app/Http/Kernel.php, in $middleware so it runs for every request
\App\Support\AssignCorrelationId::class,
// app/Providers/AppServiceProvider.php
public function boot()
{
\App\Support\CorrelationId::propagateToQueues();
}
A few decisions in this code are deliberate.
Validate the incoming header
The ID comes from outside your system and ends up in log files, log search tools, and response headers. Accepting any string would let a client inject newlines into logs or send a megabyte of junk. The pattern [A-Za-z0-9._-]{8,64} accepts UUIDs and the formats common load balancers generate, and rejects everything else by minting a fresh UUID.
Store it in the container, not a static property
Binding the value with app()->instance() keeps it scoped to the application instance. That matters in tests, which build a fresh application per test, and it keeps the code compatible with Octane-style long-lived servers, where static properties survive between requests.
Reset context at the start of every job
This is the bug my first version had. A queue worker is a single PHP process that runs job after job. Log::withContext() adds to the logger's context and keeps it. My first listener only set the ID when the payload contained one, so a job dispatched from a scheduled command (which has no request and therefore no ID) silently inherited the ID of whatever job ran before it. Its log lines then pointed at an unrelated customer request: worse than having no ID at all. Calling Log::withoutContext() and forgetting the container binding before restoring the new ID fixes it. The test below proves it.
Forwarding the ID to other services
Laravel 8's HTTP client has no global middleware, so pass the header explicitly where you build the request. Wrapping your API clients in small classes, which is a good idea anyway, means this lives in one place per integration:
Http::withHeaders([CorrelationId::HEADER => CorrelationId::current()])
->timeout(5)
->post('https://crm.example.com/orders', $payload);
If the other side is your own Laravel service running the same middleware, it will accept the ID and continue the chain. For third-party APIs, it at least appears in your own outbound request logs, and some providers echo it back or let support search by it. More on making those integrations resilient is in Resilient Third-Party API Integrations in Laravel.
Proving it works
I ran these as feature tests inside a Laravel 8.83 application on PHP 8.2, with the sync queue driver and Http::fake(). The first test sends a request with an upstream ID, logs from both the controller and a queued job, makes an outgoing HTTP call from the job, and checks all three carry the same ID:
public function test_upstream_id_flows_into_logs_job_payload_and_outgoing_http(): void
{
$payloadIds = [];
Event::listen(JobProcessing::class, function (JobProcessing $e) use (&$payloadIds) { $payloadIds[] = $e->job->payload()['correlation_id'] ?? null; });
$this->get('/_corr', ['X-Request-Id' => 'req-7f3a9c2e'])->assertOk()->assertHeader('X-Request-Id', 'req-7f3a9c2e');
$this->assertSame([['placing order', 'req-7f3a9c2e'], ['order synced', 'req-7f3a9c2e']], $this->logged);
$this->assertSame(['req-7f3a9c2e'], $payloadIds);
Http::assertSent(fn ($r) => $r->hasHeader('X-Request-Id', 'req-7f3a9c2e'));
}
The full suite had four tests, all passing:
| Test | What it proves |
|---|---|
| Upstream ID flows everywhere | Response header, both log lines, the job payload, and the outgoing request all carry req-7f3a9c2e
|
| Unsafe or missing IDs are replaced | A header containing a newline and <script> is rejected and replaced by a UUID; two requests without a header get different IDs |
| Worker restores the ID | A JobProcessing event with a payload ID makes it current in the worker process |
| No inheritance between jobs | A job without an ID, processed after one with an ID, logs with no correlation ID instead of the previous job's |
The sync driver runs the job in the same process, so the first test alone cannot show that restoration works in a real worker. That is why the third and fourth tests fire JobProcessing directly with a payload, which is exactly what a Redis or database worker does before calling your job.
Making the ID useful in practice
-
Log in a structured format. With JSON logs, the ID becomes a searchable field in whatever log tool you use, rather than text buried in a message. Laravel's Monolog channels accept a JSON formatter through the
formatteroption inconfig/logging.php. - Include it in error reports. If you send exceptions to an error tracker, attach the ID as a tag so an alert links straight to the matching log lines.
-
Show it to users on error pages. "Something went wrong, reference
req-7f3a9c2e" turns a vague support ticket into a direct search. - Keep it out of business data. The ID describes a request, not an order. Do not store it as the only link between records; use real foreign keys and log the ID alongside them.
Correlation IDs are one signal among several. How they fit with metrics, traces, and business events is covered in Laravel Observability: Logs, Metrics, and Traces That Help.
On Laravel 11 and later: the Context facade
Laravel 11 documents a Context facade that does most of this for you. Data added with Context::add('trace_id', ...) is appended to every log entry and is automatically captured when a job is dispatched, then hydrated back while the job runs. If you are on Laravel 11 or newer, the middleware shrinks to validating the header, calling Context::add(), and setting the response header. You still need to forward the ID to outgoing HTTP calls yourself, and the header validation is still your job.
On Laravel 8, 9, or 10, the class above gives you the same behaviour with about sixty lines of code and no packages.
Checklist
- Accept a validated upstream ID or generate a UUID in the first middleware.
- Add it to log context so every line carries it automatically.
- Put it in every job payload and restore it in the worker, clearing the previous value first.
- Forward it on outgoing HTTP calls to your own services and partners.
- Return it in a response header and show it on error pages.
- Test the worker path explicitly; the sync driver hides stale-context bugs.
When a production incident spans requests, queues, and third-party APIs, the time to find the cause is mostly the time to connect the evidence. If your Laravel application is hard to debug in production, my Laravel performance and technical audit includes reviewing logging, tracing, and error reporting, and fixing the gaps that slow down every investigation.
Originally published on parhamtehrani.com.
This article was written by an AI agent (Claude) on the author's behalf. The code was run and its tests passed on the environment described above.
Top comments (0)