Debugging and Logging in PHP
AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.
PHP’s default error behaviour manages to be unhelpful in both directions: a blank page during development, and a full stack trace shown to visitors in production. Both are solved by the same two-part setup.
Before you start
The examples are verified on PHP 8.4. For development, install Xdebug 3 — php -v should mention with Xdebug.
Two configurations
Development shows everything; production writes to a log and shows nothing:
// config/error.dev.php
declare(strict_types=1);
error_reporting(E_ALL);
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
ini_set('log_errors', '1');
// config/error.prod.php
declare(strict_types=1);
error_reporting(E_ALL);
ini_set('display_errors', '0'); // never print to the page
ini_set('log_errors', '1');
ini_set('error_log', __DIR__ . '/../storage/logs/php-error.log');
Choose between them at the entry point:
$env = getenv('APP_ENV') ?: 'prod';
require __DIR__ . "/config/error.{$env}.php";
display_errors=1 in production exposes file paths, SQL fragments and parts of credentials to anyone who triggers a warning. It is also one of the first things automated scanners look for.
Handling errors and exceptions
Errors (E_WARNING and friends) and exceptions (Throwable) are separate mechanisms, and both need a handler:
set_error_handler(function (int $severity, string $message, string $file, int $line): bool {
if (!(error_reporting() & $severity)) {
return false; // suppressed with @: let PHP handle it
}
throw new ErrorException($message, 0, $severity, $file, $line);
});
set_exception_handler(function (Throwable $e): void {
error_log(sprintf(
"[%s] %s: %s in %s:%d\n%s",
date('c'),
$e::class,
$e->getMessage(),
$e->getFile(),
$e->getLine(),
$e->getTraceAsString(),
));
if (!headers_sent()) {
http_response_code(500);
header('Content-Type: text/html; charset=utf-8');
}
echo '<h1>Something went wrong</h1><p>Please try again later.</p>';
});
register_shutdown_function(function (): void {
$error = error_get_last();
if ($error && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR], true)) {
error_log("[fatal] {$error['message']} in {$error['file']}:{$error['line']}");
}
});
Converting warnings into exceptions makes “we ignored the return value” fail during testing instead of silently. The error_reporting() check is essential: without it, every @ in a third-party library turns into an exception and their defensive code stops working.
Fatal errors — parse errors, memory exhaustion — never reach set_exception_handler. Only the shutdown function plus error_get_last() can record them.
Structured logs
function log_event(string $level, string $message, array $context = []): void
{
$record = [
'time' => date('c'),
'level' => $level,
'message' => $message,
'request' => $_SERVER['HTTP_X_REQUEST_ID'] ?? bin2hex(random_bytes(8)),
'context' => $context,
];
error_log(json_encode($record, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
log_event('info', 'post published', ['post_id' => 42, 'user_id' => 7]);
One JSON object per line is directly consumable by grep, jq and every log pipeline. Generate the request ID at the entry point and echo it back in a header, so a user-reported error can be traced:
$requestId = $_SERVER['HTTP_X_REQUEST_ID'] ?? bin2hex(random_bytes(8));
header("X-Request-Id: {$requestId}");
Never log passwords, full identity numbers, card data or session IDs. Log the user ID when you need to correlate.
Levels and files
enum LogLevel: string
{
case Debug = 'debug';
case Info = 'info';
case Warning = 'warning';
case Error = 'error';
}
debug: development only, switched off in production.info: significant business events (login, order placed, post published).warning: recoverable problems (a retry succeeded, a degraded response).error: something a human needs to look at.
Rotate by day so a single file does not grow without bound:
$file = __DIR__ . '/../storage/logs/app-' . date('Y-m-d') . '.log';
error_log($line . "\n", 3, $file);
Make the log directory writable only by the application user, and keep it outside the web root — or deny access explicitly in Nginx/Apache. A downloadable /logs/app.log leaks everything you carefully avoided logging.
Xdebug breakpoints
Key php.ini settings for Xdebug 3:
xdebug.mode=debug,develop
xdebug.start_with_request=yes
xdebug.client_port=9003
xdebug.idekey=VSCODE
Start listening in your IDE, set a breakpoint, reload the page, and execution stops there with locals, the call stack and step controls. It beats scattering var_dump through the codebase.
For quick checks without Xdebug:
var_dump($value); // type and structure
error_log(print_r($value, true));
debug_zval_dump($value); // reference counts, when references are the suspect
Timing a block:
$start = microtime(true);
// ... code
error_log('took ' . round((microtime(true) - $start) * 1000, 2) . ' ms');
Triage order for a blank page
- Check whether
display_errorsis off and whether theerror_logpath is writable. - Run the entry point from the CLI:
php public/index.php. This bypasses the web server and usually prints the real error. - Check the syntax:
php -lcatchesE_PARSEerrors that never reach the log. - Read the web server’s error log (Nginx
error_log, Apacheerror_log). - Check
memory_limitandmax_execution_time.
A blank page with a 500 status is almost always a fatal error that was configured not to display.
Error pages in production
// register early, before anything can fail half-rendered
if ($env === 'prod') {
ini_set('display_errors', '0');
}
Keep the error page itself trivial: no database, no menus, no session. Static HTML is the most reliable thing you can serve when the application is broken.
Common mistakes
1. Shipping leftover var_dump calls
They print internal structures to visitors. Search for var_dump / print_r before committing, or ban them with a lint rule.
2. Using @ to silence everything
@$result = $obj->method() swallows fatal problems. Check the return value or wrap in try/catch instead.
3. Logging sensitive data
Including SQL parameters interpolated into exception messages. Filter before logging, or log an allow-listed set of fields.
4. Logging only $e->getMessage()
Without file and line it is nearly useless. Log $e::class, getFile() and getLine() as well.
5. Log files inside the web root
/logs/app.log can be downloaded. Move it out, or deny it in the server config.
6. Inconsistent time zones
Set date_default_timezone_set('Asia/Shanghai') (or the relevant zone) or date.timezone in php.ini, otherwise log timestamps will not line up with application data.
7. Using echo for debugging
It lands in the middle of an HTML page or a JSON response and corrupts the format. Debug output goes through error_log; echo only when the consequence is fully understood.
Summary
display_errorson in development; log-only plus a generic error page in production.- Take over both mechanisms:
set_error_handler,set_exception_handler,register_shutdown_function. - One JSON object per line, always with a request ID, never with sensitive fields.
- Xdebug breakpoints beat scattered
var_dump;error_log(print_r($v, true))for quick checks. - Blank page: logs and
php -lfirst, then the web server and resource limits.
Next: error handling in depth, then a full PDO-backed application.