--- title: "PHP — display_errors Leaking Warnings into JSON API Responses" aliases: [php-display-errors, php-json-api-errors, php-unexpected-token, php-ini-set-override] tags: [php, debugging, api, json, backend, production] sources: - "daily/2026-04-27.md" created: 2026-04-27 updated: 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 `
Warning: ...{"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_errors` problem - **`ini_set('display_errors', 0)` in `api.php` does NOT override `display_errors = 1` in an included config file loaded after it** — PHP processes `ini_set` sequentially; a config file with `display_errors = 1` loaded by `require_once('config.php')` after the ini_set undoes the suppression - **`config.example.php` committed with `display_errors = 1`** is a common footgun — developers copy it to `config.php` on the server without changing the value - **The fix**: set `display_errors = 0` directly in `php.ini` or `.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 ```php // 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: "
Warning: file_put_contents(...)..." // api.php then sends JSON: header('Content-Type: application/json'); echo json_encode($result); // Output: "
Warning: file_put_contents(...)... {\"key\":\"value\"}" ``` The client receives this mixed output, `JSON.parse()` sees `<` as the first character, and throws: ``` SyntaxError: Unexpected token '<', "
War"... is not valid JSON ``` ### Diagnosing the Root Cause When a PHP API returns "Unexpected token '<'": 1. **Check `display_errors` in `php.ini`:** `php -r "echo ini_get('display_errors');"` — should be `0` in production 2. **Check included config files:** `grep -r "display_errors" /path/to/project/` — find every location it's set 3. **Check load order:** if `config.php` is loaded after `ini_set('display_errors', 0)`, the config wins 4. **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)** ```ini ; php.ini display_errors = Off log_errors = On error_log = /var/log/php/errors.log ``` ```apache ; .htaccess php_flag display_errors Off ``` This applies regardless of what application code does. **Option 2: Set in config.php before any other code** ```php // 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)** ```php // 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: ```php // ❌ 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()`: ```php 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`: ```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()` deleting `images/` directory (10% chance) + `config.php` having `display_errors = 1` from `config.example.php` template; fixes: `saveImage()` recreates directory + `display_errors = 0` in config