PHP-FPM Logging with Pipes and Monolog Formatting
The Problem with PHP-FPM Logging
When working with PHP-FPM, you might encounter an unexpected behavior: all logs end up in stderr, even when you explicitly write to stdout. This happens because PHP-FPM closes stdout during initialization, leaving only stderr available for output.
This creates issues when you want to separate different log levels or types of output. Application logs, error logs, and access logs all get mixed together in stderr, making it difficult to filter and process them effectively.
Understanding the Root Cause
PHP-FPM’s architecture closes stdout early in its lifecycle. When you configure error_log or use standard output streams, everything gets redirected to stderr by default. This is by design for FastCGI process management, but it’s not ideal for modern logging practices where you want structured, separated log streams.
The Solution: Pipes and Level Configuration
The proper approach is to use named pipes (FIFOs) combined with log level configuration to route logs correctly:
# Create named pipe
mkfifo /tmp/php-log.pipe
chmod 666 /tmp/php-log.pipe
Configure PHP-FPM pool to use these pipes:
[www]
catch_workers_output = yes
decorate_workers_output = no
; Route logs to pipe
php_admin_value[error_log] = /tmp/php-log.pipe
php_admin_flag[log_errors] = on
; Configure log levels
php_admin_value[error_reporting] = E_ALL
Monolog Integration with Format Detection
Monolog can automatically detect and format logs appropriately. For a simpler setup, use php://stdout and let bash handle the pipe redirection:
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use Monolog\Formatter\JsonFormatter;
use Monolog\Formatter\LineFormatter;
$logger = new Logger('app');
// Detect if running under FPM
$isFpm = php_sapi_name() === 'fpm-fcgi';
if ($isFpm) {
// Single handler writing to stdout
// Bash pipe will handle redirection based on log level
$handler = new StreamHandler('php://stdout', Logger::DEBUG);
// JSON formatter for structured logging
$jsonFormatter = new JsonFormatter(
JsonFormatter::BATCH_MODE_NEWLINES,
true // Include stack traces
);
$handler->setFormatter($jsonFormatter);
$logger->pushHandler($handler);
} else {
// Standard CLI logging with line format
$handler = new StreamHandler('php://stdout', Logger::DEBUG);
$handler->setFormatter(new LineFormatter());
$logger->pushHandler($handler);
}
JSON Output Example
With JsonFormatter, your logs will be structured and easily parseable:
{
"message": "User login successful",
"context": {
"user_id": 12345,
"ip": "192.168.1.1"
},
"level": 200,
"level_name": "INFO",
"channel": "app",
"datetime": "2025-11-15T10:30:45.123456+00:00",
"extra": {}
}
Processing Pipes with Bash Redirection
A robust shell script can handle multiple log formats and route them appropriately:
#!/bin/sh
# log-router.sh
LOG_PIPE="/tmp/php-log.pipe"
# Determines if a log line should go to stderr (error level)
# Returns 0 (true) for WARNING and above, 1 (false) otherwise
is_error_level() {
line="$1"
# Matches error-level logs that should go to stderr:
# - Monolog JSON: "level":300+ (WARNING=300, ERROR=400, CRITICAL=500, ALERT=550, EMERGENCY=600)
# - Monolog JSON: "level_name":"WARNING|ERROR|CRITICAL|ALERT|EMERGENCY"
# - Plain text formats: [WARNING], .WARNING:, WARNING -
# - PHP native errors: Fatal, Parse, Warning
# - Stack traces and exceptions
case "$line" in
*'"level":'[3-9][0-9][0-9]* | *'"level":'[1-9][0-9][0-9][0-9]* | \
*'"level_name":"WARNING"'* | *'"level_name":"ERROR"'* | \
*'"level_name":"CRITICAL"'* | *'"level_name":"ALERT"'* | \
*'"level_name":"EMERGENCY"'* | \
*'[WARNING]'* | *'[ERROR]'* | *'[CRITICAL]'* | *'[ALERT]'* | *'[EMERGENCY]'* | \
*'.WARNING:'* | *'.ERROR:'* | *'.CRITICAL:'* | *'.ALERT:'* | *'.EMERGENCY:'* | \
*'WARNING -'* | *'ERROR -'* | *'CRITICAL -'* | *'ALERT -'* | *'EMERGENCY -'* | \
*'PHP Fatal'* | *'PHP Parse'* | *'PHP Warning'* | *'Fatal error'* | \
*'Parse error'* | *'Catchable fatal'* | \
'#'[0-9]* | *'Stack trace:'* | *'Uncaught '* | *'Exception:'*)
return 0
;;
esac
return 1
}
setup_log_pipe() {
# Remove existing pipe if present
rm -f "$LOG_PIPE"
# Create named pipe with writable permissions
mkfifo "$LOG_PIPE"
chmod 666 "$LOG_PIPE"
}
start_log_router() {
# Background process that reads from pipe and routes to stdout/stderr
(
while true; do
if [ -p "$LOG_PIPE" ]; then
# Read line by line from the pipe
while IFS= read -r line; do
if [ -n "$line" ]; then
if is_error_level "$line"; then
printf '%s\n' "$line" >&2
else
printf '%s\n' "$line"
fi
fi
done < "$LOG_PIPE"
fi
# Small delay before reopening pipe (handles pipe closure)
sleep 0.1
done
) &
# Give router time to start
sleep 0.2
}
# Initialize pipe and router
setup_log_pipe
start_log_router
Docker Implementation
In containerized environments, integrate the log router into your entrypoint:
FROM php:8.2-fpm
COPY log-router.sh /usr/local/bin/
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/*.sh
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["php-fpm"]
#!/bin/bash
# docker-entrypoint.sh
DOCKER_CMD="$1"
case "$DOCKER_CMD" in
php-fpm | */php-fpm | *php-fpm*)
# Start log router for PHP-FPM
/usr/local/bin/log-router.sh
;;
esac
# Start the main process
exec "$@"
This approach handles:
- Monolog JSON format with numeric levels
- Monolog JSON format with level names
- Plain text log formats
- PHP native errors and warnings
- Stack traces and exceptions
Benefits
This approach provides:
- Proper separation of log levels and types
- Structured logging that works with log aggregation tools
- Automatic format detection by Monolog
- Clean integration with container logging systems
- No mixed stderr output
Keep pushing forward and savor every step of your coding journey.
