manomano-tech/correlation-ids
Correlation IDs for Laravel: generate a unique ID per request and propagate it through logs, HTTP clients and queued jobs so you can trace a single action across services. Lightweight middleware and helpers make setup quick with minimal configuration.
1. Verify it installs at all. Last release Sep 2018, archived, 3 stars. Expect the composer constraints to be the first real work item:
composer require manomano-tech/correlation-ids
# likely fails on PHP 8.x / psr/http-message ^2.0 — see Gotchas
2. Read src/ before reading docs. The package is tiny (this is a feature, not a criticism). You need three facts out of it, and nothing else:
X-Correlation-ID or X-Request-ID — confirm the constant, don't guess, because it must match your other services),Everything else in your integration lives in your codebase.
3. Accept that Laravel is not PSR-15. The package targets PSR-7/PSR-15. Laravel's handle($request, Closure $next) pipeline is not PSR-15, so "leveraging" this package in Laravel means writing an adapter — and the adapter is bigger than the package. Decide up front:
| Option | When |
|---|---|
Adapter + nyholm/psr7 + bridge |
You already run PSR-15 middleware elsewhere and want one pipeline |
| Reimplement the ~25-line behaviour natively | Almost always, for a one-man-band Laravel app |
4. First use case — the only one that matters: every log line and every outbound call carries the same correlation ID for a request, and the ID is echoed back on the response header.
// bootstrap/app.php (Laravel 11) or app/Http/Kernel.php — FIRST in the global stack
$middleware->prepend(\App\Http\Middleware\CorrelationId::class);
Put it first so that even 500s from later middleware get tagged.
Use the package as the specification for behaviour and write it against Laravel's primitives:
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class CorrelationId
{
public const HEADER = 'X-Correlation-ID';
public function handle(Request $request, Closure $next)
{
$incoming = $request->header(self::HEADER);
$id = is_string($incoming) && preg_match('/^[A-Za-z0-9._-]{1,128}$/', $incoming)
? $incoming
: (string) Str::uuid();
// Request-scoped, not static — Octane-safe
app()->instance('correlation.id', $id);
Log::withContext(['correlation_id' => $id]);
$response = $next($request);
$response->headers->set(self::HEADER, $id);
return $response;
}
}
If you do want the package's classes, wrap them once and never touch them again:
final class Psr15Bridge
{
public function __construct(private readonly \Psr\Http\Server\MiddlewareInterface $inner) {}
public function handle(Request $request, Closure $next)
{
$psrRequest = (new \Nyholm\Psr7\ServerRequestFactory())->createServerRequest(
$request->method(), $request->fullUrl(), $request->server->all()
);
foreach ($request->headers->all() as $name => $values) {
$psrRequest = $psrRequest->withHeader($name, $values);
}
$handler = new class($next, $request, $psrRequest) implements \Psr\Http\Server\RequestHandlerInterface {
public function __construct(private $next, private Request $laravel, private $psr) {}
public function handle(\Psr\Http\Message\ServerRequestInterface $r): \Psr\Http\Message\ResponseInterface
{
// extract the package's correlation ID attribute/header here,
// bind it into the container + log context,
// then run the Laravel pipeline and map the response back.
$response = ($this->next)($this->laravel);
return new \Nyholm\Psr7\Response($response->getStatusCode(), $response->headers->all());
}
};
return $next($request); // simplified — see Gotchas before copying
}
}
This is why Pattern A wins. Only go here if a PSR-15 stack already exists.
The value of correlation IDs collapses if outbound calls drop the header.
use Illuminate\Support\Facades\Http;
// Laravel 9+ — one-time global wiring in a service provider
Http::globalRequestMiddleware(function ($request) {
return $request->withHeader(
'X-Correlation-ID',
app()->bound('correlation.id') ? app('correlation.id') : 'untraced'
);
});
Guzzle equivalent: Middleware::mapRequest(fn ($r) => $r->withHeader(...)) pushed on the handler stack.
// Dispatch side: stamp onto the payload, not a static
Bus::dispatch((new ProcessOrder($order))->withCorrelationId(app('correlation.id')));
// Laravel 8+: job middleware is cleaner than a base class
public function middleware(): array
{
return [new \App\Jobs\Middleware\RestoreCorrelationId($this->correlationId)];
}
The middleware does the same three things as Pattern A: bind into the container, Log::withContext, clear in finally.
If the package exposes a generator/ID interface, bind your own implementation and let the middleware consume it:
$this->app->bind(\ManoManoTech\CorrelationIds\CorrelationIdGeneratorInterface::class, function () {
return new class implements \ManoManoTech\CorrelationIds\CorrelationIdGeneratorInterface {
public function generate(): string { return (string) Str::ulid(); }
};
});
ULIDs sort by time and are cheaper than UUIDv4 for high-volume log correlation — a reasonable swap.
The 2018 dependency wall is the real blocker. Expect psr/http-message: ^1.0 pinned, which conflicts with nyholm/psr7 >= 1.5 (PSR-7 v2). Symptoms: unresolvable composer graph, or a --ignore-platform-reqs install that then type-errors at runtime. Options, in order of preference: (1) reimplement natively, (2) fork and bump the constraint, (3) replace the package and vendor the middleware class into app/.
Never trust the inbound header raw. A client can send X-Correlation-ID: ../../etc or a 4 KB blob, and your log aggregator will either choke or get poisoned. Validate with a strict regex and generate on failure — silently, no 400. Also consider that accepting unvalidated IDs lets anyone forge log lines.
Header casing and multiplicity. HTTP headers are case-insensitive; $request->header() in Laravel handles that, but raw PSR-7 getHeader() is case-sensitive on the array keys. If a client or proxy sends the header twice you get an array — is_string() is the guard.
Don't regenerate when one is present. Half-implemented correlation IDs (always Str::uuid()) are worse than none: you get a false sense of traceability. The read-else-generate branch is the entire point.
Octane/Swoole/RoadRunner: no static state. Any singleton or static holder for the correlation ID will leak between requests. Bind per-request (app()->instance) and reset in finally. This is also true in plain PHP-FPM for queued jobs that process many items.
Exception handler ordering. Log::withContext set in middleware does not apply to logs emitted before that middleware runs (bootstrap errors, some container failures) or in report() when the context has already been torn down. Test by throwing a 500 and checking the log entry actually has the field.
Echo it on the response. Without the response header, a user reporting a bug can't hand you an ID. One line, disproportionate payoff. Include it in error responses too — that's where you need it most.
Log aggregator side. Whatever field name you pick (correlation_id, cid), keep it identical across PHP, Nginx, and JS. If the package's header constant differs from your frontend proxy's, fix the constant in config now, not after the first incident.
Testing it. Two assertions cover the contract:
$response = $this->withHeader('X-Correlation-ID', 'abc-123')->get('/');
$response->assertHeader('X-Correlation-ID', 'abc-123');
// and the generated case
$this->get('/')->assertHeader('X-Correlation-ID'); // assert not null / matches UUID pattern
Also add one test asserting a malicious header ('<script>', 500 chars) is replaced rather than propagated.
Queue and HTTP propagation are where the package stops. It gives you the ID on the inbound request only. The day-to-day value — a trace across web → queue → downstream service — is entirely your glue code. Budget for that, not for the install.
Finally: it's archived with 0 dependents. Treat it as a reference implementation. The behaviour is ~25 lines of Laravel; owning those lines means no PHP-version archaeology when you upgrade to 8.4/9.x. If you keep the package, pin it and add a composer why note so the next developer knows it's frozen intentionally.
How can I help you explore Laravel packages today?