Logging
error_log writes one line to the server log or a file you choose; what you put in that line, and what you leave out, is the whole skill.
error_log(string $message, int $message_type = 0, ?string $destination = null): boolA log is what you still have after the request is gone. In production nobody sees the error on screen, so the log line is the entire investigation.
error_log
error_log() takes the message and a $message_type. 0, the default, sends it wherever the error_log ini setting points. 3 appends it to the file you pass as $destination. 4 hands it to the SAPI's own logger. 1 sends mail, and 2 is no longer an option. log_errors_max_len, 1024 bytes by default, cuts the errors PHP logs and displays itself, but it is not applied to explicitly called functions such as error_log(), so a trace you pass yourself arrives whole.
What to record
A timestamp, a level, a short stable event name you can grep for, the exception class, its message, file and line, and identifiers you can join on later such as a request id and a user id. One line per event, in a shape a machine can parse, because you will be reading these with command line tools under pressure.
What never to record
Passwords, password hashes, session ids, CSRF tokens, API keys, card numbers, and whole request bodies that may contain any of them. Log files are copied to laptops, shipped to third-party services and read by people with no business seeing a password. A password written to a log is a breach you created yourself, and it stays in the backups long after the login code is fixed. Redact by key name where the line is built, so no call site can leak one by accident.
Example
When to use it
- Record a failed login attempt with the email and IP but never the password tried.
- Tie every line a request wrote together with one request id for a later investigation.
- Send container logs to stderr so the platform collects them instead of a file nobody rotates.
- Keep an audit trail of admin actions in a file separate from the error log.
More examples
The two destinations, written out
Prefer type 0 on a managed server: the operator has already decided where logs go, how they rotate and how they ship, and a file of your own escapes all of that. In a container, point the ini setting at /dev/stderr and let the platform collect it. Reach for type 3 when one stream genuinely needs to be separate, such as an audit trail, and remember the newline.
<?php
declare(strict_types=1);
// Type 0: goes wherever the error_log ini setting points. On PHP-FPM that is
// usually the pool's error log; on the CLI it is stderr. A newline is added
// for you.
error_log('WARNING: login failed for user 4412');
// Type 3: appends to a file you name. No newline is added, so add one.
error_log('WARNING: login failed for user 4412' . PHP_EOL, 3, '/var/log/php/app.log');
// Type 0 always returns true, even when nothing was written, so it cannot tell
// you the log is unwritable. Type 3 returns false if the file cannot be opened.
$written = error_log('one line' . PHP_EOL, 3, '/var/log/php/app.log');A small logger with redaction built in
One JSON object per line is the format to aim for: still readable with grep, and parseable by any log tool without a custom pattern. The redaction list lives in the logger, not at the call sites, which is the point. A call site that passes the whole $_POST array is then merely untidy rather than a disclosure. This is the shape a framework's logger gives you; writing it once shows there is nothing mysterious underneath.
<?php
declare(strict_types=1);
final class FileLogger
{
private const NEVER_LOG = ['password', 'token', 'api_key', 'card_number'];
public function __construct(private readonly string $path)
{
}
public function log(string $level, string $event, array $context = []): void
{
$line = json_encode([
'at' => gmdate('c'),
'level' => $level,
'event' => $event,
'context' => $this->redact($context),
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
error_log($line . PHP_EOL, 3, $this->path);
}
private function redact(array $context): array
{
foreach ($context as $key => $value) {
if (in_array($key, self::NEVER_LOG, true)) {
$context[$key] = '[redacted]';
}
}
return $context;
}
}Logging a failure without logging the secret
The identifiers are the useful part: the request id ties this line to the access log and to anything else the request wrote, so one grep reconstructs the whole failure. The commented-out line is a real pattern in real code bases, and it is how plaintext passwords end up in log aggregators. Log the class, the message and the location, and go to the code for the rest.
<?php
declare(strict_types=1);
$logger = new FileLogger('/var/log/php/app.log');
set_exception_handler(static function (Throwable $e) use ($logger): void {
$logger->log('error', 'uncaught_exception', [
'class' => $e::class,
'message' => $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine(),
'request_id' => $_SERVER['HTTP_X_REQUEST_ID'] ?? 'none',
]);
http_response_code(500);
echo 'Something went wrong. Please try again.';
});
// Do not do this. The trace holds the arguments every call was given, and one
// of them is the plaintext password from the login form:
//
// $logger->log('error', 'uncaught', ['trace' => $e->getTrace()]);
//
// Log getTraceAsString() if you need the trace, and keep $_POST out of it.
Discussion