Logging and request IDs
Gustav provides a PSR-3 logger and a request ID without application bootstrap
code. Constructor-inject Psr\Log\LoggerInterface wherever application code
needs to write a log:
use GustavPHP\Gustav\Http\RequestId;
use Psr\Log\LoggerInterface;
final class CreateDog
{
public function __construct(
private readonly LoggerInterface $logger,
private readonly RequestId $requestId,
) {
}
public function handle(int $dogId): void
{
$this->logger->info('Dog created', [
'request_id' => (string) $this->requestId,
'dog_id' => $dogId,
]);
}
}
The default logger writes one JSON object per line to STDERR. It implements
PSR-3, accepts the standard log levels, safely normalizes arbitrary context,
and includes structured exception details when an exception is supplied under
the exception key.
Do not write application output or logs to STDOUT while running a RoadRunner
worker. That stream carries the worker protocol. Gustav's default logger uses
STDERR, which RoadRunner collects.
Automatic server-error reporting
Every exception that becomes a 5xx response is reported exactly once in both
development and production. The record has this context:
{
"timestamp": "2026-08-23T10:15:30.123456Z",
"level": "error",
"message": "Request failed",
"context": {
"request_id": "01J5REQUEST123",
"http.method": "POST",
"http.path": "/dogs",
"http.status_code": 500,
"exception": {
"class": "RuntimeException",
"message": "Database unavailable",
"code": 0,
"file": "/app/src/Dogs/CreateDog.php",
"line": 42,
"trace": "..."
}
}
}
Expected 4xx responses are not logged automatically. Gustav also does not
automatically capture query parameters, request bodies, headers, cookies,
identity data, or client IP addresses. Add application context deliberately
and avoid credentials, tokens, and personal data.
Unexpected exception details remain hidden from production HTTP responses; they are available to the logger instead. If an application logger throws while Gustav is reporting a server failure, Gustav writes the record through its built-in fallback logger and keeps the worker alive.
Unexpected application-command exceptions use the same reporter. Their record
message is Command failed and the context contains command plus the
structured exception. Invalid command input is expected and is not logged.
Production console output never includes the unexpected exception details.
Request IDs
Gustav creates one RequestId before application-wide middleware runs and
adds it to every response as X-Request-ID. An incoming ID is preserved only
when it is a single safe value matching this contract:
[A-Za-z0-9][A-Za-z0-9._-]{0,127}
Missing, repeated, or unsafe values are replaced with a 32-character lowercase hexadecimal ID. This prevents control characters and unbounded values from reaching response headers or logs.
Inject the typed request-scoped value into controllers, middleware, or services:
use GustavPHP\Gustav\Http\RequestId;
final readonly class RequestContext
{
public function __construct(public RequestId $requestId)
{
}
}
Middleware can also read it from the PSR-7 request using
$request->getAttribute(RequestId::ATTRIBUTE).
Replacing the logger
Bind any PSR-3 implementation with a discovered service. No entrypoint call is required:
use GustavPHP\Gustav\Attribute\Service;
use GustavPHP\Gustav\Service\Lifetime;
use Psr\Log\{AbstractLogger, LoggerInterface};
use Stringable;
#[Service(as: LoggerInterface::class, lifetime: Lifetime::Singleton)]
final class ApplicationLogger extends AbstractLogger
{
public function log(
mixed $level,
string|Stringable $message,
array $context = [],
): void {
// Forward to your logging backend.
}
}
Use a service provider when a third-party
logger needs scalar configuration or a pre-built object. Keep a logger
singleton unless it intentionally depends on scoped state; add the
RequestId to ordinary application records explicitly when you need
correlation.
RoadRunner production logging
RoadRunner maps PHP worker STDERR to its server logging channel at info
severity. Keep that channel enabled and use raw mode for the already-structured
application lines:
logs:
mode: production
encoding: json
level: info
channels:
server:
mode: raw
level: info
This keeps Gustav's newline-delimited JSON intact while RoadRunner can format
its own operational records as JSON. The starter project's .rr.prod.yaml
contains this setup.