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.