ZetCode

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:

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:

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
Debug100Detailed debug information
Info200Interesting events
Notice250Normal but significant events
Warning300Exceptional occurrences that are not errors
Error400Runtime errors that do not require immediate action
Critical500Critical conditions
Alert550Action must be taken immediately
Emergency600The 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.

simple.php
<?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.

levels.php
<?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.

console_log.php
<?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.

context_array.php
<?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.

placeholders.php
<?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.

handler_stack.php
<?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.

custom_processor.php
<?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.

json_formatter.php
<?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.

line_format.php
<?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.

exception_log.php
<?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.

channels.php
<?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.

rotating_file.php
<?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.

fingers_crossed.php
<?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.

Note: We should not test applications with a real mailbox. Use a local test mail server such as Mailpit, or an online service such as Mailtrap.

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.

mail_log.php
<?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

Monolog GitHub repository

Monolog usage documentation

PSR-3: Logger Interface

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

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all PHP tutorials.