Logging
pop-log is a PSR-3 logger with six writers behind it. You compose a Pop\Log\Logger around one or
more writers, call info() or error() on it, and each writer decides where that entry lands — a file,
a syslog daemon, a database table, an inbox, an HTTP endpoint, or stdout. Swapping a laptop's log file
for a container's stdout is a change at construction and nowhere else.
composer require popphp/pop-log
Writing a Log#
A logger takes a writer. The file writer takes a path, and that's the whole setup.
use Pop\Log\Logger;
use Pop\Log\Writer\File;
$log = new Logger(new File(__DIR__ . '/../logs/app.log'));
$log->info('Just a info message');
$log->alert('Look Out! Something serious happened!');
Each call appends a tab-delimited line: timestamp, level, uppercase level name, message, then serialized context.
2026-08-24 09:42:39 info INFO Just a info message
2026-08-24 09:42:39 alert ALERT Look Out! Something serious happened!
The eight methods are the eight PSR-3 severities — emergency(), alert(), critical(), error(),
warning(), notice(), info() and debug(). Every one of them returns void, because
Pop\Log\Logger implements Psr\Log\LoggerInterface directly and PSR-3 declares them that way. Calls
do not chain.
Pass a context array as the second argument and the values are serialized onto the end of the entry.
Three keys are reserved and are consumed rather than serialized: name overrides the display name,
timestamp overrides the entry time, and format picks how the rest is serialized (json, php, or
text key=value; pairs by default).
use Pop\Log\Logger;
use Pop\Log\Writer\File;
$log = new Logger(new File(__DIR__ . '/../logs/app.log'));
$log->warning('Disk nearly full', ['free' => '2%', 'mount' => '/var']);
Messages interpolate {placeholder} tokens out of that same array, per PSR-3, and a value consumed by a
placeholder is not repeated in the trailing context blob:
use Pop\Log\Logger;
use Pop\Log\Writer\File;
$log = new Logger(new File(__DIR__ . '/../logs/app.log'));
$log->info('User {user} logged in from {ip}', ['user' => 'nick', 'ip' => '1.2.3.4']);
Rather than thread a context key through every call site, register a processor. A processor is a
callable(array $context): array that runs before interpolation, so anything it adds is immediately
available as a placeholder:
use Pop\Log\Logger;
use Pop\Log\Writer\File;
$log = new Logger(new File(__DIR__ . '/../logs/app.log'));
$log->addProcessor(function (array $context): array {
$context['request_id'] = bin2hex(random_bytes(8));
return $context;
});
$log->info('Handling request {request_id}');
Processors run in registration order and each sees what the ones before it added. A processor that throws aborts the whole call before any writer runs, which is the opposite of how a failing writer behaves — keep side effects out of them.
Writers#
Six writers ship, all implementing Pop\Log\Writer\WriterInterface. Three need nothing but PHP; three
pull in another Pop component.
| Writer | Constructor | Also requires |
|---|---|---|
Writer\File |
new File($path, $formatter) |
— |
Writer\Stream |
Stream::stdout(), Stream::stderr(), new Stream($resourceOrUrl) |
— |
Writer\Syslog |
new Syslog($facility, $host, $port, $tag) |
— |
Writer\Database |
new Database($db, $table) |
pop-db |
Writer\Mail |
new Mail($mailer, $emails, $options) |
pop-mail |
Writer\Http |
new Http($client) |
pop-http |
File picks its output format from the filename's extension: .csv, .tsv, .jsonl and .ndjson each
dispatch to a Pop\Log\Formatter\FormatterInterface implementation, .xml and .json take a legacy
whole-file-rewrite path, and everything else gets the tab-delimited line shown above. Pass a formatter as
the second argument to override that:
use Pop\Log\Formatter;
use Pop\Log\Logger;
use Pop\Log\Writer\File;
$log = new Logger(new File(__DIR__ . '/../logs/app.log', new Formatter\NdJson()));
$log->warning('Disk nearly full', ['free' => '2%']);
which writes one JSON object per line:
{"timestamp":"2026-08-24 09:42:39","level":"warning","name":"WARNING","message":"Disk nearly full","context":{"free":"2%"}}
Stream is the containerized deployment's writer — it writes to any writable PHP stream, and defaults to
Formatter\NdJson because that's what most aggregators expect off stdout. A resource you hand it stays
yours and is never closed by the writer; a stream URL string it opens itself is closed when the writer is
destroyed.
use Pop\Log\Logger;
use Pop\Log\Writer\Stream;
$log = new Logger(Stream::stdout());
$log->info('Just a info message');
Syslog sends a real RFC-3164 packet over UDP to a daemon or collector, which makes it the one writer
that leaves the machine without an HTTP dependency. Pop\Log\Facility is a backed enum of all 24
facilities and defaults to Facility::USER.
use Pop\Log\Facility;
use Pop\Log\Logger;
use Pop\Log\Writer\Syslog;
$log = new Logger(new Syslog(Facility::LOCAL0, '127.0.0.1', 514, 'my-app'));
$log->info('Just a info message');
The packet on the wire is <PRI>HEADER TAG[pid]: MSG, capped at 1024 bytes and truncated past it:
<134>Aug 24 09:43:01 web-1 my-app[356738]: Just a info message
Database takes a pop-db adapter and an optional table name, defaulting to pop_log. It creates the
table from its constructor if it is not there, with columns id, timestamp, level, name, message
and context.
use Pop\Db\Db;
use Pop\Log\Logger;
use Pop\Log\Writer\Database;
$db = Db::connect('sqlite', ['database' => __DIR__ . '/../logs/app.sqlite']);
$log = new Logger(new Database($db, 'system_logs'));
$log->alert('Look Out! Something serious happened!', ['host' => 'web-1']);
Mail takes a Pop\Mail\Mailer, one or more addresses, and an options array carrying a subject prefix
and extra headers. Each entry becomes its own message to each address, so a chatty level on this writer
is a mail flood — pair it with a limit.
use Pop\Log\Logger;
use Pop\Log\Writer\Mail;
use Pop\Mail\Mailer;
use Pop\Mail\Transport\Sendmail;
$mailer = new Mailer(new Sendmail(), 'noreply@domain.com');
$writer = new Mail($mailer, ['sysadmin@mydomain.com'], [
'subject' => 'Custom Log Entry:',
'headers' => ['cc' => 'another@mydomain.com'],
]);
$writer->setLogLimit(Logger::ERROR);
$log = new Logger($writer);
The subject becomes Custom Log Entry: ERROR (error) — the prefix, the display name, then the level in
parentheses.
Http posts each entry through a Pop\Http\Client you configure, so authentication, method and
transport options are all set on the client rather than on the writer.
use Pop\Http\Auth;
use Pop\Http\Client;
use Pop\Log\Logger;
use Pop\Log\Writer\Http;
$client = new Client('https://logs.mydomain.com/', ['method' => 'POST'], Auth::createKey('LOG_API_KEY'));
$log = new Logger(new Http($client));
$log->info('Just a info message');
The fields sent are timestamp, level, name, message and context; context is omitted from the
body entirely when it is empty, so a receiving endpoint has to treat it as optional rather than expect an
empty value.
A limit set on a writer drops anything less severe than the level given, and a Logger can hold several
writers with different limits:
use Pop\Log\Logger;
use Pop\Log\Writer\File;
$prodLog = new File(__DIR__ . '/../logs/app_prod.log');
$devLog = new File(__DIR__ . '/../logs/app_dev.log');
$prodLog->setLogLimit(Logger::ERROR);
$devLog->setLogLimit(Logger::INFO);
$log = new Logger([$prodLog, $devLog]);
$log->alert('Look Out! Something serious happened!'); // both files
$log->info('Just a info message'); // app_dev.log only
Calling setLogLimit() on the Logger instead applies one limit across every writer it holds.
Syslog opens its UDP socket in the constructor and throws Pop\Log\Writer\Exception there; the file
and database writers report at write time.
Levels and PSR-3#
Pop\Log\Logger implements Psr\Log\LoggerInterface, so any library that asks for a PSR-3 logger takes
one directly — nothing wraps it, nothing adapts it.
use Pop\Log\Logger;
use Pop\Log\Writer\File;
use Psr\Log\LoggerInterface;
function warnOnce(LoggerInterface $log): void
{
$log->warning('Disk nearly full', ['free' => '2%']);
}
warnOnce(new Logger(new File(__DIR__ . '/../logs/app.log')));
Levels come in two forms and both are accepted anywhere a level is passed. Logger::EMERGENCY through
Logger::DEBUG are the PSR-3 strings, identical to Psr\Log\LogLevel::* — Logger::ERROR is the string
'error', not an int. The legacy RFC-3164 severity integers 0 through 7 still work and are normalized
internally, so setLogLimit(3) and setLogLimit(Logger::ERROR) mean the same thing.
| Level | Severity | Constant |
|---|---|---|
emergency |
0 | Logger::EMERGENCY |
alert |
1 | Logger::ALERT |
critical |
2 | Logger::CRITICAL |
error |
3 | Logger::ERROR |
warning |
4 | Logger::WARNING |
notice |
5 | Logger::NOTICE |
info |
6 | Logger::INFO |
debug |
7 | Logger::DEBUG |
Lower severity numbers are more urgent, which is why a limit of Logger::ERROR keeps error, critical,
alert and emergency and drops the four below it.
To go the other way and get a level's display name from either form, getLevel() on an instance and the
static Logger::getLogLevel() both return the uppercase name:
use Pop\Log\Logger;
use Pop\Log\Writer\File;
$log = new Logger(new File(__DIR__ . '/../logs/app.log'));
$log->getLevel(Logger::ERROR); // 'ERROR'
Logger::getLogLevel(3); // 'ERROR'
A level that is neither a recognized PSR-3 string nor an int in 0-7 throws
Psr\Log\InvalidArgumentException — $log->log('verbose', 'x') fails with
"Error: The level string ('verbose') is an invalid level."
Custom formatters, the Pop\Log\Context helper that writers and formatters share, and the timestamp
format go past what this page covers — see the pop-log README.
See Also#
- Debugging — collecting timing, memory and query data, then handing it to a logger
- Auditing — the record of what changed, as opposed to the record of what happened
- Error Handling — where an uncaught exception goes before it reaches a log
- pop-log README — formatters, processors and the full writer API