Configure Monolog channels, JSON logs, and exception handling
Use Monolog 3 handlers, processors, and formatters to preserve request context and exception traces, then verify PHP log collection in Guance.
Monolog is a common foundation for PHP application logging, including framework integrations in Laravel and Symfony. It separates the destination, enrichment, and representation of an event into handlers, processors, and formatters.
That separation lets an application keep its logging calls stable while changing file output to container output, adding request context, or adopting a JSON schema. This guide uses Monolog 3, whose Level enum and LogRecord processor API require a compatible PHP runtime; Monolog 3 requires PHP 8.1 or later.
Create a channel and attach a handler
Install the package in a project with a supported PHP version:
composer require monolog/monolog:^3.0
Create a logger and a stream handler:
<?php
require __DIR__ . '/vendor/autoload.php';
use Monolog\Level;
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
$logger = new Logger('order-service');
$logger->pushHandler(new StreamHandler(__DIR__ . '/logs/app.log', Level::Info));
$logger->debug('Loading diagnostic state'); // Below this handler's threshold.
$logger->info('Order created', ['order_id' => 1024]);
$logger->warning('Stock running low', ['sku' => 'A-01']);
$logger->error('Payment failed', [
'order_id' => 1024,
'reason' => 'insufficient_funds',
]);
Make the destination writable by the PHP process. The default line formatter produces a record similar to:
[2026-08-25T14:55:22.123456+08:00] order-service.INFO: Order created {"order_id":1024} []
The logger name is its channel. Separate payments, security, or api loggers can use different handlers and thresholds, though handlers can also be shared deliberately.
The second argument is the PSR-3 context array. It carries structured values even when a text formatter renders them as JSON within a line. That text line is not itself a top-level JSON event. Monolog supports the eight PSR-3 severity levels: debug, info, notice, warning, error, critical, alert, and emergency. Monolog usage guide
Choose handlers and understand bubbling
| Handler | Typical role |
|---|---|
StreamHandler |
Write to a file or a stream such as php://stdout |
RotatingFileHandler |
Create dated files and limit the number retained |
SyslogHandler / SyslogUdpHandler |
Send events through a syslog path |
FilterHandler |
Restrict which levels reach a wrapped handler |
FingersCrossedHandler |
Buffer events and release them when an activation condition occurs |
For daily files and a separate error stream, configure a fresh logger rather than adding destinations accidentally to the previous example:
use Monolog\Handler\RotatingFileHandler;
$logger = new Logger('order-service');
$logger->pushHandler(new RotatingFileHandler(
__DIR__ . '/logs/app.log',
30,
Level::Info,
));
$logger->pushHandler(new StreamHandler(
__DIR__ . '/logs/error.log',
Level::Error,
));
The value 30 limits the number of matching files, not an unconditional 30-day retention guarantee. With default daily rotation and a file every day, those may approximately correspond. Sparse activity, changed filename rules, permissions, and cleanup timing can make them differ. RotatingFileHandler implementation
Handlers are visited in last-pushed-first order. By default, a handler allows records to continue to the next handler through bubbling. In this example, an error is written to both files. Setting bubble to false on the error handler prevents records it handles from continuing to the daily handler.
Putting a stricter handler on top is therefore a routing choice, not a general requirement that establishes severity filtering by itself. Test the intended duplication or exclusivity with one event at each level.
For a container, new StreamHandler('php://stdout', Level::Info) can provide the output boundary. Configure runtime rotation and collection separately.
Use JsonFormatter for a queryable event
A JSON formatter makes the entire record a JSON object:
use Monolog\Formatter\JsonFormatter;
$handler = new StreamHandler('php://stdout', Level::Info);
$formatter = new JsonFormatter();
$formatter->includeStacktraces(true);
$handler->setFormatter($formatter);
$logger = new Logger('order-service');
$logger->pushHandler($handler);
Typical fields include message, context, level, level_name, channel, datetime, and extra. Inspect the output from the version you deploy. The formatter appends a newline by default for individual events, making it suitable for line-oriented collection.
The explicit includeStacktraces(true) matters. Monolog's JSON formatter does not include the full exception trace by default. An exception object in context can otherwise contain its class, message, and location without the full stack you expected. JsonFormatter implementation
Add shared context with a processor
A Monolog 3 processor receives a LogRecord. It can add fields to extra and return the record:
use Monolog\LogRecord;
$logger->pushProcessor(function (LogRecord $record): LogRecord {
$record->extra['hostname'] = gethostname();
$incoming = $_SERVER['HTTP_X_REQUEST_ID'] ?? '';
$record->extra['request_id'] = preg_match(
'/\A[A-Za-z0-9._-]{1,128}\z/',
$incoming,
) === 1 ? $incoming : null;
return $record;
});
The request ID is bounded, but it is still untrusted correlation data if the client controls the header. Generate an ID at a trusted request boundary when needed; do not use a request ID as authorization evidence.
A processor attached to this logger affects this logger's events. It does not automatically enrich every channel in the application. Apply the shared processor to the loggers or handlers that should use it.
Monolog also provides processors for web request information, memory usage, and unique identifiers. Choose fields deliberately: a general request processor can expose data you did not intend to retain. In a long-running worker, resolve context per job or request and clear it at the boundary. A value captured once at startup may be stale for later work.
Log exceptions at the correct boundary
PSR-3 reserves the exception context key for a throwable. Use it when recording a failure:
try {
$payment->charge($order); // Application-defined objects and operation.
} catch (Throwable $e) {
$logger->error('Payment raised an exception', [
'order_id' => $order->id,
'exception' => $e,
]);
throw $e;
}
With stack traces enabled on the JSON formatter above, the serialized exception includes the trace. Decide whether this layer or an outer handler owns the diagnostic event; logging and rethrowing at every layer creates duplicates.
A standalone script can also register a handler for uncaught exceptions:
set_exception_handler(function (Throwable $e) use ($logger): void {
$logger->critical('Uncaught exception', ['exception' => $e]);
$logger->close();
exit(1);
});
This gives a command-line process a failure exit status and lets handlers close normally. It does not cover every PHP warning, fatal engine failure, or forced process termination. Frameworks often already install an exception pipeline; integrate with it instead of overwriting it blindly.
Validate collection and nested fields in Guance
Write a normal JSON event and an event containing an exception. For host files, configure logfiles in the DataKit log collector. For containers, configure the corresponding container-output collection. Assign a stable service identifier.
If using json_as_fields, verify its treatment of the top-level JSON fields. Do not assume it recursively flattens every item in context and extra. Use Pipeline to extract the business fields you need and map level_name and datetime to severity and event time.
Query the collected records by service, channel, and severity. Check the entire traceback, timestamp offset, and field types before relying on an error-count alert.
For trace correlation, a processor should obtain the actual active trace ID from the tracing integration. Confirm that the trace is also collected, then verify matching logs in Guance trace details. A randomly generated request ID is useful for log filtering but is not a substitute for that trace ID.
Frequently asked questions
How do channels and handler levels work together?
A channel identifies a logger's logical source. Handlers select the records they accept, and their order and bubbling settings determine whether other handlers also see the event. Add an error handler when you want an additional error destination; disable bubbling only when that exclusivity is intentional.
How does FingersCrossedHandler work?
It keeps events in memory until an activation strategy—often an error threshold—causes buffered context to be passed to the wrapped handler. Configure buffer size and lifecycle for the workload, especially in long-running workers. An abrupt failure can lose buffered events, and an error that never meets the activation condition may never be written. Pairing it with an always-on destination changes both reliability and volume; choose that deliberately.
Can logging slow PHP-FPM down?
Yes. A synchronous file write can block, and a network handler can hold a worker while the remote destination responds. Local output plus a separate collector reduces direct dependence on a remote logging service, but still needs testing under backpressure and disk pressure. Measure worker latency with the actual handler configuration.
Will a long-running CLI consumer lose logs on exit?
A synchronous handler avoids an application-level buffer, but that alone is not a durability guarantee. Buffered handlers should be closed or reset according to their intended lifecycle. On platforms where PCNTL is available, a worker can handle termination signals, stop accepting work, and close its logger during graceful shutdown. Signal delivery must be configured and tested; registering a callback is not enough in every execution mode. A forced kill, runtime crash, or machine failure can still lose data in process, OS, or collector buffers.
Related reading
Contact us
Join the community
to join the community
Try Guance
Start online and pay only for what you use.
Get startedChoose a Guance plan