rasuvaeff / yii3-correlation-id
Request correlation ID for Yii3: PSR-15 middleware, request-scoped holder, and yiisoft/log context provider
Requires
- php: 8.3 - 8.5
- psr/http-message: ^2.0
- psr/http-server-handler: ^1.0
- psr/http-server-middleware: ^1.0
- yiisoft/log: ^2.1
Requires (Dev)
- ergebnis/composer-normalize: ^2.51
- friendsofphp/php-cs-fixer: ^3.95
- infection/infection: ^0.33
- maglnet/composer-require-checker: ^4.17
- nyholm/psr7: ^1.8
- rasuvaeff/property-testing-testo: ^0.6
- rasuvaeff/rector-named-literals: ^1.0
- rector/rector: ^2.4
- roave/backward-compatibility-check: ^8.0
- testo/bridge-infection: ^0.1.6
- testo/testo: ^0.10.25
- vimeo/psalm: ^6.16
- yiisoft/di: ^1.4
Suggests
- rasuvaeff/yii3-api-problem: Fills the ProblemDetails instance field with the correlation ID
This package is auto-updated.
Last update: 2026-08-25 17:49:21 UTC
README
Request correlation ID for Yii3: PSR-15 middleware, request-scoped holder, and yiisoft/log context provider. Every request gets an ID, every log line carries it, and the client gets it back in the response header.
Using an AI coding assistant? llms.txt contains a compact API reference you can share with the model.
Requirements
- PHP 8.3+
psr/http-message^2.0,psr/http-server-middleware^1.0yiisoft/log^2.1 (2.1.0 introduced theContextProviderAPI)
Installation
composer require rasuvaeff/yii3-correlation-id
Usage
Put CorrelationIdMiddleware first in the stack — everything downstream that
logs should already see the ID.
use Rasuvaeff\Yii3CorrelationId\CorrelationIdHolder; use Rasuvaeff\Yii3CorrelationId\CorrelationIdMiddleware; use Rasuvaeff\Yii3CorrelationId\Uuidv4Generator; $middleware = new CorrelationIdMiddleware( generator: new Uuidv4Generator(), holder: new CorrelationIdHolder(), );
For each request the middleware:
- Adopts the ID an outer instance of itself already published in the
correlationIdrequest attribute, if there is one. - Otherwise reads
X-Request-IDand reuses the value if it is acceptable — only when configured withacceptIncoming: true, which is not the default. - Generates a UUIDv4 otherwise.
- Publishes the ID as the
correlationIdrequest attribute. - Publishes the ID in
CorrelationIdHolder, replacing whatever was there. - Clears the holder in a
finallyblock — unless it adopted the ID at step 1, in which case the outer instance owns the cleanup. - Sets
X-Request-IDon the response.
Under yiisoft/config the bundled config/di.php wires all of this from
params.php, so the middleware only needs adding to your middleware stack.
For a Yii3 application, put the container ID first in the web runner's middleware list (the exact filename depends on the application template):
// config/web.php or config/common/middleware.php use Rasuvaeff\Yii3CorrelationId\CorrelationIdMiddleware; use Yiisoft\ErrorHandler\Middleware\ErrorCatcher; use Yiisoft\Router\Middleware\Router; return [ CorrelationIdMiddleware::class, ErrorCatcher::class, Router::class, ];
Override package defaults in the application params layer, without copying the vendor DI definitions:
// config/common/params.php use Rasuvaeff\Yii3CorrelationId\CorrelationIdMiddleware; return [ 'rasuvaeff/yii3-correlation-id' => [ 'headerName' => 'X-Request-ID', 'attributeName' => 'correlationId', 'acceptIncoming' => false, // the default: the caller does not choose this service's ID 'validationPattern' => CorrelationIdMiddleware::UUID_V4_PATTERN, 'maxLength' => 128, 'contextKey' => 'requestId', ], ];
Reading the ID
Anything holding the request reads the attribute; application services inject
the read-only CorrelationIdProvider:
use Rasuvaeff\Yii3CorrelationId\CorrelationIdProvider; $id = $request->getAttribute('correlationId'); final readonly class OrderService { public function __construct( private CorrelationIdProvider $correlationId, ) {} } $id = $correlationId->get(); // throws outside a correlation scope $id = $correlationId->tryGet(); // null outside a correlation scope
CorrelationIdHolder must stay a single shared instance — the middleware
writes it and everything else reads it. The yiisoft/di container does this by
autowiring. The package aliases CorrelationIdProvider to that same instance;
application services should not depend on the holder's mutation methods.
The middleware owns the request scope: it overwrites whatever the holder
held and clears it in finally. A stray ID — left by worker bootstrap or by a
handler that called exit() — is dropped on the next request instead of failing
every request that worker will ever handle again. set() keeps its set-once
contract for application and queue code, where a second write really is a
mistake.
Registered twice
A second instance further down the stack — the middleware added twice, a module that ships its own copy — adopts the ID the outer one already published in the request attribute. It does not read the incoming header again, does not mint a rival ID, and does not clear the holder on the way out, since the scope belongs to the outer instance. Without that, one request would carry two IDs: the inner one in the logs and the handler, the outer one in the response header.
It also keeps acceptIncoming: false meaningful: the caller's header is still on
the request after the outer instance decided to ignore it, and an inner instance
configured with acceptIncoming: true would otherwise read it right back.
The attribute is adopted only after passing the same control-character,
maxLength and validationPattern checks as an incoming header, so unrelated
code writing that attribute (a route parameter of the same name) cannot decide
the correlation ID.
Queue and console scopes
Queue consumers and console commands can establish an explicit scope without
manual cleanup. runWith() restores the previous ID in finally, including
for nested scopes and exceptions:
$result = $holder->runWith( id: $message->correlationId, callback: fn () => $consumer->handle($message), );
set(), override() and runWith() each validate their argument and throw
InvalidArgumentException for an ID that is empty, longer than 4096 bytes, or
carrying a control character (\x00-\x1F, \x7F). That matters most here:
$message->correlationId above usually started life as an untrusted HTTP header
on the service that enqueued the job, and from the holder it reaches every log
line and every outgoing request header verbatim.
Validation happens before the holder's state is touched, so a rejected call
leaves the current scope exactly as it was and the callback never runs. The
4096-byte ceiling is a sanity limit against log bloat, not a format check — it
sits far above the middleware's maxLength default of 128, so a custom format
configured on the middleware is never rejected by the holder afterwards.
The holder's guarantee is deliberately minimal: it is not the HTTP validation
contract. If a scope ID must match a specific format, check it against
CorrelationIdMiddleware::UUID_V4_PATTERN (or your own pattern) before calling
runWith().
Outgoing HTTP requests
CorrelationIdHeaderInjector propagates the current ID into an outgoing PSR-7
request. It replaces a stale header rather than appending another value and is
a no-op outside a correlation scope:
$request = $injector->inject($request); $response = $httpClient->sendRequest($request);
The bundled DI config uses the same headerName as the server middleware. See
examples/06-outgoing-request.php.
Log context
CorrelationIdContextProvider puts requestId into the context of every
message logged through Yiisoft\Log\Logger. The logger takes exactly one
context provider, so compose yours with the logger's own SystemContextProvider
— dropping it would lose time, category and trace from every message:
// config/common/di/logger.php use Psr\Log\LoggerInterface; use Rasuvaeff\Yii3CorrelationId\CorrelationIdContextProvider; use Yiisoft\Log\ContextProvider\CompositeContextProvider; use Yiisoft\Log\ContextProvider\SystemContextProvider; use Yiisoft\Log\Logger; use Yiisoft\Log\StreamTarget; return [ LoggerInterface::class => static fn ( CorrelationIdContextProvider $correlationId, ): LoggerInterface => new Logger( [new StreamTarget()], new CompositeContextProvider(new SystemContextProvider(), $correlationId), ), ];
ContextProviderInterface belongs to yiisoft/log, so this package never binds
it — composing providers is the application's call.
Outside a request (console command, worker bootstrap) no ID is set, the context provider returns an empty array, and logging keeps working.
Configuration
params.php, under the rasuvaeff/yii3-correlation-id key:
| Param | Default | Meaning |
|---|---|---|
headerName |
X-Request-ID |
Read from the request, written to the response |
attributeName |
correlationId |
Request attribute carrying the ID; also how a nested instance recognises the outer one's scope |
acceptIncoming |
false |
Ignore the caller's ID and mint one here. Set it to true only on a service that direct client traffic cannot reach, to keep the ID propagating across hops |
validationPattern |
UUIDv4 regex | Invalid incoming IDs are replaced; invalid generated IDs are rejected |
maxLength |
128 |
Longer incoming IDs are replaced; longer generated IDs are rejected |
contextKey |
requestId |
Log context key |
A custom ID format needs a generator, matching pattern, and sufficient maximum
length. A generated value outside that contract throws UnexpectedValueException
before the request handler runs. See
examples/04-custom-generator.php.
Control characters (\x00-\x1F, \x7F) are rejected before
validationPattern runs, so a permissive custom pattern cannot let an ANSI
escape, a NUL byte, or a smuggled newline reach the holder, the logs, or an
outgoing header.
Trusting the caller's ID
acceptIncoming defaults to false. A middleware that has not been told where
it sits assumes a public trust boundary and mints its own ID, so a caller cannot
choose what this service's logs are keyed by, nor make two unrelated requests
share one ID.
Set it to true on a service that direct client traffic cannot reach, where the
forwarded ID keeps the two services' logs correlated:
// config/common/params.php — internal service behind a gateway return [ 'rasuvaeff/yii3-correlation-id' => [ 'acceptIncoming' => true, ], ];
Prior to 2.0.0 the default was true; see UPGRADE.md.
The UUID constant
CorrelationIdMiddleware::UUID_V4_PATTERN is the default validationPattern
and is safe to reuse on its own — validating a queue message's correlation id
before runWith(), checking an ID read back from a database:
if (preg_match(CorrelationIdMiddleware::UUID_V4_PATTERN, $id) !== 1) { $id = $generator->generate(); }
It is anchored with \z, which matches only at the very end of the subject.
Up to 1.0.1 it was anchored with $, which PCRE also matches before a single
trailing \n — so the old value returned 1 for "<uuid>\n" when used this
way. Middleware behaviour never differed: the control-character guard rejects a
trailing newline before any pattern runs.
Incoming trust policy
After format and length validation, an IncomingCorrelationIdPolicy may reject
an otherwise valid ID based on request context. Rejection generates a fresh ID.
Bind the policy in application DI:
use Psr\Http\Message\ServerRequestInterface; use Rasuvaeff\Yii3CorrelationId\IncomingCorrelationIdPolicy; final readonly class TrustedProxyPolicy implements IncomingCorrelationIdPolicy { #[\Override] public function accepts(ServerRequestInterface $request, string $id): bool { return in_array( $request->getServerParams()['REMOTE_ADDR'] ?? null, ['10.0.0.10', '10.0.0.11'], true, ); } } return [ IncomingCorrelationIdPolicy::class => TrustedProxyPolicy::class, ];
For manual construction, pass it as the named incomingPolicy argument.
acceptIncoming: false — the default — is the hard off switch: it skips the
policy and always mints a new ID, so a policy is only ever consulted on a
middleware configured with acceptIncoming: true. See
examples/07-trusted-proxy-policy.php.
Public API
| Class | Description |
|---|---|
CorrelationIdMiddleware |
PSR-15 middleware: resolve, publish, echo back |
CorrelationIdProvider |
Read-only get/tryGet access for application services |
CorrelationIdHolder |
Mutable infrastructure holder: set-once set(), unconditional override(), and runWith() scopes. Every write validates the ID |
CorrelationIdGenerator |
Interface for ID generation |
Uuidv4Generator |
Pure-PHP RFC 4122 v4 UUIDs from random_bytes() |
CorrelationIdContextProvider |
yiisoft/log context provider adding requestId |
CorrelationIdHeaderInjector |
Adds the current ID to outgoing PSR-7 requests |
IncomingCorrelationIdPolicy |
Request-aware trust decision for valid incoming IDs |
AcceptAllIncomingCorrelationIdPolicy |
Default policy used when none is supplied |
Exception\CorrelationIdNotSetException |
Thrown by CorrelationIdHolder::get() outside a request |
When to use this instead of yii3-telemetry
yii3-correlation-id |
yii3-telemetry |
|
|---|---|---|
| Scope | One service: correlate its own logs | Distributed tracing across services |
| Model | One ID per request | Spans, parent/child, sampling |
| Propagation | X-Request-ID header |
W3C Trace Context, OTLP export |
| Cost | Middleware + holder, no exporter | Collector, exporter, sampling config |
Both can run together: place this middleware first and read tryGet() into a
span attribute. Do not expect this package to grow traceparent support — that
is what telemetry is for.
Integration recipes
Keep optional package glue in the application. For yii3-audit-log, take the
ID from the provider rather than rereading an untrusted request header:
use Rasuvaeff\Yii3AuditLog\AuditMetadata; $metadata = new AuditMetadata( requestId: $correlationId->tryGet(), ip: $request->getServerParams()['REMOTE_ADDR'] ?? null, userAgent: $request->getHeaderLine('User-Agent'), );
For yii3-telemetry, add it to the currently active span from code running
downstream of both middleware:
$id = $correlationId->tryGet(); if ($id !== null) { $tracer->currentSpan()->setAttribute('request.id', $id); }
Security
| Risk | What the package does |
|---|---|
| Header injection | Control characters (\x00-\x1F, \x7F) are rejected unconditionally, before validationPattern, so a permissive custom pattern stays safe; the pattern then rejects anything that is not a well-formed ID, including content smuggled after a space |
| Oversized header | maxLength (default 128) rejects long values before the pattern runs |
| Client-spoofed ID | acceptIncoming defaults to false, so a caller's ID is ignored unless the service explicitly opts in. Opt in only on internal services that direct client traffic cannot reach |
| Log injection | Both incoming and generated IDs must pass the control-character guard, the validation pattern, and the length limit before reaching the holder or log context. The guard runs first, so the anchor of validationPattern cannot weaken the middleware. CorrelationIdHolder applies its own control-character and length guard to set(), override() and runWith(), so an ID entering from a queue or console path cannot carry an ANSI escape or a CR/LF into the logs or an outgoing header either |
| Info leak | A request ID carries no user data. UUIDv4 is unguessable but is not a secret — never use it for authorization |
Browser access. CORS does not expose custom response headers to JavaScript by default. When a browser client must include the ID in a support report, configure the application's CORS middleware to send:
Access-Control-Expose-Headers: X-Request-ID
Use the configured custom header name instead when headerName is changed.
Concurrency. The holder is one shared instance cleared in a finally block,
which is correct for sequential request handling: PHP-FPM, and workers that take
one request at a time (RoadRunner). Under coroutine concurrency (Swoole), where
several requests share a worker's memory at once, a shared holder would leak IDs
between them — this package does not support that model.
Examples
See examples/ for runnable scripts. Examples are expected to execute without fatal errors and stay aligned with the documented public API.
| Script | Shows | Needs server? |
|---|---|---|
| 01-middleware-setup.php | Middleware in a PSR-15 stack under acceptIncoming: true: generate / reuse / replace |
no |
| 02-log-context.php | yiisoft/log + context provider: requestId on every line |
no |
| 03-access-in-action.php | Reading the ID from the attribute and from the holder | no |
| 04-custom-generator.php | ULID-like generator with a matching validation pattern | no |
| 05-gateway-mode.php | Public gateway replaces an untrusted ID, internal service preserves the gateway ID | no |
| 06-outgoing-request.php | Queue scope and outgoing PSR-7 header propagation | no |
| 07-trusted-proxy-policy.php | Accept a valid incoming ID only from a trusted gateway IP (needs acceptIncoming: true) |
no |
Development
No PHP/Composer on the host — run in Docker via the composer:2 image:
docker run --rm -v "$PWD":/app -w /app composer:2 composer install docker run --rm -v "$PWD":/app -w /app composer:2 composer build docker run --rm -v "$PWD":/app -w /app composer:2 composer cs:fix docker run --rm -v "$PWD":/app -w /app composer:2 composer test docker run --rm -v "$PWD":/app -w /app composer:2 composer release-check
Or with Make:
make install
make build
make cs-fix
make test
make test-coverage
make mutation
make release-check
make test-coverage and make mutation bootstrap pcov inside the
composer:2 container because the base image has no coverage driver.