Logging

Structured output that lands in the right place on every deployment target.

PHP
<?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:

Output
{"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.

A newline in user input cannot forge an entry

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
<?php
use PhpOrbit\Log\StreamLogger;

$logger = StreamLogger::standardError(Level::Info);
TargetWhere lines land
./orbit serveYour terminal
nginx + PHP-FPMThe pool's error log
ApacheThe server error log
FrankenPHPThe server's stderr, and so your container logs
Never the STDERR constant

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
<?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
<?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
.env
LOG_LEVEL=info

Entries 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:

Output
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
<?php
use PhpOrbit\Log\LogRequests;

$app->middleware(new LogRequests($logger));
Output
{"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

Context goes into your log aggregator

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
<?php
// No
$logger->log(Level::Info, 'login', ['email' => $email, 'password' => $password]);

// Yes
$logger->log(Level::Info, 'login', ['user' => $user->authIdentifier()]);

Your own logger

PHP
<?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.

Loggers are shared

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().