Logging with Monolog
When a customer in Lucknow says "my payment failed yesterday at 9 pm", you cannot attach a debugger to yesterday. The only witness is your log: a file or service where your application writes down what it did and what went wrong. Good logs turn a two-day investigation into a five-minute search. Bad logs (or none) leave you guessing.
Namespaces & Composer installed Monolog and wrote one log line. In this lesson you will use it the way teams do in production: choosing the right level, adding context, splitting logs into channels, rotating files, writing JSON for log tools, catching every PHP error and exception, and keeping secrets such as passwords and OTPs out of your logs.
Why not just error_log() or echo?
PHP's built-in error_log('Payment failed') writes one plain line to the server's error log. It works, but it has no levels, no structured data, no way to send critical errors somewhere else, and no rotation. echo is worse: it shows internal details to visitors. Monolog implements PSR-3 (see PSR Standards in Practice), so the same logger works in plain PHP, Laravel and Symfony.
Levels, messages and context
Monolog has eight levels, from least to most serious: DEBUG, INFO, NOTICE, WARNING, ERROR, CRITICAL, ALERT and EMERGENCY. A handler decides where records go and from which level up. Each record has a fixed message and a context array with the changing details:
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Monolog\Formatter\LineFormatter;
use Monolog\Handler\StreamHandler;
use Monolog\Level;
use Monolog\Logger;
use Monolog\Processor\PsrLogMessageProcessor;
$screen = new StreamHandler('php://stdout', Level::Info);
$screen->setFormatter(new LineFormatter("%channel%.%level_name%: %message% %context% %extra%\n"));
$log = new Logger('shop');
$log->pushHandler($screen);
$log->pushProcessor(new PsrLogMessageProcessor());
$log->debug('Cart recalculated', ['items' => 3]);
$log->info('Order {order} placed by {user}', ['order' => 1042, 'user' => 'meera@example.in']);
$log->warning('Payment slow', ['gateway_ms' => 4200]);
$log->error('Payment failed', ['order' => 1043, 'code' => 'BANK_DECLINED']);
$payments = $log->withName('payments');
$payments->notice('Refund queued', ['order' => 1043, 'amount' => 1299]);shop.INFO: Order 1042 placed by meera@example.in {"order":1042,"user":"meera@example.in"} []
shop.WARNING: Payment slow {"gateway_ms":4200} []
shop.ERROR: Payment failed {"order":1043,"code":"BANK_DECLINED"} []
payments.NOTICE: Refund queued {"order":1043,"amount":1299} []The DEBUG line is missing because this handler starts at Level::Info. The PsrLogMessageProcessor fills {order} and {user} placeholders from the context. withName() creates a second channel that shares the same handlers, so you can later filter "everything about payments".
Which level to use:
| Level | Use it for | Example |
|---|---|---|
debug | Details useful only while developing | SQL query timings |
info | Normal events worth recording | User logged in, order placed |
notice | Normal but significant | Refund queued, settings changed |
warning | Something odd that the app handled | Slow gateway, retry needed, deprecated API used |
error | An action failed and needs attention | Payment failed, email not sent |
critical and above | Part of the system is down | Database unreachable |
A useful rule: if nobody should be woken up or emailed about it, it is not error or above.
Rotating files and catching PHP errors
A single log file grows forever and eventually fills the disk. RotatingFileHandler writes one file per day and deletes old ones. Monolog's ErrorHandler connects the logger to PHP itself, so warnings, uncaught exceptions and fatal errors are all logged with the same format:
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Monolog\ErrorHandler;
use Monolog\Handler\RotatingFileHandler;
use Monolog\Level;
use Monolog\Logger;
$log = new Logger('app');
$log->pushHandler(new RotatingFileHandler(__DIR__ . '/storage/logs/app.log', 14, Level::Info));
ErrorHandler::register($log);
echo $undefinedVariable;
throw new LogicException('Price list not loaded');The handler adds the date to the file name, so today's file is storage/logs/app-2026-11-02.log, and it keeps 14 days of files. Its contents (with your own paths and times):
[2026-11-02T01:58:53.012796+05:30] app.WARNING: E_WARNING: Undefined variable $undefinedVariable {"code":2,"message":"Undefined variable $undefinedVariable","file":"/var/www/shop/index.php","line":15} []
[2026-11-02T01:58:53.033525+05:30] app.ERROR: Uncaught Exception LogicException: "Price list not loaded" at /var/www/shop/index.php line 16 {"exception":"[object] (LogicException(code: 0): Price list not loaded at /var/www/shop/index.php:16)"} []Keep log files outside the web root (here in storage/), or anyone could download them from the browser. On Linux servers you can also use the system's logrotate tool with a plain StreamHandler; both approaches are common.
JSON logs and "fingers crossed"
When logs go to a search tool (Graylog, Elasticsearch, Loki, a cloud logging service), one JSON object per line is far easier to search than free text. And in production you usually do not want DEBUG lines, except when something fails, when they are exactly what you need. FingersCrossedHandler solves this: it keeps lower-level records in memory and writes them only if a record at the trigger level arrives.
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Monolog\Formatter\JsonFormatter;
use Monolog\Handler\FingersCrossedHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Level;
use Monolog\Logger;
$json = new StreamHandler('php://stdout', Level::Debug);
$json->setFormatter(new JsonFormatter());
$log = new Logger('checkout');
$log->pushHandler(new FingersCrossedHandler($json, Level::Error));
$log->debug('Request started', ['path' => '/checkout']);
$log->info('Coupon applied', ['code' => 'DIWALI10']);
echo "--- nothing written yet ---\n";
try {
throw new RuntimeException('Gateway timeout after 30s');
} catch (RuntimeException $e) {
$log->error('Payment failed', ['order' => 1043, 'exception' => $e]);
}--- nothing written yet ---
{"message":"Request started","context":{"path":"/checkout"},"level":100,"level_name":"DEBUG","channel":"checkout","datetime":"2026-11-02T01:59:14.122927+05:30","extra":{}}
{"message":"Coupon applied","context":{"code":"DIWALI10"},"level":200,"level_name":"INFO","channel":"checkout","datetime":"2026-11-02T01:59:14.123004+05:30","extra":{}}
{"message":"Payment failed","context":{"order":1043,"exception":{"class":"RuntimeException","message":"Gateway timeout after 30s","code":0,"file":"/var/www/shop/checkout.php:25"}},"level":400,"level_name":"ERROR","channel":"checkout","datetime":"2026-11-02T01:59:14.123109+05:30","extra":{}}Requests that succeed write nothing; a failed request writes its whole story. Notice the convention of passing a caught exception under the context key exception: Monolog formats its class, message and location for you.
Keeping secrets out of logs
Logs are copied to backups, shared with colleagues and sent to outside services. They must never contain passwords, OTPs, full card numbers, API keys or session ids, and should hold as little personal data as possible. In India, the Digital Personal Data Protection Act, 2023 makes careless handling of personal data a legal risk, not just a technical one. A processor is a callable that edits every record before it is written, which makes it the right place to mask secrets:
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Monolog\Formatter\LineFormatter;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use Monolog\LogRecord;
use Monolog\Processor\ProcessorInterface;
final class MaskSecrets implements ProcessorInterface
{
private const array KEYS = ['password', 'otp', 'card_number', 'token'];
public function __invoke(LogRecord $record): LogRecord
{
$context = $record->context;
foreach (self::KEYS as $key) {
if (array_key_exists($key, $context)) {
$context[$key] = '***';
}
}
return $record->with(context: $context);
}
}
$handler = new StreamHandler('php://stdout');
$handler->setFormatter(new LineFormatter("%level_name%: %message% %context%\n"));
$log = new Logger('auth');
$log->pushHandler($handler);
$log->pushProcessor(new MaskSecrets());
$log->info('OTP sent', ['mobile' => '98290xxxxx', 'otp' => '482913']);
$log->warning('Login failed', ['email' => 'rohan@example.in', 'password' => 'cricket@123']);INFO: OTP sent {"mobile":"98290xxxxx","otp":"***"}
WARNING: Login failed {"email":"rohan@example.in","password":"***"}LogRecord objects are immutable, so the processor returns a changed copy with with(). The mask is a safety net; the first rule is still to not put secrets into the context at all.
Common mistakes
- Logging with string joining.
"Payment failed for $order"creates thousands of different messages; use a fixed message plus context. - Logging everything at
error. Real problems drown in noise and alerts are ignored. - Showing errors on screen in production. Set
display_errorsoff and log instead. - Log files inside
public/. Anyone can download them. - Catching an exception and logging only
$e->getMessage(). Pass the whole exception as'exception' => $eto keep its class, file and line.
Key points
- Monolog is the standard PSR-3 logger for PHP; Laravel and Symfony use it too.
- Choose levels carefully:
infofor normal events,warningfor handled problems,errorand above for failures that need action. - Fixed messages plus context arrays make logs searchable; channels group related logs.
RotatingFileHandlerlimits disk use,ErrorHandler::register()captures PHP errors and uncaught exceptions.- JSON lines suit log tools;
FingersCrossedHandlerwrites debug detail only when something fails. - Never log passwords, OTPs, tokens or card numbers; mask them with a processor as a safety net.
Exercise
Set up a logger for a train-booking app with two handlers: a StreamHandler to storage/logs/bookings.log for everything from INFO up, and a second StreamHandler to php://stderr only for ERROR and above. Log one info line for booking PNR 4521367890 by Ananya and one error line when booking PNR 4521367891 fails with the reason NO_SEATS. Which lines appear in each place?
Show solution
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Monolog\Handler\StreamHandler;
use Monolog\Level;
use Monolog\Logger;
$log = new Logger('bookings');
$log->pushHandler(new StreamHandler(__DIR__ . '/storage/logs/bookings.log', Level::Info));
$log->pushHandler(new StreamHandler('php://stderr', Level::Error));
$log->info('Booking confirmed', ['pnr' => '4521367890', 'passenger' => 'Ananya']);
$log->error('Booking failed', ['pnr' => '4521367891', 'reason' => 'NO_SEATS']);[2026-11-02T10:15:04.512330+05:30] bookings.ERROR: Booking failed {"pnr":"4521367891","reason":"NO_SEATS"} []The terminal (stderr) shows only the error. The file bookings.log gets both lines, because every handler receives each record and checks it against its own minimum level.
Quick Quiz
1. Why write 'Order {order} failed' with context instead of "Order $order failed"?
Fixed messages with structured context are what log tools search and count.
2. What does FingersCrossedHandler do?
Successful requests stay quiet, and failing ones log their full history.
3. Where should log files be stored?
Files inside the web root can be downloaded by anyone who guesses the URL.
4. Which of these is acceptable in a log line?
Never log secrets; ids and codes are enough to investigate.
Learn PHP with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
