Error Logging
Why Logging Matters In simple terms: In production, error messages must never be shown to visitors — but developers still need to know what went wrong. Logging writes errors and important events to a
Why Logging Matters
In simple terms: In production, error messages must never be shown to visitors — but developers still need to know what went wrong. Logging writes errors and important events to a file or service where developers can read them later. Without logs, a bug reported as "the payment page sometimes fails" is almost impossible to investigate.
Technically: PHP writes its own errors to the destination configured by the error_log setting (when log_errors is on). Applications add their own entries with error_log() or, more commonly, with a logging library such as Monolog, which implements the standard PSR-3 logger interface. Good logs are structured, include context, use severity levels, and never contain secrets.
PHP's Built-in Logging
Configuration
php.ini Setting | Recommended | Purpose |
|---|---|---|
log_errors | On | Write PHP errors to the log |
error_log | A file path, e.g. /var/log/php/app.log (or unset in containers) | Where PHP's errors go; if unset, they go to the web server's error log or to stderr |
display_errors | Off in production | Never show errors to visitors |
In Docker and many cloud platforms, the convention is to log to stderr, where the platform collects logs from all containers.
error_log()
error_log(string $message, int $message_type = 0, ?string $destination = null): bool
| Type | Destination |
|---|---|
0 (default) | PHP's configured error log (or the system log) |
3 | Appended to the file given in $destination |
4 | Sent directly to the server API's logging (for example, the web server) |
1 | Sent by email — avoid: a burst of errors becomes a flood of emails |
<?php
$logFile = __DIR__ . '/app.log';
error_log("Order 501 failed: gateway timeout\n", 3, $logFile);
error_log("Order 502 failed: card declined\n", 3, $logFile);
echo file_get_contents($logFile);
unlink($logFile);
Output
Order 501 failed: gateway timeout
Order 502 failed: card declined
With type 3, error_log() appends exactly the given text — add the newline yourself. With type 0, PHP adds a timestamp and newline automatically.
What Makes a Good Log Entry
A useful log entry answers: when, how serious, what happened, and in what context.
| Element | Example | Why |
|---|---|---|
| Timestamp with time zone | 2026-10-15T10:02:17+05:30 | Order events and correlate with other systems |
| Level | ERROR | Filter by severity |
| Message | Payment failed | Human-readable summary |
| Context | order_id=501, gateway=razorpay | The data needed to investigate |
| Request ID | req-7f3a | Connect all entries from one request |
Log Levels (PSR-3)
| Level | Use For |
|---|---|
emergency | The whole system is unusable |
alert | Immediate action needed (database down) |
critical | Critical component failure |
error | An operation failed (payment error, exception) |
warning | Something unusual that did not fail (slow API, retry needed) |
notice | Normal but significant events |
info | Routine events (user logged in, order placed) |
debug | Detailed diagnostic information (only in development) |
Production systems typically record info and above; debug produces too much data.
Structured Logging With Context and Redaction
Writing logs as JSON lines makes them searchable by log tools (ELK, Grafana Loki, cloud logging services). Sensitive values must be removed before writing:
<?php
function redact(array $context): array
{
foreach ($context as $key => $value) {
if (is_array($value)) {
$context[$key] = redact($value);
} elseif (in_array(strtolower((string) $key), ['password', 'token', 'secret', 'api_key'], true)) {
$context[$key] = '[REDACTED]';
} elseif ($key === 'card_number' && is_string($value)) {
$context[$key] = str_repeat('*', max(0, strlen($value) - 4)) . substr($value, -4);
}
}
return $context;
}
function logEvent(string $file, string $level, string $message, array $context, DateTimeInterface $time, string $requestId): void
{
$entry = [
'time' => $time->format(DATE_ATOM),
'level' => strtoupper($level),
'message' => $message,
'context' => redact($context),
'request_id' => $requestId,
];
file_put_contents($file, json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n", FILE_APPEND | LOCK_EX);
}
$file = __DIR__ . '/app.jsonl';
logEvent($file, 'error', 'Payment failed', [
'order_id' => 501,
'card_number' => '4111111111111111',
'password' => 'secret123',
], new DateTimeImmutable('2026-10-15 10:02:17', new DateTimeZone('Asia/Kolkata')), 'req-7f3a');
echo file_get_contents($file);
unlink($file);
Output
{"time":"2026-10-15T10:02:17+05:30","level":"ERROR","message":"Payment failed","context":{"order_id":501,"card_number":"************1111","password":"[REDACTED]"},"request_id":"req-7f3a"}
How It Works
- Each entry is one JSON object on one line ("JSON Lines"), easy to append and to parse.
redact()replaces passwords, tokens, and secrets (recursively, including nested arrays) and masks card numbers to their last four digits.- The request ID connects this entry with all other entries from the same request. Generate one per request (for example,
bin2hex(random_bytes(8))), or use the ID from a load balancer header. - Time and request ID are parameters, which makes the function testable.
Never Log
- Passwords, password hashes, and one-time codes
- Full card numbers, CVV, bank account numbers
- API keys, access tokens, session IDs
- Unnecessary personal data (log a user ID instead of name, email, and phone)
Logs are often kept for a long time and accessed by many people and tools; leaked logs are a common source of data breaches.
Message Placeholders
PSR-3 messages may contain {placeholders} that are filled from the context, keeping the message text constant (which helps grouping in log tools):
<?php
function interpolate(string $message, array $context): string
{
$replacements = [];
foreach ($context as $key => $value) {
if (is_scalar($value) || $value instanceof Stringable) {
$replacements['{' . $key . '}'] = (string) $value;
}
}
return strtr($message, $replacements);
}
echo interpolate('Order {order_id} failed at gateway {gateway}', ['order_id' => 501, 'gateway' => 'razorpay']) . "\n";
Output
Order 501 failed at gateway razorpay
Using Monolog
In real projects, use Monolog, the standard PHP logging library (used by Laravel and Symfony):
composer require monolog/monolog
<?php
require __DIR__ . '/vendor/autoload.php';
use Monolog\Formatter\JsonFormatter;
use Monolog\Handler\RotatingFileHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Level;
use Monolog\Logger;
use Monolog\Processor\PsrLogMessageProcessor;
$logger = new Logger('shop');
$fileHandler = new RotatingFileHandler(__DIR__ . '/storage/logs/app.log', 14, Level::Info);
$fileHandler->setFormatter(new JsonFormatter());
$logger->pushHandler($fileHandler);
$logger->pushHandler(new StreamHandler('php://stderr', Level::Error));
$logger->pushProcessor(new PsrLogMessageProcessor());
$logger->info('User {user_id} logged in', ['user_id' => 7]);
$logger->error('Payment failed for order {order_id}', ['order_id' => 501, 'gateway' => 'razorpay']);
try {
throw new RuntimeException('Gateway timeout');
} catch (RuntimeException $e) {
$logger->error('Payment exception', ['exception' => $e]);
}
What This Configures
- Handlers decide where entries go: a rotating daily file that keeps 14 days, and stderr for errors.
- Each handler has a minimum level: the file receives
infoand above, stderr onlyerrorand above. - Formatters decide the format (JSON here).
- Processors enrich entries (here, filling
{placeholders}); others add memory usage, request IDs, or user information. - Passing an exception in the
exceptioncontext key records its class, message, file, line, and stack trace.
Because Monolog implements PSR-3's Psr\Log\LoggerInterface, application classes can depend on the interface and receive any compatible logger.
Log Rotation and Retention
Log files grow continuously. Configure rotation (new file per day or per size) and retention (delete files after a number of days), either with Monolog's RotatingFileHandler, the operating system's logrotate, or your logging platform. Keep logs long enough for investigations, and no longer than your privacy obligations allow.
Common Mistakes
Displaying Instead of Logging
Effect: Errors are visible to users and lost to developers.
Fix: display_errors = Off, log_errors = On.
Logging Sensitive Data
Effect: Passwords, tokens, and card numbers end up in log files.
Fix: Redact before writing; log identifiers, not personal data.
Unstructured, Context-Free Messages
Effect: "Error occurred" entries that cannot be investigated.
Fix: Include context (IDs, operation, relevant values) and use levels.
Logging Everything at One Level
Effect: Important errors are buried among routine messages.
Fix: Use appropriate PSR-3 levels and minimum levels per handler.
Never Reading the Logs
Effect: Problems are noticed only when users complain.
Fix: Set up alerts for error and above, and review logs regularly.
Best Practices
- Log every error with a timestamp, level, message, context, and request ID.
- Use JSON lines (or a logging platform) so logs are searchable.
- Redact secrets and personal data before writing.
- Use Monolog (or your framework's logger) via the PSR-3 interface.
- Rotate logs, limit retention, and alert on serious errors.
Official reference: https://www.php.net/manual/en/function.error-log.php