Logging
Structured output that lands in the right place on every deployment target.
<?php
use PhpOrbit\Log\Level;
use PhpOrbit\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.
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
<?php
use PhpOrbit\Log\StreamLogger;
$logger = StreamLogger::standardError(Level::Info);| Target | Where lines land |
|---|---|
./orbit 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');
$logger = new StreamLogger($handle, Level::Info);Append mode matters: several workers may hold the same file open.
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 comparisonsLOG_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 PhpOrbit\Log\LogRequests;
$app->middleware(new LogRequests($logger));{"time":"…","level":"info","message":"request","context":{"method":"GET","path":"/articles","status":200,"ms":12.4}}
{"time":"…","level":"warning","message":"request","context":{"method":"GET","path":"/nope","status":404,"ms":0.8}}
{"time":"…","level":"error","message":"request","context":{"method":"POST","path":"/articles","status":500,"ms":31.7}}The level follows the status: 5xx logs as an error, 4xx as a warning, everything else as info. Register it first, so it observes the true status of everything below it — including responses produced by other middleware.
What not to log
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 PhpOrbit\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.
A logger is a boot singleton, used by every request the worker serves. Do not buffer entries on the instance intending to flush them at the end of a request — that buffer would span requests. Write as you go, or flush from $scope->onClose().