Logging
Structured output that lands in the right place on every deployment target.
<?php
use PhpOnWheels\Log\Level;
use PhpOnWheels\Log\Logger;
final class PublishArticle implements Handler
{
public function __construct(private readonly Logger $logger)
{
}
public function handle(ServerRequest $request): Response
{
$this->logger->log(Level::Info, 'article published', [
'id' => $id,
'author' => $authorId,
]);
return Response::redirect('/articles');
}
}One method, four levels: Debug, Info, Warning, Error.
Output
One JSON object per line — greppable by a human, parseable by anything else:
{"time":"2026-08-10T14:22:01+00:00","level":"info","message":"article published","context":{"id":42,"author":7}}
{"time":"2026-08-10T14:22:03+00:00","level":"error","message":"payment declined","context":{"order":118}}context is omitted entirely when empty, so ordinary lines stay short.
Context supports nested arrays of strings, integers, floats, booleans and null; JSON preserves those types. Objects and resources are refused rather than implicitly serialized. Nesting is limited to 32 levels, including protection against recursive arrays. Invalid UTF-8 bytes are replaced so a malformed request path cannot break JSON logging; other encoding failures are explicit exceptions.
<?php
$logger->log(Level::Info, 'order accepted', [
'order' => ['id' => 42, 'total' => 19.95, 'paid' => true],
'items' => ['book', 'pen'],
]);The message is JSON-encoded, so a value containing \n"level":"error" stays one line and one field. Line-oriented logs that interpolate raw strings are trivially forgeable by anyone who can influence a logged value.
Where it goes
The demo, blank scaffold and admin application use the same environment-driven factory. JSON lines remain the only format; no collector or network call runs inside a request.
LOG_LEVEL=info
LOG_OUTPUT=stderr
LOG_PATH=storage/logs/app.log
LOG_SERVICE=my-app
APP_ENV=productionLOG_OUTPUT accepts stderr (the default), stdout or file. LOG_PATH is used only for file output, resolves relative to the project root and defaults to storage/logs/app.log. Parent directories are created when needed. Files open in append mode; regular-file writes are locked and flushed so cooperating workers cannot interleave entries. Missing permissions and failed writes surface as errors, not successful log calls. Keep the path outside public/; configure retention, permissions and rotation externally, and restart workers after renaming an open log file.
Prefer stderr under web SAPIs. stdout is intended for CLI/container hosts configured to collect it; do not route logs into an HTTP response or CLI machine-readable output. Hosts may prefix captured stderr lines; file output preserves raw JSON lines for a collector such as Alloy.
<?php
use PhpOnWheels\Log\LoggerFactory;
$logger = LoggerFactory::fromEnvironment($env, $root);The factory binds service (default phponwheels) and environment (default production) in every entry's context. A log call cannot overwrite them. Directly constructing StreamLogger preserves its previous output shape without these fields.
<?php
use PhpOnWheels\Log\StreamLogger;
$logger = StreamLogger::standardError(Level::Info);| Target | Where lines land |
|---|---|
./pow serve | Your terminal |
| nginx + PHP-FPM | The pool's error log |
| Apache | The server error log |
| FrankenPHP | The server's stderr, and so your container logs |
STDERR is defined only under the CLI SAPI. Referring to it in app/bootstrap.php or anywhere in src/ is a fatal error at boot under FPM, Apache and php -S — and the test suite will not catch it, because the suite itself runs under the CLI.
StreamLogger::standardError() opens the php://stderr wrapper, which exists everywhere. tests/Unit/PortabilityTest.php fails the build if the constant reappears.
To log to a file instead:
<?php
$handle = fopen($root . '/storage/logs/app.log', 'ab');
if ($handle === false) {
throw new \RuntimeException('Cannot open application log.');
}
$logger = new StreamLogger($handle, Level::Info);Append mode matters: several workers may hold the same file open.
For reusable immutable context, wrap any logger in new ContextLogger($logger, ['component' => 'billing']), or pass a context: array to StreamLogger. Bound fields take precedence over individual calls. Caller-supplied streams remain caller-owned; the factory closes its own handles when the logger is destroyed.
Levels
<?php
Level::Debug; // developing only — noisy by design
Level::Info; // something happened that you would want in an audit
Level::Warning; // recovered, but someone should look
Level::Error; // the request failed
Level::fromName('warning'); // parses configuration
Level::Warning->severity(); // for comparisons
LOG_LEVEL=infoEntries below the configured minimum are dropped before formatting, so debug logging costs almost nothing when switched off.
A typo is rejected rather than guessed at:
ValueError: Unknown log level "warn". Use one of: debug, info, warning, error.Silently falling back to debug would put request detail into production logs; falling back to error would hide warnings someone deliberately asked for.
Request logging
<?php
use PhpOnWheels\Log\LogRequests;
$app->middleware(new LogRequests($logger));
{"time":"…","level":"info","message":"request handled","context":{"method":"GET","path":"/articles","status":200,"duration_ms":12.4,"request_id":"…"}}
{"time":"…","level":"info","message":"request handled","context":{"method":"GET","path":"/nope","status":404,"duration_ms":0.8,"request_id":"…"}}
{"time":"…","level":"error","message":"request handled","context":{"method":"POST","path":"/articles","status":500,"duration_ms":31.7,"request_id":"…"}}5xx responses log as errors; all other returned responses log as info. A thrown exception produces request failed at error level with its class, not its message or stack trace, and is rethrown for the existing error handler. Register logging first, before any middleware resolves Logger from the request scope, so it observes everything below it.
Request correlation
LogRequests generates a fresh 128-bit random request_id for every invocation, including unmatched routes. Incoming X-Request-ID is deliberately not trusted. It publishes an immutable ContextLogger as Logger in the current request scope, so controllers and scoped services automatically share that ID with the request-completion log. The normally returned response carries the same X-Request-ID header, overriding a handler-supplied value. Exceptions rendered outside the middleware still have the ID in their log, but that outer error response does not carry the header.
Request IDs cannot be overridden through a log call and never mutate the shared logger. Singleton services and code using the original boot logger have only the fixed application context: use the request-scoped logger when correlation is needed. A singleton must never capture a scoped logger. Queries, request bodies, cookies and incoming headers are not automatically logged.
What not to log
StreamLogger automatically replaces sensitive context values with [REDACTED], recursively. Field matching ignores case and punctuation: password, password hash/confirmation, secret, token, API key, session ID, private key and recovery-code suffixes are protected, along with authorization headers, cookies, credentials, session, APP_KEY and APP_PREVIOUS_KEYS. For example, db_password, accessToken, Authorization and _token are redacted; a sensitive array is replaced entirely.
This is defense in depth, not a way to make arbitrary data safe. Message text, unrecognized field names and personal information are not automatically scrubbed. Never put secrets in messages, URLs, exception text or fields such as value. Custom logger implementations must provide their own protection.
Passwords, tokens, session ids, full card numbers and personal data do not belong there. The framework holds this line itself: QueryFailed carries the SQL but never the bound parameters, and configuration errors name the key but never the value.
<?php
// No
$logger->log(Level::Info, 'login', ['email' => $email, 'password' => $password]);
// Yes
$logger->log(Level::Info, 'login', ['user' => $user->authIdentifier()]);Your own logger
<?php
use PhpOnWheels\Log\Logger;
final class FanOutLogger implements Logger
{
/** @param list<Logger> $loggers */
public function __construct(private readonly array $loggers)
{
}
public function log(Level $level, string $message, array $context = []): void
{
foreach ($this->loggers as $logger) {
$logger->log($level, $message, $context);
}
}
}Register it as the Logger singleton at boot and everything that injects the interface picks it up.
The boot logger is shared; LogRequests publishes a separate immutable wrapper per request. Never set request fields on a singleton or buffer entries there for a later request. Write as you go, or flush request-owned buffers from $scope->onClose().