Structured logging library for PHP 8.5+.
composer require ordinary/logLogger is the ready-to-use implementation. Add one or more drivers and start logging immediately.
useOrdinary\Log\Logger;
useOrdinary\Log\Driver\StreamDriver;
useOrdinary\Log\JsonFormatter;
$logger = newLogger();
$logger->add(newStreamDriver(STDOUT, newJsonFormatter()));
$logger->info('User signed in', ['user_id' => 42]);
$logger->warning('Cache miss on key {key}', ['key' => 'user:42:prefs']);
$logger->error('Payment failed', ['order_id' => 'ORD-999', 'exception' => $e]);Message strings support {key} placeholder interpolation using context values.
Debug → Info → Notice → Warning → Error → Critical → Alert → Emergency
If any library or framework in your stack type-hints \Psr\Log\LoggerInterface, use Logger::toPsr() to get a drop-in adapter — one method call, no extra setup:
useOrdinary\Log\Logger;
useOrdinary\Log\Driver\StreamDriver;
useOrdinary\Log\JsonFormatter;
$logger = newLogger();
$logger->add(newStreamDriver(STDOUT, newJsonFormatter()));
// Framework integration — one call produces a PSR-3 adapter$container->bind(\Psr\Log\LoggerInterface::class, fn() => $logger->toPsr());PSR-3 specifies that Throwables in context must be passed under the key "exception". LogEntryInterface::RESERVED_EXCEPTION is also "exception", so no translation is required — the same key works in both the native API and the PSR-3 adapter:
// Native API$logger->error('Charge failed', ['exception' => $e, 'order_id' => 'ORD-1']);
// PSR-3 API — identical behavior$logger->toPsr()->error('Charge failed', ['exception' => $e, 'order_id' => 'ORD-1']);If you need to create a PsrLoggerAdapter directly (for example, to wrap a custom LoggerInterface implementation):
useOrdinary\Log\Psr\PsrLoggerAdapter;
$psrLogger = newPsrLoggerAdapter($myCustomLogger);Formatters control how log items are serialized to strings. Two are provided out of the box.
Produces a human-readable log line by interpolating {key} placeholders in the message string. The keys {date} and {level} are injected automatically, so they are always available regardless of what context you pass.
Default output (using StreamDriver's defaults — date as ISO-8601, level lowercase):
[2024-06-01T12:00:00Z] [error] Something failed order_id=ORD-999
Any context keys that are not used as {key} placeholders in the message are appended as key=value pairs.
useOrdinary\Log\TextFormatter;
useOrdinary\Log\DateTimeFormatter;
useOrdinary\Log\LevelFormatter;
useOrdinary\Log\ExceptionFormatter;
// Default — ISO-8601 date, lowercase level, no exception stack traces$formatter = newTextFormatter();
// Custom date format — daily granularity, uppercase level$formatter = newTextFormatter(
dateTimeFormatter: newDateTimeFormatter('Y-m-d', 'America/New_York'),
levelFormatter: newLevelFormatter(uppercase: true),
);
// With stack traces$formatter = newTextFormatter(
exceptionFormatter: newExceptionFormatter(includeTrace: true),
);Template-driven messages — any {key} from context is substituted inline:
// Message: "User john logged in from 192.0.2.1"$logger->info('User {username} logged in from {ip}', [
'username' => 'john',
'ip' => '192.0.2.1',
]);StreamDriver, SyslogDriver, and RotatingStreamDriver all default to new TextFormatter().
Produces structured JSON — one object per log item. Top-level fields: channel (if set), level, date, message, exception (if a Throwable was attached), context.
useOrdinary\Log\JsonFormatter;
// {"level":"error","date":"2024-06-01T12:00:00+00:00","message":"Charge failed","exception":"RuntimeException: ...","context":{"order_id":"ORD-999"}}$formatter = newJsonFormatter();
// Custom date format$formatter = newJsonFormatter(
dateTimeFormatter: newDateTimeFormatter('Y-m-d', 'UTC'),
);Context values are normalized before encoding: DateTimeInterface → ISO-8601 string, \Stringable → string, NaN → "NaN", INF → "Infinity", booleans and nulls preserved.
LogEntryInterface defines reserved context keys. Understanding which ones you may set versus which are injected helps avoid subtle bugs.
| Key | Constant | Who sets it | Notes |
|---|---|---|---|
exception | RESERVED_EXCEPTION | You | Pass any \Throwable; formatters render it |
date | RESERVED_DATE | Formatter | Overwriting silently has no effect |
level | RESERVED_LEVEL | Formatter | Same |
channel | RESERVED_CHANNEL | Logger (from $channel param) | Same |
exception.message | RESERVED_EXCEPTION_MESSAGE | Formatter | Same |
exception.line | RESERVED_EXCEPTION_LINE | Formatter | Same |
exception.code | RESERVED_EXCEPTION_CODE | Formatter | Same |
RESERVED_EXCEPTION is the only reserved key you should set yourself. All others are injected by the Logger or formatters and will be overwritten even if you supply them.
Drivers implement LogDriverInterface with a single handleLog() method. They are pure I/O — matching, dispatching, and failure handling are all managed by Logger.
Writes a formatted line to any writable stream resource.
useOrdinary\Log\Driver\StreamDriver;
useOrdinary\Log\JsonFormatter;
// Write JSON lines to a file$logger->add(newStreamDriver(
stream: fopen('/var/log/app.log', 'a'),
formatter: newJsonFormatter(),
));
// Write errors and above to STDERR$logger->add(
newStreamDriver(STDERR, newJsonFormatter()),
matcher: newIsLevelOrHigher(LogLevel::Error),
);Sends log events to AWS CloudWatch Logs. Requires aws/aws-sdk-php:
composer require aws/aws-sdk-phpuseAws\CloudWatchLogs\CloudWatchLogsClient;
useOrdinary\Log\Driver\CloudWatchDriver;
$logger->add(newCloudWatchDriver(
client: newCloudWatchLogsClient(['region' => 'us-east-1', 'version' => 'latest']),
logGroupName: '/my-app/production',
logStreamName: 'web-01',
formatter: newJsonFormatter(),
));Accumulates log items in memory and dispatches them in bulk when flushed. Use this to batch writes to high-latency backends like CloudWatch or a database. Call Logger::flush() at request end to drain the buffer. The buffer is also drained automatically when the driver is destroyed.
useOrdinary\Log\Driver\BufferingDriver;
useOrdinary\Log\Driver\CloudWatchDriver;
$logger->add(newBufferingDriver(
inner: newCloudWatchDriver($client, '/app/prod', 'web'),
flushAfter: 100, // auto-flush at 100 items; omit or set 0 for explicit-only
));
// ... handle request ...$logger->flush(); // sends all buffered itemsWhen the inner driver implements LogBatchDriverInterface, flush() calls handleLogBatch() with the entire buffer in a single operation instead of issuing individual handleLog() calls.
Suppresses repeated log items within a sliding time window and re-dispatches a summary when the window closes. Two items are considered duplicates when their fingerprints match; the default fingerprint is "{level}:{message}".
Behavior:
- The first occurrence of a fingerprint is forwarded immediately as a normal log item.
- Subsequent occurrences within the window replace the stored pending item (the latest context is always preserved) and their timestamps are accumulated.
- When the window expires (detected on the next
handleLogcall) or whenflush()is called, any pending item that received at least one duplicate is re-dispatched with two extra context keys added:dedup_count— the number of suppressed duplicates (int)dedup_times— ISO-8601 strings of each suppressed occurrence inJsonFormatteroutput
- Pending items that received no duplicates are silently discarded on flush.
flush()clears all pending state — fingerprints are treated as fresh after a flush cycle.- The driver flushes automatically on destruction, so pending summaries are never lost even without an explicit
flush()call.
useOrdinary\Log\Driver\DeduplicatingDriver;
$logger->add(newDeduplicatingDriver(
inner: newStreamDriver(STDERR),
windowSeconds: 300, // suppress repeats for 5 minutes
));
// Custom fingerprint — deduplicate by event code rather than full message$logger->add(newDeduplicatingDriver(
inner: newSlackDriver($webhookUrl),
windowSeconds: 3600,
fingerprint: fn(LogEntryInterface$item) => $item->level->name . ':' . ($item->context['event'] ?? $item->message),
));The $clock parameter (Psr\Clock\ClockInterface, defaults to UtcClock) is used for all window tracking. Inject a test double to control time in unit tests.
A typical output sequence for three occurrences of the same log within the window — two dispatches total:
[error] Payment failed ← dispatched immediately (first occurrence)
... 2 more suppressed ...
[error] Payment failed dedup_count=2 dedup_times=[...] ← flushed at window close
Writes to a date-rotated file. The file path is built by substituting {date} in the path pattern with the formatted date of each log item. A new file is opened automatically whenever the date changes:
useOrdinary\Log\Driver\RotatingStreamDriver;
useOrdinary\Log\JsonFormatter;
$logger->add(newRotatingStreamDriver(
pathPattern: '/var/log/app-{date}.log',
formatter: newJsonFormatter(),
));
// Produces: /var/log/app-2024-06-01.log, /var/log/app-2024-06-02.log, …Use $dateFormat to control rotation granularity — 'Y-m-d' (default) for daily, 'Y-m-d-H' for hourly.
Buffers all log items silently until one at or above the activation level arrives, then flushes the entire buffer to the inner driver. This gives full diagnostic context around errors without log noise during normal operation:
useOrdinary\Log\Driver\FingersCrossedDriver;
useOrdinary\Log\Driver\StreamDriver;
useOrdinary\Log\LogLevel;
$logger->add(newFingersCrossedDriver(
inner: newStreamDriver(fopen('/var/log/app.log', 'a')),
activationLevel: LogLevel::Error,
));After activation all subsequent items go directly to the inner driver. If the threshold is never reached, flush() discards the buffer silently.
Options:
$maxBuffer— cap buffer size (drops oldest item when full); 0 = unlimited.$resetOnFlush— return to buffering state after eachflush(), for request-scoped use.
Silently discards every log item. Useful as a placeholder or to explicitly disable a group during tests:
useOrdinary\Log\Driver\NullDriver;
$logger->add(newNullDriver());Collects all log items in memory. Use it in tests to assert on what was logged without touching real I/O:
useOrdinary\Log\Driver\TestDriver;
useOrdinary\Log\LogLevel;
$driver = newTestDriver();
$logger->add($driver);
$service->processPayment($order); // triggers $logger->error(...)$this->assertTrue($driver->hasRecordThatContains('Payment failed', LogLevel::Error));
$this->assertTrue($driver->hasRecord(LogLevel::Error, 'Payment failed'));
$this->assertTrue($driver->hasRecordAtLevel(LogLevel::Error));
$errors = $driver->getRecordsAtLevel(LogLevel::Error); // list<LogEntryInterface>$driver->reset(); // clear between test casesDecorators compose: wrap one inside another and call Logger::flush() once — the cascade propagates automatically.
$logger->add(
newDeduplicatingDriver(
inner: newBufferingDriver(
inner: newCloudWatchDriver($client, '/app/prod', 'web'),
flushAfter: 50,
),
windowSeconds: 60,
),
);
$logger->flush(); // DeduplicatingDriver → BufferingDriver → CloudWatchDriverDrivers are organised into named groups. Groups let you apply a shared matcher to multiple drivers without repeating it, and add or remove entire sets of drivers at runtime.
A default group is always present with no matcher. Drivers added without specifying a group land there.
// All drivers in this group only receive Warning and above$logger->addGroup('prod', matcher: newIsLevelOrHigher(LogLevel::Warning));
$logger->add(newCloudWatchDriver($client, '/app/prod', 'web', $formatter), group: 'prod');
$logger->add(newStreamDriver(fopen('/var/log/app.log', 'a'), $formatter), group: 'prod');
// Default group — receives everything$logger->add(newStreamDriver(STDOUT, $formatter));Per-driver matchers are combined with the group matcher as an AND:
// Group passes Warning+; this driver additionally requires Error+$logger->add(
newCloudWatchDriver($client, '/app/alerts', 'web', $formatter),
matcher: newIsLevelOrHigher(LogLevel::Error),
group: 'prod',
);Groups can be added and removed at runtime. All drivers registered to a removed group stop receiving log items immediately.
// On entering a request context$logger->addGroup('request', matcher: newHasContext('request_id'));
$logger->add(newCloudWatchDriver($client, '/app/requests', 'web', $formatter), group: 'request');
// On exit$logger->removeGroup('request');Constraints:
- Group IDs must be unique —
addGroup()throws if the ID already exists. removeGroup('default')throws — the default group cannot be removed.add()throws if the specified group does not exist.
Processors transform log items before they are dispatched to drivers. Register them with addProcessor() — they run in registration order after the channel is stamped.
Stamps a fixed set of key-value pairs on every log item:
useOrdinary\Log\Processor\TagProcessor;
$logger->addProcessor(newTagProcessor([
'env' => 'production',
'release' => 'v2.3.1',
'service' => 'payment-api',
]));Generates a unique identifier at construction time and adds it to every log item. Create a new instance per request to correlate all log entries for a single operation:
useOrdinary\Log\Processor\UidProcessor;
$logger->addProcessor(newUidProcessor()); // 'uid' — 7 hex chars$logger->addProcessor(newUidProcessor('request_id', length: 16)); // custom key + length// Bring your own generator — must return a non-empty string$logger->addProcessor(newUidProcessor(generator: fn () => Uuid::v4()->toString()));Throws \UnexpectedValueException at construction if the generator returns an empty string.
Adds the current memory usage (in bytes) to every log item:
useOrdinary\Log\Processor\MemoryUsageProcessor;
$logger->addProcessor(newMemoryUsageProcessor()); // adds 'memory.usage'$logger->addProcessor(newMemoryUsageProcessor(includePeak: true)); // also 'memory.peak_usage'$logger->addProcessor(newMemoryUsageProcessor(realUsage: true)); // system-allocated bytesAdds HTTP request details to every log item. Supply a PSR-7 ServerRequestInterface
or a raw server params array — nothing is read from globals:
useOrdinary\Log\Processor\WebProcessor;
// PSR-7 request: native methods used first, server params as fallback$logger->addProcessor(newWebProcessor($psrRequest));
// Adds: request.url, request.ip, request.method, request.server, request.referrer, request.user_agent// Raw server params array (useful in tests or CLI scripts)$logger->addProcessor(newWebProcessor(['REQUEST_URI' => '/test', 'REMOTE_ADDR' => '127.0.0.1']));
// Include additional server param keys$logger->addProcessor(newWebProcessor($psrRequest, extraFields: ['HTTP_X_REQUEST_ID']));
// Also adds: request.http_x_request_idWhen a PSR-7 request is provided, URL and method come from getUri() / getMethod(),
headers from getHeaderLine(), and IP / extra fields from getServerParams(). Passing
null (the default) adds no context.
Adds the file, line, class, and function of the actual log call site by inspecting the call stack:
useOrdinary\Log\Processor\IntrospectionProcessor;
$logger->addProcessor(newIntrospectionProcessor());
// Adds: log.file, log.line, log.class, log.functionIf your application wraps ordinary/log in its own logger class, exclude that namespace from the walk:
$logger->addProcessor(newIntrospectionProcessor(skipNamespaces: ['App\\Logging\\']));Wraps a closure as a processor for one-off transformations:
useOrdinary\Log\CallableProcessor;
$logger->addProcessor(newCallableProcessor(
fn(ImmutableLogEntryInterface$item) => $item->withContext(['request_id' => $requestId]),
));Implement LogProcessorInterface with a single process() method:
useOrdinary\Log\ImmutableLogEntryInterface;
useOrdinary\Log\LogEntry;
useOrdinary\Log\LogEntryInterface;
useOrdinary\Log\LogProcessorInterface;
finalclass TenantProcessor implements LogProcessorInterface
{
publicfunction__construct(privatereadonlystring$tenantId) {}
publicfunctionprocess(LogEntryInterface$logItem): LogEntryInterface
{
if ($logIteminstanceof ImmutableLogEntryInterface) {
return$logItem->withContext(['tenant_id' => $this->tenantId]);
}
returnnewLogEntry(
$logItem->level,
$logItem->message,
$logItem->dateTime,
\array_merge($logItem->context, ['tenant_id' => $this->tenantId]),
);
}
}Implement LoggerInterface and use LoggerTrait. The trait provides the eight named methods; you only need to implement log().
useOrdinary\Log\LogEntryInterface;
useOrdinary\Log\LoggerInterface;
useOrdinary\Log\LoggerTrait;
finalclass TenantLogger implements LoggerInterface
{
use LoggerTrait;
publicfunction__construct(
privatereadonlystring$tenantId,
privatereadonlyLogger$logger,
) {}
publicfunctionlog(LogEntryInterface$logItem): void
{
$this->logger->log(
$logItem->withContext(['tenant_id' => $this->tenantId]),
);
}
}All log items created via the named helper methods (info(), error(), etc.) are timestamped using Logger's $clock parameter. The default is UtcClock. Inject a Psr\Clock\ClockInterface to control timestamps in tests or to use a different time source:
useOrdinary\Log\Logger;
useOrdinary\Log\UtcClock;
// Production — default UTC clock$logger = newLogger();
// Tests — inject a MutableClock to control time$clock = newMutableClock(newDateTimeImmutable('2024-01-01T00:00:00Z'));
$logger = newLogger(clock: $clock);The same pattern applies to DeduplicatingDriver — inject a clock to drive the deduplication window in tests without relying on real wall-clock time.
Implement LogDriverInterface with a single handleLog() method. Do not add matcher or dispatcher logic — Logger handles that.
useOrdinary\Log\LogDriverInterface;
useOrdinary\Log\LogEntryInterface;
finalclass SlackDriver implements LogDriverInterface
{
publicfunction__construct(privatereadonlystring$webhookUrl) {}
publicfunctionhandleLog(LogEntryInterface$logItem): void
{
// post formatted message to Slack webhook...
}
}Call Logger::flush() to drain any buffered items held by drivers that implement FlushableInterface. Every registered driver that implements this interface is flushed, in group order, regardless of whether an earlier one throws. The first exception, if any, is re-thrown after all drivers have been attempted.
// Typically called once, at request or job end$logger->flush();Implement FlushableInterface alongside LogDriverInterface to participate in flush propagation. Wrapping drivers must cascade to the inner driver:
useOrdinary\Log\FlushableInterface;
useOrdinary\Log\LogDriverInterface;
useOrdinary\Log\LogEntryInterface;
finalclass MyBufferingDriver implements LogDriverInterface, FlushableInterface
{
privatearray$buffer = [];
publicfunction__construct(privatereadonlyLogDriverInterface$inner) {}
publicfunctionhandleLog(LogEntryInterface$logItem): void
{
$this->buffer[] = $logItem;
}
publicfunctionflush(): void
{
foreach ($this->bufferas$item) {
$this->inner->handleLog($item);
}
$this->buffer = [];
// Cascade to inner if it is also flushableif ($this->innerinstanceof FlushableInterface) {
$this->inner->flush();
}
}
}If your backend supports writing multiple items in a single call, implement LogBatchDriverInterface. BufferingDriver detects this interface during flush and calls handleLogBatch() with the entire buffer at once.
useOrdinary\Log\LogBatch;
useOrdinary\Log\LogBatchDriverInterface;
useOrdinary\Log\LogEntryInterface;
finalclass ElasticsearchDriver implements LogBatchDriverInterface
{
publicfunctionhandleLog(LogEntryInterface$logItem): void
{
$this->client->index(['body' => $this->format($logItem)]);
}
publicfunctionhandleLogBatch(LogBatch$batch): void
{
$body = [];
foreach ($batch->itemsas$item) {
$body[] = ['index' => ['_index' => 'logs']];
$body[] = $this->format($item);
}
$this->client->bulk(['body' => $body]);
}
}If your driver must always execute immediately — bypassing the async dispatcher — implement SynchronousDriverInterface instead:
useOrdinary\Log\SynchronousDriverInterface;
finalclass ImmediateStreamDriver implements SynchronousDriverInterface
{
publicfunctionhandleLog(LogEntryInterface$logItem): void
{
// always runs synchronously even when a dispatcher is set
}
}Matchers implement LogMatcherInterface and filter which log items a driver or group processes.
useOrdinary\Log\Matcher\IsAll;
useOrdinary\Log\Matcher\IsAny;
useOrdinary\Log\Matcher\IsLevel;
useOrdinary\Log\Matcher\IsLevelOrHigher;
useOrdinary\Log\Matcher\IsLevelOrLower;
useOrdinary\Log\Matcher\IsNot;
// Severity thresholdsnew IsLevelOrHigher(LogLevel::Error) // Error, Critical, Alert, Emergencynew IsLevelOrLower(LogLevel::Notice) // Debug, Info, Notice// Exact level matching (variadic)new IsLevel(LogLevel::Warning, LogLevel::Error)
// Compositionnew IsNot(newIsLevel(LogLevel::Debug))
new IsAll([newIsLevelOrHigher(LogLevel::Warning), newIsNot(newIsLevel(LogLevel::Warning))])
new IsAny([newIsLevel(LogLevel::Debug), newIsLevelOrHigher(LogLevel::Error)])Matchers compose without limit. Reuse the same instance across groups and drivers:
$prodMatcher = newIsLevelOrHigher(LogLevel::Warning);
$logger->addGroup('prod', matcher: $prodMatcher);
$logger->add($cloudWatchDriver, group: 'prod');
$logger->add($fileDriver, group: 'prod');
// Dev extends prod — receives everything prod does, plus debug$logger->addGroup('dev', matcher: newIsAny([$prodMatcher, newIsLevel(LogLevel::Debug)]));
$logger->add($localStreamDriver, group: 'dev');Implement LogMatcherInterface with a single matches() method:
useOrdinary\Log\LogEntryInterface;
useOrdinary\Log\LogMatcherInterface;
finalclass HasContext implements LogMatcherInterface
{
publicfunction__construct(privatereadonlystring$key) {}
publicfunctionmatches(LogEntryInterface$logItem): bool
{
returnarray_key_exists($this->key, $logItem->context);
}
}When a driver throws, the exception is wrapped in a LogFailureException that carries the original exception, the log item, and the failing driver. Logger passes it to the configured failure handler and then continues to the next driver.
By default, Logger uses ErrorLogFailureHandler, which writes the failure to PHP's error log via error_log(). Override it in the constructor:
useOrdinary\Log\FailureHandler\NoOpFailureHandler;
useOrdinary\Log\FailureHandler\SyslogFailureHandler;
useOrdinary\Log\FailureHandler\StderrFailureHandler;
useOrdinary\Log\FailureHandler\ErrorLogFailureHandler;
// Default — writes to PHP error log$logger = newLogger(onFailure: newErrorLogFailureHandler());
// Write to syslog$logger = newLogger(onFailure: newSyslogFailureHandler());
// Write to STDERR$logger = newLogger(onFailure: newStderrFailureHandler());
// Silently discard (explicit no-op)$logger = newLogger(onFailure: newNoOpFailureHandler());$e->getMessage(); // "Log dispatch failed for [error] 'msg': <original message>"$e->getPrevious(); // the original \Throwable$e->getLogItem(); // the LogEntryInterface being dispatched$e->getFailingDriver(); // the driver that threwImplement LogFailureHandlerInterface:
useOrdinary\Log\LogFailureExceptionInterface;
useOrdinary\Log\LogFailureHandlerInterface;
finalclass SentryFailureHandler implements LogFailureHandlerInterface
{
publicfunctionhandleLogFailure(LogFailureExceptionInterface$e): void
{
\Sentry\captureException($e->getPrevious() ?? $e);
}
}Pass a $dispatcher closure to Logger to defer driver calls. The closure receives each driver invocation as a zero-argument closure.
The shouldLog() / matcher checks always run synchronously before anything is queued, so filtered items are never dispatched.
revolt/event-loop is the shared backend for Amp 3.x and ReactPHP 3.x.
composer require revolt/event-loopuseRevolt\EventLoop;
useOrdinary\Log\Logger;
$logger = newLogger(
dispatcher: fn(\Closure$fn) => EventLoop::queue($fn),
);
$logger->add(newStreamDriver(fopen('/var/log/app.log', 'a'), $formatter));
$logger->info('Queued — not written yet');
EventLoop::run(); // flushes all queued writesEach driver call is dispatched as its own unit. Drivers implementing SynchronousDriverInterface are always invoked immediately, even when a dispatcher is set.
This section maps common Monolog patterns to their ordinary/log equivalents.
// MonologuseMonolog\Logger;
useMonolog\Handler\StreamHandler;
$log = newLogger('app');
$log->pushHandler(newStreamHandler('/var/log/app.log', Logger::WARNING));
// ordinary/loguseOrdinary\Log\Logger;
useOrdinary\Log\Driver\StreamDriver;
useOrdinary\Log\JsonFormatter;
useOrdinary\Log\Matcher\IsLevelOrHigher;
useOrdinary\Log\LogLevel;
$logger = newLogger(channel: 'app');
$logger->add(
newStreamDriver(fopen('/var/log/app.log', 'a'), newJsonFormatter()),
matcher: newIsLevelOrHigher(LogLevel::Warning),
);// Monolog — already implements Psr\Log\LoggerInterface natively$container->bind(Psr\Log\LoggerInterface::class, fn() => $log);
// ordinary/log — one extra call$container->bind(Psr\Log\LoggerInterface::class, fn() => $logger->toPsr());| Monolog handler | ordinary/log driver |
|---|---|
StreamHandler | StreamDriver |
RotatingFileHandler | RotatingStreamDriver |
SyslogHandler | SyslogDriver |
CloudWatchLogsHandler | CloudWatchDriver |
BufferHandler | BufferingDriver |
FingersCrossedHandler | FingersCrossedDriver |
NullHandler | NullDriver |
TestHandler | TestDriver |
// Monolog$log->pushProcessor(function (array$record): array {
$record['extra']['request_id'] = $requestId;
return$record;
});
// ordinary/loguseOrdinary\Log\CallableProcessor;
$logger->addProcessor(newCallableProcessor(
fn($item) => $item->withContext(['request_id' => $requestId]),
));// Monolog — minimum level on each handler$log->pushHandler(newStreamHandler(STDERR, Logger::ERROR));
// ordinary/log — matcher on each driver (or on the group)useOrdinary\Log\Matcher\IsLevelOrHigher;
useOrdinary\Log\LogLevel;
$logger->add(newStreamDriver(STDERR), matcher: newIsLevelOrHigher(LogLevel::Error));// Monolog — separate Logger instance per channel$paymentLog = newLogger('payment');
// ordinary/log — set on the Logger constructor; one logger, one channel$paymentLogger = newLogger(channel: 'payment');
// Appears as a top-level "channel" field in JsonFormatter output// Monolog$log->error('Charge failed', ['exception' => $e]);
// ordinary/log — identical; RESERVED_EXCEPTION is also "exception"$logger->error('Charge failed', ['exception' => $e]);