6.4 KiB
| title | aliases | tags | sources | created | updated | |||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| PHP — display_errors Leaking Warnings into JSON API Responses |
|
|
|
2026-04-27 | 2026-04-27 |
PHP — display_errors Leaking Warnings into JSON API Responses
When PHP's display_errors is enabled, any warning, notice, or error emitted before the JSON response body is prepended to the output buffer. The API client receives something like <br/><b>Warning</b>: ...{"result": "..."} — valid JSON is now invalid because it's prefixed with HTML. JavaScript's JSON.parse() throws "Unexpected token '<'", which is the reliable diagnostic signal.
Key Points
- "Unexpected token '<'" in JSON.parse = PHP warning or error is being emitted before the JSON body — not a data problem, a
display_errorsproblem ini_set('display_errors', 0)inapi.phpdoes NOT overridedisplay_errors = 1in an included config file loaded after it — PHP processesini_setsequentially; a config file withdisplay_errors = 1loaded byrequire_once('config.php')after the ini_set undoes the suppressionconfig.example.phpcommitted withdisplay_errors = 1is a common footgun — developers copy it toconfig.phpon the server without changing the value- The fix: set
display_errors = 0directly inphp.inior.htaccess, or ensure the config file is loaded BEFORE the ini_set call, or use an output buffer (ob_start) to catch stray output before sending JSON - Probabilistic/intermittent bugs (10% of requests) that delete required directories are especially dangerous — they interact with display_errors to produce sporadic failures
Details
How the Leak Happens
// api.php — attempts to suppress errors
ini_set('display_errors', 0);
// Later in the request lifecycle:
require_once('config.php'); // sets display_errors = 1 — overrides the ini_set above
// Later still, something triggers a warning:
file_put_contents('/path/that/was/deleted', $data);
// PHP emits: "<br/><b>Warning</b>: file_put_contents(...)..."
// api.php then sends JSON:
header('Content-Type: application/json');
echo json_encode($result);
// Output: "<br/><b>Warning</b>: file_put_contents(...)... {\"key\":\"value\"}"
The client receives this mixed output, JSON.parse() sees < as the first character, and throws:
SyntaxError: Unexpected token '<', "<br><b>War"... is not valid JSON
Diagnosing the Root Cause
When a PHP API returns "Unexpected token '<'":
- Check
display_errorsinphp.ini:php -r "echo ini_get('display_errors');"— should be0in production - Check included config files:
grep -r "display_errors" /path/to/project/— find every location it's set - Check load order: if
config.phpis loaded afterini_set('display_errors', 0), the config wins - Test with curl to see raw output:
curl -i https://yourapi.com/endpoint— the HTML warning appears before the JSON
The Fix: Three Options
Option 1: Fix in php.ini or .htaccess (recommended)
; php.ini
display_errors = Off
log_errors = On
error_log = /var/log/php/errors.log
; .htaccess
php_flag display_errors Off
This applies regardless of what application code does.
Option 2: Set in config.php before any other code
// config.php — must be the FIRST setting, before any require_once
ini_set('display_errors', 0);
error_reporting(E_ALL);
ini_set('log_errors', 1);
ini_set('error_log', '/var/log/php/errors.log');
Option 3: Output buffer (belt-and-suspenders)
// api.php
ob_start(); // catch any stray output
require_once('config.php');
// ... application code ...
$output = ob_get_clean(); // clear the buffer
if (strpos($output, '<') !== false) {
error_log("Stray HTML in API output: " . substr($output, 0, 200));
}
echo json_encode($result); // only the JSON goes to the client
The Probabilistic Cleanup Bug (2026-04-27 Incident)
The actual bug that triggered the display_errors issue:
// ❌ DANGEROUS — auto-cleanup deletes the images/ directory itself, not just old files
function autoCleanupExpiredImages() {
if (rand(1, 10) === 1) { // 10% chance per request
$files = glob('images/*');
if (count($files) > 50) {
array_map('unlink', $files);
rmdir('images/'); // deletes the directory!
}
}
}
The cleanup ran in the same request as saveImage(), deleted the images/ directory, then saveImage() tried to write there and emitted a PHP warning. With display_errors = 1, that warning prepended the JSON response.
The defensive fix in saveImage():
function saveImage($filename, $data) {
if (!is_dir('images/')) {
mkdir('images/', 0755, true); // recreate if missing
}
file_put_contents('images/' . $filename, $data);
}
The deeper fix: cleanup functions should delete file contents, not the parent directory.
config.example.php as a Footgun
Any config.example.php committed to version control with display_errors = 1 becomes the template for production deployments. Developers copy it to config.php and never change the value. This is how display_errors = 1 ends up in production.
Correct config.example.php:
// config.example.php — safe defaults for production
define('DISPLAY_ERRORS', false);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
ini_set('error_log', __DIR__ . '/logs/errors.log');
// API Keys — replace with actual values
define('GEMINI_API_KEY', 'YOUR_KEY_HERE');
Related Concepts
- wiki/concepts/shell-static-deploy-patterns — deploy script patterns; similar "safe defaults in committed config" principle applies
- wiki/concepts/monorepo-deploy-script-pitfall — another class of "silent failure from config oversight"
- wiki/tech-patterns/nodejs-vanilla-proxy — Node.js API alternative where this class of PHP error doesn't apply
Sources
- daily/2026-04-27.md — Lux Studio (AI Cinematography):
JSON.parse"Unexpected token '<'" error; root cause was two bugs interacting:autoCleanupExpiredImages()deletingimages/directory (10% chance) +config.phphavingdisplay_errors = 1fromconfig.example.phptemplate; fixes:saveImage()recreates directory +display_errors = 0in config