Pop PHP
The Toolkit

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.

BASH
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.

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

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

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

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

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

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

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

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

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

TEXT
<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.

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

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

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

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

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

PHP
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