PHP Monolog
last modified November 14, 2026
PHP Monolog tutorial shows how to do logging in PHP with Monolog. The examples were run with Monolog 3.12 and PHP 8.5.
Logging
Logging is the process of writing information into log files. Log files contain information about various events that happened in operating system, software, or in communication.
Purpose of logging
Logging is done for the following purposes:
- Information gathering
- Troubleshooting
- Generating statistics
- Auditing
- Profiling
Logging is not limited to identifying errors in software development. It is also used in detecting security incidents, monitoring policy violations, providing information in case of problems, finding application bottlenecks, or generating usage data.
Events to log
Events that should be logged include input validation failures, authentication and authorization failures, application errors, configuration changes, and application start-ups and shut-downs.
Events not to log
Events that should not be logged include application source code, session identification values, access tokens, sensitive personal data, passwords, database connection strings, encryption keys, bank account and card holder data.
Logging best practices
The following are some best practices for logging:
- Logging should be meaningful.
- Logging should contain context.
- Logging should be balanced; it should not include too little or too much information.
- Logging messages should be understandable to humans and parseable by machines.
- Logging should be structured and done at different levels.
- Logging should be adapted to development and to production.
- Logging in more complex applications should be done into several log files.
PHP Monolog
Monolog is a popular PHP logging library. It allows to send
logs to files, sockets, inboxes, databases and various web services.
It implements the PSR-3 LoggerInterface, so it can be replaced by
any other PSR-3 logger, and most PHP frameworks use it as their default logger.
Monolog 3 requires PHP 8.1 or newer. It uses the Monolog\Level
enum for log levels and the Monolog\LogRecord class for log records.
The old PHP 7 era API (Monolog 1 and 2) is gone: the add* methods
such as addInfo, the warn and err aliases
and array records in processors no longer exist.
Installation
We install Monolog with Composer. The examples also use the
vendor/autoload.php file generated by Composer.
$ composer require monolog/monolog
Monolog structure
A Monolog logger instance has a channel (name) and a stack of handlers. The handlers are responsible for saving the message to the file, database, or sending it to a mail.
A logged message travels through the handler stack. The last pushed handler acts
first. The further propagation of a message is controlled with the bubble
argument of the handler, which is by default set to true. A handler
that has bubble set to false and handles the message
stops the propagation.
A log record is a piece of information that is going to be written
to the log. It is an instance of LogRecord and has the following
properties:
| Property | Type | Description |
|---|---|---|
| message | string | The log message. |
| level | Monolog\Level | Severity of the log message (an enum case). |
| context | array | Arbitrary data passed with the construction of the message. |
| channel | string | The channel this message was logged to. |
| datetime | DateTimeImmutable | Date and time when the message was logged. |
| extra | array | A placeholder array where processors can put additional data. |
A processor is any PHP callable that receives the record and returns it, possibly with some extra data added. Processors can be attached to the logger or to a single handler.
The context is array data which has additional information that
does not fit well in the primary string. The context is a parameter to the
logging methods (e.g. info or warning).
Formatters are used to turn log records into strings or other formats.
Every handler has a default formatter, which can be replaced with
setFormatter.
Monolog log levels
Logging levels are used to categorize log messages by urgency.
Monolog has the following log levels, defined as cases of the
Monolog\Level enum:
| Level | Value | Meaning |
|---|---|---|
| Debug | 100 | Detailed debug information |
| Info | 200 | Interesting events |
| Notice | 250 | Normal but significant events |
| Warning | 300 | Exceptional occurrences that are not errors |
| Error | 400 | Runtime errors that do not require immediate action |
| Critical | 500 | Critical conditions |
| Alert | 550 | Action must be taken immediately |
| Emergency | 600 | The system is unusable |
Each handler has a minimum level; the default is Level::Debug.
Less severe records are ignored by the handler. By setting the level
to Level::Error we get messages with the error level and up.
To produce a message with a specific log level, we have the methods
debug, info, notice, warning,
error, critical, alert, and
emergency. There is also the generic log method, which
takes the level as the first argument.
Monolog simple example
In the first example, we use StreamHandler to log
a message to a file.
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Monolog\Handler\StreamHandler;
use Monolog\Level;
use Monolog\Logger;
$logger = new Logger('main');
$logger->pushHandler(new StreamHandler(__DIR__ . '/logs/app.log', Level::Debug));
$logger->info('First message');
The example writes an info message to the logs/app.log file.
$logger = new Logger('main');
A new logger named main is created. The name is the channel; it
appears in every log line.
$logger->pushHandler(new StreamHandler(__DIR__ . '/logs/app.log', Level::Debug));
We add a StreamHandler to the logger with pushHandler.
The handler writes messages to the specified file; the Level::Debug
argument means that it accepts all messages. The logs directory
is created automatically if it does not exist.
$logger->info('First message');
We log an info message with the info method.
The log line starts with the current date and time in the ISO 8601 format. It is
followed by the channel name and the level. Then comes the message. The two pairs
of square brackets are where Monolog puts the context and the extra data;
they are empty here. We can customize this output with formatters.
$ php simple.php $ cat logs/app.log [2026-11-14T18:19:49.304200+00:00] main.INFO: First message [] []
Monolog log levels example
The next example logs a message on each level. The handler has the
Level::Warning level, so the debug, info, and notice messages are
dropped. At the end we list the enum cases with their numeric values.
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Monolog\Handler\StreamHandler;
use Monolog\Level;
use Monolog\Logger;
$logger = new Logger('levels');
$logger->pushHandler(new StreamHandler('php://stdout', Level::Warning));
$logger->debug('Debug message');
$logger->info('Info message');
$logger->notice('Notice message');
$logger->warning('Warning message');
$logger->error('Error message');
$logger->critical('Critical message');
$logger->alert('Alert message');
$logger->emergency('Emergency message');
$logger->log(Level::Error, 'Message with a level given as an argument');
foreach (Level::cases() as $level) {
printf("%-9s %3d %s\n", $level->getName(), $level->value, $level->toPsrLogLevel());
}
The log method accepts a Level case.
The getName method of the enum returns the name of the level and
toPsrLogLevel returns the corresponding PSR-3 string constant.
$ php levels.php [2026-11-14T18:19:49.605351+00:00] levels.WARNING: Warning message [] [] [2026-11-14T18:19:49.605902+00:00] levels.ERROR: Error message [] [] [2026-11-14T18:19:49.605920+00:00] levels.CRITICAL: Critical message [] [] [2026-11-14T18:19:49.605932+00:00] levels.ALERT: Alert message [] [] [2026-11-14T18:19:49.605945+00:00] levels.EMERGENCY: Emergency message [] [] [2026-11-14T18:19:49.605957+00:00] levels.ERROR: Message with a level given as an argument [] [] DEBUG 100 debug INFO 200 info NOTICE 250 notice WARNING 300 warning ERROR 400 error CRITICAL 500 critical ALERT 550 alert EMERGENCY 600 emergency
Monolog console logging
We can write log messages to the terminal. The StreamHandler accepts
any PHP stream, including php://stdout and php://stderr.
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Monolog\Handler\StreamHandler;
use Monolog\Level;
use Monolog\Logger;
$logger = new Logger('stderr');
$logger->pushHandler(new StreamHandler('php://stderr', Level::Warning));
$logger->warning('A warning message');
$logger->info('This message is below the handler level');
We write to the console by passing php://stderr to the
StreamHandler. Log messages are diagnostic output, so the standard
error stream is the right place for them: it does not mix with the regular
output of the program. The info message is below the handler level and is
dropped.
$ php console_log.php [2026-11-14T18:19:52.286627+00:00] stderr.WARNING: A warning message [] []
Monolog context array
Monolog context array allows to add information to the message records at the userland level.
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
$logger = new Logger('main');
$logger->pushHandler(new StreamHandler('php://stdout'));
$logger->info('User logged in', ['user' => 'alice', 'ip' => '192.168.0.12']);
The second parameter of the info method is the context array.
We add the user name and the IP address to the message. The default formatter
writes the context as JSON after the message, which makes it parseable by
machines.
$ php context_array.php
[2026-11-14T18:19:49.906685+00:00] main.INFO: User logged in {"user":"alice","ip":"192.168.0.12"} []
Monolog placeholders
The PsrLogMessageProcessor replaces {name} placeholders
in the message with the values from the context, as defined by the PSR-3
specification.
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use Monolog\Processor\PsrLogMessageProcessor;
$logger = new Logger('main');
$logger->pushHandler(new StreamHandler('php://stdout'));
$logger->pushProcessor(new PsrLogMessageProcessor());
$logger->info('User {user} logged in from {ip}', ['user' => 'alice', 'ip' => '192.168.0.12']);
$logger->warning('Disk usage is {percent} %', ['percent' => 91]);
The processor is added to the logger with pushProcessor. The
placeholders in the message are replaced with the context values. The context
is still written after the message, so no information is lost. The message
text stays constant between calls only if we do not put the values in it, which
is why many systems prefer to group messages by the template and keep the values
in the context.
$ php placeholders.php
[2026-11-14T18:19:50.204924+00:00] main.INFO: User alice logged in from 192.168.0.12 {"user":"alice","ip":"192.168.0.12"} []
[2026-11-14T18:19:50.205490+00:00] main.WARNING: Disk usage is 91 % {"percent":91} []
Monolog logger handler stack
We can add multiple handlers to the stack. The last added handler is executed first.
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Monolog\Formatter\LineFormatter;
use Monolog\Handler\StreamHandler;
use Monolog\Level;
use Monolog\Logger;
function handler(string $label, Level $level, bool $bubble = true): StreamHandler
{
$handler = new StreamHandler('php://stdout', $level, $bubble);
$handler->setFormatter(new LineFormatter("$label: %level_name%: %message%\n"));
return $handler;
}
$logger = new Logger('main');
$logger->pushHandler(handler('first pushed', Level::Debug));
$logger->pushHandler(handler('second pushed', Level::Debug));
$logger->info('Information message');
echo "---\n";
$logger = new Logger('main');
$logger->pushHandler(handler('first pushed', Level::Debug));
$logger->pushHandler(handler('errors only', Level::Error, bubble: false));
$logger->info('Information message');
$logger->error('Error message');
The example has a handler function, which creates a
StreamHandler with a custom line format. In the first logger, we
push two handlers; the second one runs first, then the message bubbles up to the
first one.
In the second logger, the last pushed handler has the Level::Error
level and $bubble set to false. The info message is
ignored by it and goes to the first handler. The error message is handled by it
and does not bubble any further, so the first handler does not see it.
$ php handler_stack.php second pushed: INFO: Information message first pushed: INFO: Information message --- first pushed: INFO: Information message errors only: ERROR: Error message
Monolog custom processor
A custom processor can be added with pushProcessor.
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use Monolog\LogRecord;
$logger = new Logger('main');
$logger->pushHandler(new StreamHandler('php://stdout'));
$logger->pushProcessor(function (LogRecord $record): LogRecord {
return $record->with(extra: [...$record->extra, 'php' => PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION]);
});
$logger->info('Information message', ['user' => 'alice']);
In the example, we add some extra information to the log record in a
processor. A LogRecord is meant to be treated as immutable, so the
processor returns a copy created with with. We add the PHP version
to the extra data. According to the documentation, the difference between
context and extra data is that context is supplied in user land whereas
extra is internal only and can be filled by processors.
Monolog has several built-in processors, including
WebProcessor (URL and client IP),
IntrospectionProcessor (file, line, class and function that
created the message),
MemoryUsageProcessor, ProcessIdProcessor and
UidProcessor (a unique id per request).
$ php custom_processor.php
[2026-11-14T18:19:50.821098+00:00] main.INFO: Information message {"user":"alice"} {"php":"8.5"}
Monolog JsonFormatter
JsonFormatter writes records in JSON format, one object per line.
This is the format that log aggregation systems such as Elasticsearch or Loki
understand best.
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Monolog\Formatter\JsonFormatter;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
$logger = new Logger('main');
$handler = new StreamHandler(__DIR__ . '/logs/app-json.log');
$handler->setFormatter(new JsonFormatter());
$logger->pushHandler($handler);
$logger->info('Information message', ['user' => 'alice']);
$logger->error('Something failed', ['code' => 42]);
The example writes two messages to a file in JSON format using JsonFormatter.
We set the formatter on the handler with setFormatter.
$ php json_formatter.php
$ cat logs/app-json.log
{"message":"Information message","context":{"user":"alice"},"level":200,"level_name":"INFO","channel":"main","datetime":"2026-11-14T18:19:52.585831+00:00","extra":{}}
{"message":"Something failed","context":{"code":42},"level":400,"level_name":"ERROR","channel":"main","datetime":"2026-11-14T18:19:52.585922+00:00","extra":{}}
Monolog LineFormatter
LineFormatter formats a log record into a one-line string.
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Monolog\Formatter\LineFormatter;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
$output = "[%datetime%] %channel%.%level_name%: %message% (user: %context.user%)\n";
$formatter = new LineFormatter($output, 'Y-m-d H:i:s');
$handler = new StreamHandler('php://stdout');
$handler->setFormatter($formatter);
$logger = new Logger('main');
$logger->pushHandler($handler);
$logger->info('Information message', ['user' => 'alice']);
In the example, we customize the log line with LineFormatter. The
first argument is the template. It contains the date and time, channel name, level
name, message, and a value from the context array. The second argument is the
date format. The names between percent signs refer to the record
parts; %context.user% accesses a key of the context.
$ php line_format.php [2026-11-14 18:19:51] main.INFO: Information message (user: alice)
Monolog exceptions
Exceptions are logged by passing them in the exception key of the
context. Monolog formatters know this key and convert the object to a readable
description.
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Monolog\Formatter\LineFormatter;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
function divide(int $a, int $b): int
{
if ($b === 0) {
throw new InvalidArgumentException('Division by zero');
}
return intdiv($a, $b);
}
$handler = new StreamHandler('php://stdout');
$handler->setFormatter(new LineFormatter(dateFormat: 'Y-m-d H:i:s', ignoreEmptyContextAndExtra: true));
$logger = new Logger('main');
$logger->pushHandler($handler);
try {
divide(10, 0);
} catch (InvalidArgumentException $e) {
$logger->error('Calculation failed', ['exception' => $e]);
}
The exception class, code, message, file and line are written to the log. The
LineFormatter in the example has the
ignoreEmptyContextAndExtra option set, which removes the empty
extra brackets. A stack trace can be included by setting the
includeStacktraces option of the formatter or by calling
includeStacktraces().
$ php exception_log.php
[2026-11-14 18:19:51] main.ERROR: Calculation failed {"exception":"[object] (InvalidArgumentException(code: 0): Division by zero at /app/exception_log.php:14)"}
Monolog channels
Larger applications log to several channels, such as app,
db, or mail, so that messages from various parts of the
application can be told apart.
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Monolog\Formatter\LineFormatter;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
$handler = new StreamHandler('php://stdout');
$handler->setFormatter(new LineFormatter("%channel%.%level_name%: %message%\n"));
$app = new Logger('app');
$app->pushHandler($handler);
$db = $app->withName('db');
$mail = $app->withName('mail');
$app->info('Application started');
$db->info('Connected to the database');
$mail->warning('SMTP server is slow');
The withName method returns a clone of the logger with a different
channel name. The clone shares the handlers and processors of the original logger.
The line format shows the channel before the level.
$ php channels.php app.INFO: Application started db.INFO: Connected to the database mail.WARNING: SMTP server is slow
Monolog RotatingFileHandler
A log file that grows without limits will eventually fill the disk.
RotatingFileHandler creates a new file every day and keeps only a
given number of the most recent files.
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Monolog\Handler\RotatingFileHandler;
use Monolog\Level;
use Monolog\Logger;
$logger = new Logger('main');
$logger->pushHandler(new RotatingFileHandler(__DIR__ . '/logs/rotating.log', 7, Level::Info));
$logger->info('This message goes to a file with the current date in its name');
The handler takes the file name, the maximum number of files to keep (7 in the
example; 0 means unlimited) and the minimum level. The date is added to the file
name before the extension. The rotation can be changed to hourly, monthly, or
yearly with setFilenameFormat.
$ php rotating_file.php $ ls logs app-json.log app.log rotating-2026-11-14.log
Monolog FingersCrossedHandler
The FingersCrossedHandler buffers all log messages and passes them
to the wrapped handler only if a message at or above the activation level
appears. In production, we then log the debug detail only for the requests that
fail.
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Monolog\Handler\FingersCrossedHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Level;
use Monolog\Logger;
function process(Logger $logger, bool $fail): void
{
$logger->debug('Request started');
$logger->info('Loading data');
if ($fail) {
$logger->error('Could not load data');
}
$logger->debug('Request finished');
}
$stream = new StreamHandler('php://stdout');
echo "Successful request:\n";
$logger = new Logger('web');
$logger->pushHandler(new FingersCrossedHandler($stream, Level::Error));
process($logger, false);
$logger->close();
echo "Failed request:\n";
$logger = new Logger('web');
$logger->pushHandler(new FingersCrossedHandler($stream, Level::Error));
process($logger, true);
$logger->close();
The wrapped StreamHandler accepts everything, but the
FingersCrossedHandler forwards the records only when an error is
logged. The first request has no error, so its messages are discarded. The second
request logs an error; the handler writes the whole buffer, including the debug
messages that preceded the error and the one that follows it. The
close call flushes the handler and resets its state.
$ php fingers_crossed.php Successful request: Failed request: [2026-11-14T18:19:51.407962+00:00] web.DEBUG: Request started [] [] [2026-11-14T18:19:51.407964+00:00] web.INFO: Loading data [] [] [2026-11-14T18:19:51.407965+00:00] web.ERROR: Could not load data [] [] [2026-11-14T18:19:51.408600+00:00] web.DEBUG: Request finished [] []
Monolog mail log message
Emails can be sent with SymfonyMailerHandler. It uses the Symfony
Mailer component to deliver the message. The older SwiftMailerHandler
was removed from Monolog 3, because the Swift Mailer library is abandoned.
For this example, we run Mailpit in a Docker container. It accepts SMTP connections on port 1025 and shows the received messages in a web interface on port 8025 and via a REST API. We also install the Symfony Mailer.
$ docker run -d --name mailpit -p 1025:1025 -p 8025:8025 axllent/mailpit $ composer require symfony/mailer
We create the e-mail template and the handler.
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SymfonyMailerHandler;
use Monolog\Level;
use Monolog\Logger;
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Transport;
use Symfony\Component\Mime\Email;
$transport = Transport::fromDsn('smtp://localhost:1025');
$mailer = new Mailer($transport);
$template = (new Email())
->from('approot@example.com')
->to('support@example.com')
->subject('Critical message');
$logger = new Logger('main');
$logger->pushHandler(new StreamHandler('php://stdout'));
$logger->pushHandler(new SymfonyMailerHandler($mailer, $template, Level::Critical));
$logger->info('This message is only written to the console');
$logger->critical('Could not connect to the database');
The SymfonyMailerHandler takes a mailer, an e-mail template (an
Email object, or a callable that returns one), and the minimum level.
The template defines the sender, recipient and subject; the handler fills the
body with the formatted log record. The critical message is both
printed to the console and e-mailed; the info message is below the level of the
mail handler, so it only goes to the console.
The Mailpit REST API shows the received message.
$ php mail_log.php [2026-11-14T18:19:53.124341+00:00] main.INFO: This message is only written to the console [] [] [2026-11-14T18:19:53.124893+00:00] main.CRITICAL: Could not connect to the database [] [] $ curl -s http://localhost:8025/api/v1/message/latest | jq -r .Text ******** CRITICAL ******** Message: Could not connect to the database Time: 2026-11-14T18:19:53.124893+00:00 Channel: main
Source
In this article we have used Monolog to do logging in PHP. We have worked with log levels, handlers, processors, formatters, rotating files, and e-mail alerts. Read also the PHP exceptions tutorial and the PHPUnit tutorial.
Author
List all PHP tutorials.