juliendufresne/inter-app-request-identifier-bundle
A Symfony bundle that assigns and propagates unique identifiers for inter-application requests, making it easier to trace requests and correlate logs across services. Integrates with the Symfony request lifecycle and provides configuration for consistent request IDs.
This is a Symfony bundle, not a Laravel package. There is still no service provider, no config/*.php, and nothing in composer.json targeting illuminate/*. If you composer require it into a Laravel app you will get an autoloaded interface, a Guzzle middleware, a Monolog processor, and a DI extension class that never runs. Treat it as a reference implementation to read, not a dependency to install.
What it gives you is small but now slightly larger than before: an interface describing "this process has an identifier", plus a request listener that reads/writes that identifier on a header, and — as of v0.2.0 — the two integration pieces most teams hand-roll: a Guzzle middleware that propagates the header outbound, and a Monolog processor that stamps it onto every log record.
What changed in v0.2.0 (Activate Guzzle and Monolog by default if present): the bundle now auto-registers its Guzzle middleware and Monolog processor when those libraries are detected in the app, instead of requiring opt-in config. Two consequences worth noting before you read the source:
extra or context) that you will otherwise guess wrong when you port them.Minimal steps to evaluate (15 minutes, up from 10):
src/ on GitHub directly — still a handful of files. Read the interface first, then the listener, then the new Guzzle middleware and Monolog processor, then the DI extension last (it is the only file that explains the "if present" wiring).composer.json — the original targeted PHP 7.x and Symfony 3/4; check whether v0.2.0 moved them at all. A 0.2.0 bump with a one-line changelog is not a maintenance signal.app/.First use case: an API gateway calls a billing service, which calls a ledger service. A single customer-facing request touches three logs across three codebases. You want one string in every log line, every outbound header, and every queued job.
// app/Contracts/HasRequestIdentifier.php — your port of the bundle's interface
interface HasRequestIdentifier
{
public function identifier(): string;
}
// app/Http/Middleware/AssignRequestIdentifier.php
final class AssignRequestIdentifier
{
public const HEADER = 'X-Request-Id';
public function handle(Request $request, Closure $next): Response
{
$inbound = $request->header(self::HEADER);
$id = $this->isValid($inbound) ? $inbound : (string) Str::uuid();
app()->instance('request.id', $id);
Log::withContext(['request_id' => $id]);
$response = $next($request);
$response->headers->set(self::HEADER, $id);
return $response;
}
private function isValid(?string $v): bool
{
return is_string($v) && preg_match('/^[A-Za-z0-9._-]{1,64}$/', $v) === 1;
}
}
Register it in bootstrap/app.php ($middleware->prepend(...) on the web/api groups).
Edge generation, interior trust. Generate the ID only at the system boundary (the public-facing app or gateway). Every internal service honors an inbound header if present and generates one only if absent. This keeps the ID stable across the whole call graph instead of forking a new one at each hop.
Outbound propagation — now a first-class concern. This is the pattern v0.2.0 promotes from "you should write this" to "the bundle ships this". In Laravel:
Http::globalRequestMiddleware(fn ($request) => $request->withHeader(
AssignRequestIdentifier::HEADER,
app()->bound('request.id') ? app('request.id') : (string) Str::uuid(),
));
For Guzzle, this is exactly what the bundle's middleware now does by default — HandlerStack::push(Middleware::mapRequest(...)) behind the scenes. Read their version before writing yours and decide deliberately: generate-on-missing (their likely default) or omit-the-header-when-unset. Generate-on-missing is friendlier to log correlation; omit-when-unset is friendlier when the same client talks to third parties (see the gotcha below).
Carry it through async work. Queue jobs, scheduled commands, and event listeners do not inherit the request context. Push the ID into the job payload and re-establish it in the worker:
final class SyncInvoice implements ShouldQueue
{
public function __construct(
public readonly int $invoiceId,
public readonly string $requestId,
) {}
public function handle(): void
{
Log::withContext(['request_id' => $this->requestId]);
// ...
}
}
Dispatch with SyncInvoice::dispatch($id, app('request.id')). Neither the Guzzle middleware nor the Monolog processor helps here — both are per-process/per-record and neither serializes context into a payload. This remains the highest-value thing you get out of the pattern and still the most commonly skipped.
Log processor over per-call context. Also newly first-class: the bundle's Monolog processor is the reference for this. A Monolog processor that pulls from the container applies everywhere, including framework-generated log lines, which Log::withContext() in controllers never covers. Port theirs (read how it handles the missing-context case — that is the part that bites in console commands) or use Laravel's equivalent:
// app/Logging/RequestIdProcessor.php
public function __invoke(LogRecord $record): LogRecord
{
$record->context['request_id'] ??= app()->bound('request.id')
? app('request.id')
: null;
return $record;
}
Interface over global. The bundle's real contribution is the HasRequestIdentifier contract rather than a magic global. Binding an implementation in the container ($this->app->bind(HasRequestIdentifier::class, ...)) lets you swap a UUID generator for a deterministic one in tests, and lets domain services depend on the contract instead of reading request()->header() directly. Do this if you have more than two or three call sites; skip it for a single middleware.
The release is a convenience, not a revival. v0.2.0 wires up Guzzle and Monolog, which is genuinely the right next feature for this package — but it is still a 0.x release of an unmaintained bundle. There is no Laravel support, no PHP 8 guarantee, no Symfony 5/6/7 work, and no security response. Do not put it in composer.json; copy the interface and the two new integration classes and move on. Nothing here exceeds a hundred lines.
"By default if present" is a cache-sensitive conditional. Symfony's DI extension almost certainly guards registration with class_exists() / ContainerBuilder::willBeAvailable(). That decision is baked into the compiled container, so: installing or removing Guzzle or Monolog requires a cache:clear, and behavior differs between dev and prod, and between machines with different composer install --no-dev states. If you are debugging "why is my header missing in CI but not locally", start here.
New default = silent behavior change on upgrade. The whole point of "activate by default" is that upgrading changes what your app sends, without you editing config. If a Guzzle middleware starts attaching X-Request-Id to every outbound request, it now also attaches it to Stripe, Mailgun, and whatever else you call. Review your outbound clients before bumping; consider scoping the middleware to internal hosts only.
DI-configured clients only, for the Guzzle piece. A middleware registered through the bundle is attached to the clients the container builds. new Client() anywhere in your code, or a Laravel HTTP client with its own handler stack, bypasses it entirely. Half your outbound calls carrying the header is worse than none — it produces traces that look complete until they hit the gap. Centralize via Http::globalRequestMiddleware() and one Guzzle factory.
The Monolog processor changes your log schema. Every record now gains a field. Log parsers, dashboards, alerting rules, and any json log drain with a strict schema will see it. And in console/queue contexts with no request, check what the processor emits — null, an empty string, or an exception on a missing service. Decide and document; do not inherit it by accident.
Never trust the inbound header blindly. An attacker who controls the value can forge correlation across tenants, inject newlines into log files (log forging), or blow up your log index with 10 KB IDs. Validate with a strict regex and a length cap, and reject/regenerate on failure — as in the middleware above. Some teams also strip the header at the public edge entirely and only honor it on internal service-to-service routes. A shipped Guzzle middleware does not do this validation for you.
Header name collisions. The ecosystem is converging on W3C traceparent and X-Correlation-ID. If you already run OpenTelemetry or a service mesh, adding a competing X-Request-Id gives you two identifiers that disagree and no way to join them. Either reuse the trace ID or emit both with a documented relationship.
The ID is not an identity. It is a correlation token. Do not use it for idempotency keys, tenant scoping, authorization, rate limiting, or anything security-relevant — it is client-controllable by design.
Context leaks in long-running workers. Octane and queue workers reuse the process across requests and jobs. If you set the ID in the container and never reset it, job N+1 logs under job N's ID — and with a Monolog processor registered globally, that stale ID now lands on every line instead of just the ones you remembered to annotate. Reset in a finally block or use Context::forget() in a terminating hook.
Freeze it in tests. Bind a fixed ID in setUp() so assertions on log output or outbound headers are deterministic. Random UUIDs in tests are a flakiness source — and with Guzzle propagation on by default, Http::fake() assertions on headers will now fail nondeterministically unless you do.
Extension points worth keeping. The interface (HasRequestIdentifier with identifier(): string) and the two integration classes are the reusable artifacts. Vendor-copy them into app/Contracts/ and app/Logging/, bind one implementation, and you have the bundle's value with none of its maintenance risk. Note the source and the version you copied from so future readers — and future you — can diff against upstream.
How can I help you explore Laravel packages today?