Health Checks and Monitoring
Two endpoints, one that touches nothing and one that checks the database, logs on stdout, and alerts that tell you what to do.
http_response_code(int $response_code = 0): int|boolA load balancer or container orchestrator needs a URL it can call to decide what to do with an instance. There are two different decisions, so there are two endpoints.
Liveness touches nothing
Make /health return 200 and a two-word body. No database, no cache, no session, no autoloaded framework if you can avoid it. It answers one question: is this process still able to run PHP. If it fails, the right action is to restart the container.
Readiness checks dependencies
/ready runs the cheapest real query there is, SELECT 1 through PDO, plus a ping of anything else the app cannot serve without. It returns 200 when every check passes and 503 when one does not, with a small JSON body naming which. If it fails, the right action is to take this instance out of rotation, not to kill it. http_response_code(503) is the shorthand for header('HTTP/1.1 503 Service Unavailable'); both set the same status line.
Logs go to stdout
In a container, write logs to the standard streams and let the platform collect them. Set error_log = /dev/stderr in php.ini, and in the FPM pool set catch_workers_output = yes with decorate_workers_output = no (PHP 7.3 and later) so lines arrive unprefixed. One JSON object per line, with a request id, turns grep into a search.
Alerts name an action
"5xx above 2% of requests for five minutes" tells someone what broke and what to look at. "CPU at 80%" tells them nothing, and after a week of it they stop reading.
Example
When to use it
- Give Kubernetes or an ELB a probe URL that marks an instance unhealthy for the right reason.
- Confirm a release is actually serving before deleting the previous one.
- Find every log line from one failed checkout by its request id.
More examples
The two endpoints as they ship
The two files are shown together to compare them; in the project they are separate scripts. ATTR_TIMEOUT keeps a dead database from holding the check open until the web server gives up. The exception message goes to the log and never into the response, because a connection error names your host and user. Cache-Control: no-store stops a proxy from answering the check from cache, which would report a healthy instance long after it stopped being one.
<?php
declare(strict_types=1);
// public/health.php - liveness. Loads nothing, touches nothing.
header('Content-Type: application/json');
header('Cache-Control: no-store');
http_response_code(200);
echo '{"status":"ok"}';
// ---------------------------------------------------------------
// public/ready.php - readiness. Cheapest possible real queries.
// (Its own file, so it opens with the same declare(strict_types=1).)
header('Content-Type: application/json');
header('Cache-Control: no-store');
$checks = [];
try {
$pdo = new PDO(
(string) getenv('DB_DSN'),
(string) getenv('DB_USER'),
(string) getenv('DB_PASSWORD'),
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_TIMEOUT => 2,
]
);
$pdo->query('SELECT 1');
$checks['database'] = 'up';
} catch (PDOException $e) {
error_log('readiness: database down: ' . $e->getMessage());
$checks['database'] = 'down';
}
$ready = !in_array('down', $checks, true);
http_response_code($ready ? 200 : 503);
echo json_encode([
'status' => $ready ? 'ready' : 'degraded',
'checks' => $checks,
], JSON_THROW_ON_ERROR);One JSON object per log line
error_log() writes to whatever error_log points at, so with /dev/stderr the same call that records a PHP warning records yours, and the platform picks both up. A request id threaded through every line is what lets you pull one visitor's whole request out of a million lines. Never put a password, a card number or a session id in the context array; logs are copied to places with weaker access control than your database.
<?php
declare(strict_types=1);
namespace Shop;
final class Log
{
private static string $requestId = '';
public static function begin(string $requestId): void
{
self::$requestId = $requestId;
}
public static function write(string $level, string $message, array $context = []): void
{
$line = json_encode([
'level' => $level,
'msg' => $message,
'request_id' => self::$requestId,
'context' => $context,
], JSON_UNESCAPED_SLASHES | JSON_PARTIAL_OUTPUT_ON_ERROR);
error_log((string) $line);
}
}
// Log::begin($_SERVER['HTTP_X_REQUEST_ID'] ?? bin2hex(random_bytes(8)));
// Log::write('warning', 'payment retry', ['order_id' => 8812, 'attempt' => 2]);Container logging settings
/dev/stderr and /proc/self/fd/2 are two names for the same stream; use whichever exists in your base image. Without catch_workers_output, anything a worker writes to stdout or stderr is discarded and your logs look empty. decorate_workers_output = no, available since PHP 7.3, removes the per-line prefix that otherwise breaks JSON parsing. daemonize = no keeps FPM in the foreground, which is what a container needs as its main process.
; /usr/local/etc/php/conf.d/99-logging.ini
display_errors = Off
log_errors = On
error_log = /dev/stderr
; /usr/local/etc/php-fpm.d/zz-logging.conf
[global]
error_log = /proc/self/fd/2
daemonize = no
[www]
catch_workers_output = yes
decorate_workers_output = no
access.log = /proc/self/fd/2
Discussion