<?php
namespace App\Correlation;
final class CorrelationIdStorage
{
private ?string $correlationId = null;
public function get(): ?string
{
return $this->correlationId;
}
public function set(string $correlationId): void
{
$this->correlationId = $correlationId;
}
public function reset(): void
{
$this->correlationId = null;
}
}
<?php
namespace App\Correlation;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Uid\Uuid;
final class RequestCorrelationListener
{
private const HEADER = 'X-Correlation-Id';
public function __construct(private readonly CorrelationIdStorage $storage)
{
}
#[AsEventListener(event: KernelEvents::REQUEST, priority: 4096)]
public function onKernelRequest(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$incoming = $event->getRequest()->headers->get(self::HEADER);
$correlationId = $this->isValid($incoming) ? $incoming : Uuid::v4()->toRfc4122();
$this->storage->set($correlationId);
$event->getRequest()->attributes->set('_correlation_id', $correlationId);
}
#[AsEventListener(event: KernelEvents::RESPONSE, priority: -4096)]
public function onKernelResponse(ResponseEvent $event): void
{
$id = $this->storage->get();
if (null !== $id) {
$event->getResponse()->headers->set(self::HEADER, $id);
}
}
private function isValid(?string $value): bool
{
return null !== $value && '' !== $value && strlen($value) <= 128;
}
}
<?php
namespace App\Correlation;
use Monolog\LogRecord;
use Monolog\Processor\ProcessorInterface;
final class CorrelationIdProcessor implements ProcessorInterface
{
public function __construct(private readonly CorrelationIdStorage $storage)
{
}
public function __invoke(LogRecord $record): LogRecord
{
$id = $this->storage->get();
if (null === $id) {
return $record;
}
$record->extra['correlation_id'] = $id;
return $record;
}
}
services:
_defaults:
autowire: true
autoconfigure: true
App\Correlation\CorrelationIdStorage: ~
App\Correlation\RequestCorrelationListener:
arguments:
$storage: '@App\Correlation\CorrelationIdStorage'
App\Correlation\CorrelationIdProcessor:
arguments:
$storage: '@App\Correlation\CorrelationIdStorage'
tags:
- { name: monolog.processor }
This snippet wires a correlation id through the entire lifecycle of a Symfony request so every log line, and every downstream response, can be tied back to a single client interaction. A correlation id (sometimes called a request id or trace id) is the backbone of debuggable distributed systems: when a user reports an error, grepping logs for one opaque token surfaces every line that request produced, across every service that forwarded the header.
The design keeps the id in one place. CorrelationIdStorage is a tiny mutable holder registered as a service; both the listener and the processor depend on it rather than passing the id around explicitly. This is the idiomatic Symfony approach to sharing per-request state without leaking it into method signatures, and because Symfony's default container reuses services within a request it behaves like a scoped value.
In RequestCorrelationListener, the onKernelRequest handler runs only on the main request (checked via $event->isMainRequest()) to avoid overwriting the id during sub-requests such as ESI fragments or forwards. It reads an incoming X-Correlation-Id header if a trusted upstream already set one, otherwise it mints a fresh UUID. This accept-or-generate rule is what lets the id survive across service boundaries. The onKernelResponse handler echoes the id back on the response header so clients and gateways can capture it. Both handlers are bound with #[AsEventListener] and explicit priorities so the id is established very early and written back late.
CorrelationIdProcessor implements Monolog's ProcessorInterface; its __invoke merges the current id into extra on every LogRecord, meaning no logging call site needs to remember to include it. Because the processor pulls from the same storage the listener populated, the value is always current.
The services.yaml tab shows the wiring: the storage is a plain service, and the processor is tagged with monolog.processor so Monolog picks it up automatically. A subtle pitfall worth noting is long-running workers (Messenger, RoadRunner): the storage must be reset between messages or ids will bleed across jobs, which is why keeping it a mutable single object rather than a constructor-injected constant matters.
Related snips
package api
import (
"net/http"
"runtime/debug"
)
Expose build metadata for debugging deploys
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
package com.example.myapp
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
Dependency injection with Hilt
use tracing::{info, instrument};
#[instrument]
fn process_request(user_id: u64) {
info!(user_id, "Processing request");
// Work happens here
tracing for structured logging and distributed tracing
<?php
namespace App\Providers;
use App\Contracts\PaymentGateway;
use App\Services\StripePaymentGateway;
Laravel service container and dependency injection
package deps
import (
"net"
"net/http"
"time"
HTTP client tuned for production: timeouts, transport, and connection reuse
Share this code
Here's the card — post it anywhere.